toga-ai 1.0.394 → 1.0.396

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@
6
6
  | [BDR Web Funnel — Full Implementation Plan](features/bdr-web-funnel-plan.md) | > **Execution status (2026-07-16):** PLAN **Phases 0–5 are BUILT and QA'd**, Phase 6 > was **mostly already built** (the `info` contract was implemented at comm | bdr/PLAN.md, bdr/mockup/app.jsx, bdr/mockup/screens.jsx, bdr/mockup/components.jsx |
7
7
  | [Call Orchestration — PHP Worker ↔ Vapi (the integration seam)](features/call-orchestration.md) | The **PHP worker** is the orchestrator; **Vapi** is the actor. | ai-bdr/docs/client-onboarding-sop.md, ai-bdr/docs/vapi-firstmessage-timing-fix.md, ai-bdr/docs/vapi-inbound-callback-assistant-request.md, ai-bdr/docs/vapi-voicemail-iphone-screening.md, ai-bdr/scripts/update_structured_output.py |
8
8
  | [Vapi Integration — Assistants, Tools, Structured Output](features/vapi-integration.md) | Everything inside Vapi: the three assistants, the three shared tools, the shared structured-output schema, the Liquid-templated system prompt, and the Python sc | ai-bdr/vapi/templates/assistant.template.json, ai-bdr/vapi/templates/system-prompt.template.md, ai-bdr/prompts/archive/prompt-may-15.txt, ai-bdr/prompts/campaigns/healthcare-2026-03-18.md, ai-bdr/prompts/campaigns/healthcare-v2-2026-03-25.md, ai-bdr/prompts/templates/campaign-template.md, ai-bdr/scripts/update_assistant.py, ai-bdr/scripts/update_system_prompt.py, ai-bdr/scripts/update_structured_output.py, ai-bdr/scripts/update_call_summary_context.py, ai-bdr/scripts/fix_booking_guardrails.py, ai-bdr/scripts/get_assistant.py, ai-bdr/docs/vapi-firstmessage-timing-fix.md, ai-bdr/docs/vapi-voicemail-iphone-screening.md |
9
- | [Web Funnel — Built Next.js App (structure, stack, how to run)](features/web-funnel-app.md) | The **built** state of the BDR web funnel — the Next.js UI / lead-capture front door of the existing AI-BDR product (see `../architecture.md`). | bdr/PLAN.md, bdr/.gitignore, bdr/eslint.config.mjs, bdr/amplify.yml, bdr/src/app, bdr/src/app/layout.tsx, bdr/src/app/page.tsx, bdr/src/app/studio.css, bdr/src/lib/resolveCampaignId.ts, bdr/src/lib/scheduleSlots.ts, bdr/src/lib/formatPhone.ts, bdr/src/lib/analytics.ts, bdr/src/lib/rateLimit.ts, bdr/src/lib/clientIp.ts, bdr/src/proxy.ts, bdr/.env.example, bdr/src/components/GoogleAnalytics.tsx, bdr/src/components/RichText.tsx, bdr/src/content, bdr/src/components, bdr/src/flow, bdr/src/server, bdr/src/server/toga.ts, bdr/src/app/api, bdr/test, bdr/test/togaPrimaryPhone.test.ts, bdr/public, bdr/mockup/styles.css |
9
+ | [Web Funnel — Built Next.js App (structure, stack, how to run)](features/web-funnel-app.md) | The **built** state of the BDR web funnel — the Next.js UI / lead-capture front door of the existing AI-BDR product (see `../architecture.md`). | bdr/PLAN.md, bdr/.gitignore, bdr/src/server/hubspot.ts, bdr/src/server/leadSink.ts, bdr/src/server/callbackService.ts, bdr/src/server/workerDialer.ts, bdr/src/lib/shareMailto.ts, bdr/src/flow/screens/CallNow.tsx, bdr/src/flow/screens/Schedule.tsx, bdr/src/flow/screens/CallSummary.tsx, bdr/src/content/schema.ts, bdr/src/content/default.ts, bdr/test/togaEnrichment.test.ts, bdr/test/fastDial.test.ts, bdr/test/shareMailto.test.ts, bdr/eslint.config.mjs, bdr/amplify.yml, bdr/src/app, bdr/src/app/layout.tsx, bdr/src/app/page.tsx, bdr/src/app/studio.css, bdr/src/lib/resolveCampaignId.ts, bdr/src/lib/scheduleSlots.ts, bdr/src/lib/formatPhone.ts, bdr/src/lib/analytics.ts, bdr/src/lib/rateLimit.ts, bdr/src/lib/clientIp.ts, bdr/src/proxy.ts, bdr/.env.example, bdr/src/components/GoogleAnalytics.tsx, bdr/src/components/RichText.tsx, bdr/src/content, bdr/src/components, bdr/src/flow, bdr/src/server, bdr/src/server/toga.ts, bdr/src/app/api, bdr/test, bdr/test/togaPrimaryPhone.test.ts, bdr/public, bdr/mockup/styles.css |
10
10
  | [Web Funnel — Next.js UI + Config-Driven Campaign Content](features/web-funnel-content-model.md) | > **Status note (RESOLVED 2026-07-16):** the content **contract** below (schema, > provider seam, adapters) is BUILT and QA'd (PLAN Phases 0–5). | bdr/src/content/schema.ts, bdr/src/content/provider.ts, bdr/src/content/useCampaign.ts, bdr/src/content/default.ts, bdr/src/lib/resolveCampaignId.ts, bdr/src/server/leadSink.ts, bdr/src/server/callbackService.ts, bdr/src/app/api/call-now/route.ts, bdr/src/app/api/call-later/route.ts, bdr/src/app/api/contact/route.ts |
11
11
  | [New-Campaign Onboarding (6-phase runbook)](workflows/new-campaign-onboarding.md) | The end-to-end procedure for launching a new outbound BDR campaign (industry, offer, target persona, geography). | ai-bdr/docs/client-onboarding-sop.md, ai-bdr/docs/calcom-new-campaign-event-guide.md, ai-bdr/prompts/templates/campaign-template.md, ai-bdr/scripts/update_assistant.py, ai-bdr/scripts/update_system_prompt.py |
12
- | [Safe AI-BDR Call-Loop Testing (isolated test-campaign runbook)](workflows/safe-call-loop-testing.md) | How to prove the AI-BDR **dialer → Vapi → end-of-call webhook** loop end-to-end **without dialing real prospects**. | dbchanges2/Client_True/2026-07-20a - AiBdrTcoxTestCampaign.sql, worker2/Worker/Ai/Bdr/Vapi.php, worker2/Config/beta.ini |
12
+ | [Safe AI-BDR Call-Loop Testing (isolated test-campaign runbook)](workflows/safe-call-loop-testing.md) | How to prove the AI-BDR **dialer → Vapi → end-of-call webhook** loop end-to-end **without dialing real prospects**. | dbchanges2/Client_True/2026-07-20a - AiBdrTcoxTestCampaign.sql, worker2/Worker/Ai/Bdr/Vapi.php, worker2/Worker/Ai/Bdr/ZoomInfo.php, worker2/Config/beta.ini |
@@ -6,8 +6,8 @@ project: AI-BDR
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-16
10
- owners: [akhokhani]
9
+ updated: 2026-07-21
10
+ owners: [akhokhani, tcox]
11
11
  files:
12
12
  - ai-bdr/vapi/templates/assistant.template.json
13
13
  - ai-bdr/vapi/templates/system-prompt.template.md
@@ -71,6 +71,14 @@ firstMessage, system prompt, and the phone number wired to them.
71
71
  - Healthcare — `+1 (331) 244-5958` — `0c058f55-1610-413c-ae04-c71b30b33a43`
72
72
  - Tech demo — `+1 (331) 281-1679` — `0505c46e-1150-4814-9a5c-60ad44287abf`
73
73
 
74
+ **A campaign's caller ID is its `Campaigns.c_fromPhoneNumberIdentifier`, not "the primary BDR
75
+ number."** Live example: campaign 2 (`26.05 - AI BDR - Ryan Nitti`) carries
76
+ `c_fromPhoneNumberIdentifier = a5d6cbe1-073b-4288-95e8-853ec6782a96`, which maps to the
77
+ **Healthcare** number `+1 (331) 244-5958` — so that campaign dials from the healthcare caller ID,
78
+ not the primary BDR line. `buildVapiPayload` (worker2 dialer) **hard-requires** this field — a
79
+ campaign created without it cannot place a call (see
80
+ `../workflows/safe-call-loop-testing.md`).
81
+
74
82
  ## Tools (3, shared across assistants)
75
83
 
76
84
  | Tool | Id | Purpose |
@@ -114,7 +122,7 @@ Returned in `message.analysis.structuredData` on every `end-of-call-report`:
114
122
  | `contactEmail` | string | Email the prospect confirmed; empty if not collected |
115
123
  | `emailMatchedKnown` | boolean | True if confirmed email matches `knownEmail` from variableValues |
116
124
  | `meetingTime` | ISO 8601 | Present if `callOutcome=meeting_booked` |
117
- | `callbackTime` | ISO 8601 | Present if `callOutcome=callback_scheduled`; backend converts a spoken time to ISO |
125
+ | `callbackTime` | ISO 8601 | Present if `callOutcome=callback_scheduled`; backend converts a spoken time to ISO. **BUG (2026-07-21): the model frequently emits natural language with spelled-out numbers (e.g. `"in ten minutes"`) which the PHP parser cannot handle — the callback is then silently dropped. See the convertDateTime gotcha in [worker2 vapi-webhook-handler.md](../../worker2/features/vapi-webhook-handler.md). Fix candidate on this side: constrain this field's schema to ISO 8601 or a numeric-relative format so the model never spells the number out.** |
118
126
  | `callbackContactPreference` | string | `phone` / `email` / `sms` |
119
127
  | `callType` | string | Mirrors the `callType` variable injected on this call |
120
128
  | `nextCallOpeningContext` | string | One-sentence coaching note for the next attempt's opener |
@@ -153,10 +161,15 @@ ensure UA spoofing is preserved or you'll see 403s with no body.
153
161
  - **Voicemail detection isn't perfect on iPhone "Silence Unknown Callers".**
154
162
  See `docs/vapi-voicemail-iphone-screening.md` for the screening
155
163
  workaround.
156
- - **First-message timing (Option B)** — the assistant waits for the human's
157
- first phoneme before speaking, via the `status-update` server message.
158
- Don't unsubscribe from `status-update` without re-checking the prompt's
159
- opener logic.
164
+ - **First-message timing (Option B) — TESTERS MUST SPEAK FIRST.** The healthcare assistant
165
+ (`Campaign - HealthCare`, `0c058f55-1610-413c-ae04-c71b30b33a43`) is configured
166
+ `firstMessageMode = "assistant-waits-for-user"`: it will **not** utter a word until the callee
167
+ speaks. This is the documented voicemail/iPhone-screening workaround (Option B), but it means a
168
+ **silent answerer gets silence** — confirmed live 2026-07-21 with an 8-second mutual-silence call
169
+ that Vapi ended with `endedReason = customer-ended-call`. When manually testing this campaign,
170
+ **say "Hello?" after answering** or the call dies silently. The assistant waits for the human's
171
+ first phoneme via the `status-update` server message; don't unsubscribe from `status-update`
172
+ without re-checking the prompt's opener logic.
160
173
  - **`GetBookingSlots` can span a weekend.** `fix_booking_guardrails.py`
161
174
  enforces a Mon–Fri 9–5 default; relax it cautiously.
162
175
  - **`previousCallSummary` is regenerated per call** — don't try to persist
@@ -168,4 +181,11 @@ ensure UA spoofing is preserved or you'll see 403s with no body.
168
181
  `callSummary` over a schema fork.
169
182
 
170
183
  ## Change history
184
+ - 2026-07-21 — Live go-live findings. Recorded that the healthcare assistant runs
185
+ `firstMessageMode = "assistant-waits-for-user"` (Option B) so a silent answerer gets silence
186
+ (confirmed: 8s mutual-silence call, `endedReason=customer-ended-call`) — testers must speak first.
187
+ Documented that a campaign's caller ID is its `c_fromPhoneNumberIdentifier` (campaign 2's
188
+ `a5d6cbe1-…` → the Healthcare number, not the primary BDR line) and that `buildVapiPayload`
189
+ hard-requires that field. Flagged the `callbackTime` spelled-out-number parse bug (schema-side fix
190
+ candidate: constrain to ISO/numeric-relative) — full detail on the worker2 webhook doc. (tcox)
171
191
  - 2026-06-16 — Initial Vapi integration feature doc. (akhokhani)
@@ -6,11 +6,24 @@ project: AI-BDR
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-20
9
+ updated: 2026-07-21
10
10
  owners: [tcox]
11
11
  files:
12
12
  - bdr/PLAN.md
13
13
  - bdr/.gitignore
14
+ - bdr/src/server/hubspot.ts
15
+ - bdr/src/server/leadSink.ts
16
+ - bdr/src/server/callbackService.ts
17
+ - bdr/src/server/workerDialer.ts
18
+ - bdr/src/lib/shareMailto.ts
19
+ - bdr/src/flow/screens/CallNow.tsx
20
+ - bdr/src/flow/screens/Schedule.tsx
21
+ - bdr/src/flow/screens/CallSummary.tsx
22
+ - bdr/src/content/schema.ts
23
+ - bdr/src/content/default.ts
24
+ - bdr/test/togaEnrichment.test.ts
25
+ - bdr/test/fastDial.test.ts
26
+ - bdr/test/shareMailto.test.ts
14
27
  - bdr/eslint.config.mjs
15
28
  - bdr/amplify.yml
16
29
  - bdr/src/app
@@ -93,7 +106,9 @@ to its ship state (trusted-IP position for X-Forwarded-For; O(1) LRU-capped limi
93
106
  - **ESLint flat config** carries a **no-em-dash rule** on shipped strings
94
107
  (`Literal` / `TemplateElement` / `JSXText` selectors) — enforces the mockup copy
95
108
  rule on in-repo strings (`eslint.config.mjs`).
96
- - **Vitest** test suite: **114 tests, all green** (10 added 2026-07-20 for the primary-phone
109
+ - **Vitest** test suite: **131 tests, all green** (2026-07-21 added `togaEnrichment.test.ts`,
110
+ `fastDial.test.ts`, `shareMailto.test.ts` for the enrichment / fast-dial / share features below;
111
+ 10 added 2026-07-20 for the primary-phone
97
112
  self-heal — null-pointer re-link, changed-number re-link, create-as-last-resort, strict
98
113
  `requestCall` vs. absorbing `upsertContactByEmail`; 13 added in the first 2026-07-16 session:
99
114
  phone format/prefill/mask, live-anchor consent links, `submitOk` race; the later 2026-07-16
@@ -168,6 +183,10 @@ to its ship state (trusted-IP position for X-Forwarded-For; O(1) LRU-capped limi
168
183
  number next to the fixed `+1` country-code field would dial the wrong country). CallNow
169
184
  + Schedule init their number fields from it. (The old `info` site exposed name + email +
170
185
  phone the same way; BDR reduces the client-crossing PII to **phone only**.)
186
+ **Fallback (2026-07-21):** when HubSpot has no phone for the contact, primeLead now falls back to
187
+ the **Toga contact's existing primary phone** (`LeadRef` gained an optional `phone`) — the one
188
+ real parity gap the info-vs-BDR audit found (info's `defaultPhone` behavior). So a known lead
189
+ whose CRM record carries no phone but whose Toga record does still sees their number pre-filled.
171
190
  - **Live phone input mask.** `formatPhoneInput()` (`src/lib/formatPhone.ts`) renders a
172
191
  progressive `(555) 012-3456` as the user types: hard 10-digit cap (`maxLength=14` on the
173
192
  inputs), strips non-digits on paste, idempotent, and backspace-safe by design (formatting
@@ -247,6 +266,93 @@ source of truth for "why isn't my contact being called." Full rules are document
247
266
  injection to true reverse/has-many relations). The self-heal covers the funnel meanwhile; the
248
267
  same api2 bug likely affects `primaryContactEmailAddress` / `primaryContactAddress` on update.
249
268
 
269
+ ## Contact enrichment (funnel-side, feeds the Vapi opener)
270
+
271
+ The funnel enriches the Toga contact at page-prime so the AI-BDR call opener has real context
272
+ (`src/server/hubspot.ts`, `src/server/toga.ts`, `src/server/leadSink.ts`, `src/app/page.tsx`; tests
273
+ `test/togaEnrichment.test.ts`). The HubSpot fetch was extended to pull **industry / city / state /
274
+ website** on top of the existing properties and maps them two ways:
275
+
276
+ - **Direct Toga columns (worker2's dialer reads these with NO callContext fallback):**
277
+ `jobtitle → Contacts.title`, `company → Contacts.doingBusinessAs`, `industry → Contacts.industry`.
278
+ `buildVapiPayload` reads `title` / `companyName` / `companyIndustry` **directly from these columns**
279
+ and does **not** fall back to `callContext` for them — so if they aren't on the contact row, the
280
+ opener has nothing.
281
+ - **`callContext` JSON keys (the dialer DOES read these as fallback):** `city → personCity`,
282
+ `state → personState`, `website → companyWebsite`.
283
+
284
+ Rules that keep enrichment safe:
285
+ - **Fill-if-empty only** — ZoomInfo CSV import data always wins; enrichment never overwrites a
286
+ populated field (ZoomInfo is how outbound-list contacts get these fields in the first place — see
287
+ the parity audit below).
288
+ - **`callContext` merges** with any existing keys winning.
289
+ - **Enrichment ships as a separate absorbed `PUT`** so that an ACL-rejected field can never break
290
+ core lead capture — the enrichment write is best-effort; the lead-capture write is not.
291
+ - **Live-verified 2026-07-21:** contact 248's first real funnel pass set `title = INTERN` and
292
+ `doingBusinessAs = "Agilant Solutions, Inc."`, and the Vapi opener used them.
293
+
294
+ ## Fast dial (skip the up-to-60s cron wait)
295
+
296
+ After a successful **Call Now** write, BDR optionally POSTs the worker's single-contact entry point
297
+ so the call fires in **seconds** instead of waiting up to 60s for the dialer cron
298
+ (`src/server/workerDialer.ts` NEW, `src/server/callbackService.ts`; test `test/fastDial.test.ts`).
299
+
300
+ - Calls the worker action `Ai/Bdr/Vapi/InitiateOutboundCall` with the contact's **primary
301
+ phone-row uuid**.
302
+ - **Gated by optional env `TOGA_WORKER_URL`** — unset ⇒ cron-only (no behavior change).
303
+ - **Strictly best-effort:** 5s timeout, all failures absorbed + logged; the cron remains the
304
+ fallback, and the dialer's own in-progress check prevents a double-dial.
305
+ - **Scheduled calls are deliberately excluded** — they wait for their slot.
306
+ - **Motivation:** the Success screen's "Connecting" animation is scripted theater (~5s) while the
307
+ cron could take 60s; fast dial makes the theater roughly honest.
308
+ - **SECURITY note / ticket:** the worker's direct-action endpoint accepts **unauthenticated** POSTs
309
+ (a pre-existing exposure — now load-bearing for a product feature). Flag for hardening; it is not
310
+ introduced by BDR but BDR now depends on it.
311
+
312
+ ## Anonymous-visit guard (no lead reference ⇒ disabled submit)
313
+
314
+ With **no** lead reference (the page was opened without `?hsContactId=`), both submit screens now
315
+ **disable the button** and show a content-driven notice ("We couldn't find your contact reference…
316
+ open this page from the link in your email"), replacing the previous silent no-op that faked success
317
+ (`src/flow/screens/CallNow.tsx`, `src/flow/screens/Schedule.tsx`, `src/content/schema.ts`,
318
+ `src/content/default.ts`). This matches info's "Missing contact reference" behavior. Production links
319
+ come from HubSpot personalization tokens —
320
+ `?campaign=slug&hsContactId={{contact.hs_object_id}}&hsCampaignId=uuid` — or, when the campaign
321
+ bundle pins `togaCampaignUuid`, links need only the slug + contact token.
322
+
323
+ ## Share button is a real mailto: (no server-send path)
324
+
325
+ The CallSummary "Send" button now builds a **`mailto:` URL** (recipient, subject, the displayed
326
+ summary as body) and opens the visitor's own mail client — a real share with **no server-side send
327
+ path**, which deliberately avoids the spam-relay surface of server-sending client-supplied text
328
+ (`src/lib/shareMailto.ts` NEW, `src/flow/screens/CallSummary.tsx`, `src/content/default.ts`; test
329
+ `test/shareMailto.test.ts`). Confirmation copy is honest ("Opening your email app").
330
+
331
+ - **LIMITATION:** the summary **content** is still the **simulated rotation** — the real
332
+ `c_callSummary` can't reach the frontend because the BDR API client gets **403 on
333
+ `ContactAttempts`**.
334
+ - **Pending backend ask (DB ACL config, no deploy):** a read-only grant for the BDR API client on
335
+ `ContactAttempts` fields `uuid` / `contactId` / `dtStarted` / `dtEnded` / `c_callSummary` /
336
+ `c_callOutcome`. **One grant unlocks three features:** the real summary in the card, the real
337
+ summary in Copy/Share, and a live call-status indicator.
338
+
339
+ ## Parity audit — `info` (original) vs `bdr` (new) (§ closed 2026-07-21)
340
+
341
+ A full audit of the original `info` funnel vs the new BDR funnel closed the open "did we port
342
+ everything?" question in this doc:
343
+
344
+ - **All API work is ported** — routes, the identical 6-property HubSpot fetch, the Toga payloads.
345
+ - **The `c_*` / title / industry enrichment was populated by NEITHER repo** — it is **net-new**
346
+ work (see Contact enrichment above), **not** a port miss. Outbound-list contacts normally get
347
+ those fields from the **ZoomInfo CSV import**.
348
+ - **The `visit` route was intentionally dropped** per PLAN.md (dead 501, no caller).
349
+ - **The only real parity gap was phone prefill fallback** to the Toga contact's existing phone —
350
+ now shipped (see the phone-prefill fallback note above).
351
+ - **Dead env vars:** `TOGA_PUBLIC_TOKEN_URL` and `NEXT_PUBLIC_ENV` were declared but **never read**
352
+ even in info's own code — do not carry them into BDR.
353
+ - **BDR deploy env vars = 7 total:** the 5 server-side ones + `NEXT_PUBLIC_GA_MEASUREMENT_ID` +
354
+ (new 2026-07-21) **`TOGA_WORKER_URL`** (the fast-dial gate; unset ⇒ cron-only).
355
+
250
356
  ## Local dev setup (BDR)
251
357
 
252
358
  BDR local dev needs **5 server-side env vars** in `BDR/.env.local` (gitignored, never
@@ -281,7 +387,9 @@ up — ClickUp is stale, confirm the real owner before assuming.)
281
387
  - **5 server-side env vars are required** (same set as local dev): `TOGA_API_BASE_URL`,
282
388
  `TOGA_CLIENT_ID`, `TOGA_CLIENT_API_UUID`, `TOGA_CLIENT_API_SECRET`, `HUBSPOT_ACCESS_TOKEN`.
283
389
  **Where the values live (never the values):** the `info` repo's `.env.local` (production
284
- credential set). Add them in the Amplify console environment config — never commit them.
390
+ credential set). Add them in the Amplify console environment config — never commit them. Two
391
+ optional vars beyond the 5: `NEXT_PUBLIC_GA_MEASUREMENT_ID` (GA4) and **`TOGA_WORKER_URL`** (the
392
+ fast-dial gate — unset ⇒ cron-only). See the parity audit for the full 7-var accounting.
285
393
  - **Phase 8 WAF pairing.** `BDR/PLAN.md` Phase 8 specifies a **per-IP rate rule on the CloudFront
286
394
  distribution / WAF** that pairs with the in-app token-bucket limiter (the app limiter is
287
395
  defense-in-depth only; the edge WAF rule is the authoritative control — see the enumeration
@@ -416,6 +524,26 @@ up — ClickUp is stale, confirm the real owner before assuming.)
416
524
  harness; pull it with `npx toga-ai`.
417
525
 
418
526
  ## Change history
527
+ - 2026-07-21 — Go-live continuation. **The full AI-BDR call loop was proven end-to-end in
528
+ production** (campaign 2, `26.05 - AI BDR - Ryan Nitti`): UI submit → funnel API → dialer → Vapi
529
+ call → webhook write-back (test method now in `../workflows/safe-call-loop-testing.md`). BUILT:
530
+ (1) **funnel-side contact enrichment** — HubSpot fetch extended with industry/city/state/website,
531
+ mapping title/company/industry to the Toga columns the dialer reads with NO fallback and
532
+ city/state/website into `callContext` (fill-if-empty, ZoomInfo wins, absorbed separate PUT;
533
+ live-verified on contact 248); (2) **phone-prefill fallback** to the Toga contact's existing
534
+ primary phone when HubSpot has none (the one real parity gap); (3) **fast dial** —
535
+ `src/server/workerDialer.ts` POSTs the worker's `InitiateOutboundCall` after a Call Now write so
536
+ the call fires in seconds not up to 60s, gated by optional `TOGA_WORKER_URL`, best-effort,
537
+ scheduled calls excluded (NOTE: the worker direct-action endpoint is unauthenticated — pre-existing
538
+ exposure now load-bearing); (4) **anonymous-visit guard** — no lead ref disables both submit
539
+ buttons with a content-driven notice instead of faking success; (5) **share button** made a real
540
+ `mailto:` (no server send path). Closed the **info-vs-BDR parity audit** (all API work ported; the
541
+ `c_*`/enrichment fields are net-new not a port miss; `visit` route intentionally dropped;
542
+ `TOGA_PUBLIC_TOKEN_URL`/`NEXT_PUBLIC_ENV` are dead; deploy = 7 env vars). LIMITATION recorded: the
543
+ share/card summary is still the simulated rotation because the BDR API client gets 403 on
544
+ `ContactAttempts` — pending a read-only ACL grant (one grant unlocks three features). Suite at
545
+ **131 green**. All of today's BDR work was **uncommitted at capture** (commits go to
546
+ `BDR-Phase-2` → `_dev-sandbox`). (tcox)
419
547
  - 2026-07-20 — FIXED the phone-less-contact dialer bug on the funnel side: shipped a primary-phone
420
548
  **self-heal** in `src/server/toga.ts` (commit `4ac579e`) — after any contact write carrying a
421
549
  phone, if the echoed `primaryContactPhoneNumber` is null or its last-10 digits mismatch, find/create
@@ -6,11 +6,12 @@ project: AI-BDR
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-07-20
9
+ updated: 2026-07-21
10
10
  owners: [tcox]
11
11
  files:
12
12
  - dbchanges2/Client_True/2026-07-20a - AiBdrTcoxTestCampaign.sql
13
13
  - worker2/Worker/Ai/Bdr/Vapi.php
14
+ - worker2/Worker/Ai/Bdr/ZoomInfo.php
14
15
  - worker2/Config/beta.ini
15
16
  related:
16
17
  - new-campaign-onboarding.md
@@ -24,9 +25,16 @@ How to prove the AI-BDR **dialer → Vapi → end-of-call webhook** loop end-to-
24
25
  dialing real prospects**. The production dialer cron (`Core.CronJobs` "AI BDR",
25
26
  `ProcessAllActiveCampaigns`, schedule `* * * * *`) fires **every minute**, so any campaign that
26
27
  becomes eligible starts dialing live prospects **within 60 seconds** — a test must be built so
27
- that the *only* contact that can ever be dialed is a deliberate test contact. The safe technique
28
- is an **isolated one-contact test campaign with no call windows**. Eligibility mechanics that make
29
- this work are in [worker2 vapi-webhook-handler.md](../../worker2/features/vapi-webhook-handler.md).
28
+ that the *only* contact that can ever be dialed is a deliberate test contact. There are **two safe
29
+ vehicles**, both proven live:
30
+
31
+ - **Approach A — reuse an EXISTING campaign, DNC-zero everyone first (Rule 2).** The go-live
32
+ approach; use it when you must prove the *real* campaign works end-to-end.
33
+ - **Approach B — an isolated one-contact test campaign with no call windows (Rule 2b).** Use it
34
+ when you only need to rehearse the loop mechanics with zero real prospects linked.
35
+
36
+ Eligibility mechanics that make both work are in
37
+ [worker2 vapi-webhook-handler.md](../../worker2/features/vapi-webhook-handler.md).
30
38
 
31
39
  ## Rule 1 — NEVER revive an ended campaign that has linked prospects
32
40
 
@@ -39,7 +47,47 @@ dialed real prospects in one cron tick.) Likewise, do **not** blanket-flip `isOk
39
47
  real list: it is two mass writes plus a restore burden, and it silently un-blocks any contact
40
48
  that was intentionally set `isOkayToCall=0`.
41
49
 
42
- ## Rule 2 — Use an isolated one-contact test campaign with NO call windows
50
+ **The safe exception — flip everyone to `isOkayToCall = 0` FIRST, then revive.** Reviving an ended
51
+ campaign is only dangerous because its contacts are dialable. If you first mass-flip
52
+ `isOkayToCall = 0` for **every** contact on the campaign, then extending `dateEnd` re-activates a
53
+ campaign on which **no one is dialable** — and the funnel's Call Now submit re-arms only the single
54
+ tester (`isOkayToCall = 1` + `dtNextContactRequested`). This is exactly Approach A (Rule 2). The
55
+ direction matters: flipping to `0` is the safe direction; the warning above is about flipping to
56
+ `1`.
57
+
58
+ ## Rule 2 (Approach A, PREFERRED for go-live) — reuse an existing campaign, DNC-zero everyone first
59
+
60
+ This is the approach used to prove campaign 2 (`26.05 - AI BDR - Ryan Nitti`) live on 2026-07-21,
61
+ and it **supersedes** the one-contact test campaign (Rule 2b) whenever the goal is to prove the
62
+ **real** campaign works, not just the loop mechanics. It is "Rohan's existing-campaign pattern":
63
+
64
+ 1. **Record the pre-existing DNC set FIRST (non-negotiable).** Before touching anything, list every
65
+ contact on the campaign that is *already* `isOkayToCall = 0` for a real reason. For campaign 2
66
+ those are contact ids **120, 132, 134, 146, 155** (all with DNC evidence in `ContactAttempts`).
67
+ The restore statement at the end **must exclude these ids forever** — re-enabling a genuine DNC
68
+ is a compliance incident, not a test-cleanup slip.
69
+ 2. **Mass-flip `Contacts.isOkayToCall = 0` for ALL contacts on the campaign** (including the
70
+ tester). Now no one on the campaign is dialable.
71
+ 3. **Extend `Campaigns.dateEnd`** to revive the campaign. Because step 2 made everyone
72
+ non-dialable, revival dials **no one**.
73
+ 4. **The tester arms themselves through the funnel.** The UI "Call Now" submit sets
74
+ `isOkayToCall = 1` + `dtNextContactRequested` on the tester's contact — re-arming **only** the
75
+ tester. That single requested contact is the only thing that can be dialed.
76
+ 5. **Restore** at the end: set `isOkayToCall = 1` back for the contacts that were dialable before
77
+ the test, **excluding the pre-existing DNC ids from step 1** (and excluding the tester if the
78
+ tester should not be re-armed). No snapshot table is needed **provided** the step-1 DNC ids were
79
+ recorded — that list is the whole safety margin.
80
+
81
+ **Do NOT create the campaign via a dbchanges2 migration.** That approach was tried and **withdrawn**
82
+ — the migration missed `Campaigns.c_fromPhoneNumberIdentifier`, which `buildVapiPayload` (worker2
83
+ dialer) **hard-requires**, so a campaign built that way cannot place a call. The **sanctioned
84
+ programmatic campaign creator is `_Worker_Ai_Bdr_ZoomInfo::SetupCampaign`**
85
+ (`worker2/Worker/Ai/Bdr/ZoomInfo.php`): it creates the campaign type + assistant + the campaign
86
+ itself (including all `c_*` fields such as `c_fromPhoneNumberIdentifier`) + call windows + booking
87
+ windows + cadences + meet-with contacts + exceptions in one consistent pass. Use it, not raw SQL,
88
+ to stand up a new campaign.
89
+
90
+ ## Rule 2b (Approach B) — isolated one-contact test campaign with NO call windows
43
91
 
44
92
  The safe posture exploits the eligibility split (see vapi-webhook-handler.md): the normal
45
93
  (non-requested) dial path requires a `CampaignCallWindows` row for the current day/time, but the
@@ -100,6 +148,17 @@ Sequence the cleanup into a later, separately-executed window. (See dbchanges2 a
100
148
  `YYYY-MM-DD<letter>` ordering contract.)
101
149
 
102
150
  ## Change history
151
+ - 2026-07-21 — Added **Approach A** (now preferred for go-live), used to prove campaign 2
152
+ (`26.05 - AI BDR - Ryan Nitti`) live end-to-end: reuse an existing campaign by recording its
153
+ pre-existing DNC set first (campaign 2 = contact ids 120/132/134/146/155, which the restore must
154
+ exclude forever), mass-flipping `isOkayToCall = 0` on ALL its contacts, then extending `dateEnd`
155
+ to revive a campaign on which no one is dialable, and letting the funnel Call Now re-arm only the
156
+ tester. Reframed Rule 1's "never blanket-flip isOkayToCall" as direction-specific (flipping to `0`
157
+ first is the safe exception that makes revival safe). Recorded that the **dbchanges2 campaign-
158
+ creation migration was withdrawn** (it missed `c_fromPhoneNumberIdentifier`, which
159
+ `buildVapiPayload` hard-requires) and that **`_Worker_Ai_Bdr_ZoomInfo::SetupCampaign` is the
160
+ sanctioned programmatic campaign creator**. The old one-contact test campaign is retained as
161
+ Approach B (Rule 2b). (tcox)
103
162
  - 2026-07-20 — Initial runbook. Captured the safe AI-BDR call-loop test procedure: never revive an
104
163
  ended campaign with linked prospects (every-minute cron dials within 60s); use an isolated
105
164
  one-contact test campaign with NO `CampaignCallWindows` (only requested `dtNextContactRequested`
@@ -3,5 +3,5 @@
3
3
  | Doc | Summary | Files |
4
4
  |-----|---------|-------|
5
5
  | [TOGa Supply (toga2-supply) Architecture](architecture.md) | `toga2-supply` is the **React + Vite frontend** for TOGa Supply — warehouse fulfillment tooling (shipment selection, fulfill & ship against carrier APIs, NetSui | toga2-supply/src/api/toga.ts, toga2-supply/src/pages/ShipmentItems/view/ShipmentItemsPage.tsx, toga2-supply/src/pages/EditShipment/view/EditShipmentPage.tsx, toga2-supply/src/pages/EditShipment/api/UpdateShipmentApi.ts, toga2-supply/src/pages/Shipments/view/components/ShipmentsCardTableForm/ShipmentsCardTableForm.tsx |
6
- | [Fulfill & Ship](features/fulfill-and-ship.md) | Fulfill & Ship lets a warehouse user select sales-order line items, enter serials, pick a carrier/method, and in one action: create the Item Fulfillment records | toga2-supply/src/components/ui/Tables/BasicTable/BasicTable.tsx, toga2-supply/src/pages/ShipmentItems/view/forms/ShipmentItemsTable.tsx, toga2-supply/src/pages/EditShipment/view/components/forms/EditShipmentForm.tsx, toga2-supply/src/components/ui/BaseInput/UnitSelect.tsx, toga2-supply/src/pages/EditShipment/view/modals/SerialNumbersModal.tsx, toga2-supply/src/pages/ShipmentItems/view/ShipmentItemsPage.tsx, toga2-supply/src/pages/EditShipment/view/EditShipmentPage.tsx, toga2-supply/src/pages/EditShipment/view/components/forms/EditShipmentForm.tsx, toga2-supply/src/pages/EditShipment/view/components/SelectedShipmentItemsTable.tsx, toga2-supply/src/pages/EditShipment/view/helpers/ShipmentDetailsForm/renderEditShipmentFormInput.tsx, toga2-supply/src/pages/EditShipment/viewModel/FIELDS/DUMMYUPDATESHIPMENTFIELDS.json, toga2-supply/src/pages/EditShipment/helpers/ShipmentDetailsForm/renderEditShipmentFormInput.tsx, toga2-supply/tailwind.config.cjs, toga2-supply/src/pages/EditShipment/view/modals/ReturnShippingModal.tsx, toga2-supply/src/styles/index.scss, toga2-supply/src/components/ui/BaseInput/BaseInput.tsx, toga2-supply/src/pages/EditShipment/api/UpdateShipmentApi.ts, toga2-supply/src/pages/EditShipment/viewModel/signatureTypes.ts, toga2-supply/src/pages/EditShipment/view/modals/SelectReturnAddressModal.tsx, toga2-supply/src/pages/EditShipment/helpers/ShipmentDetailsForm/formatShipmentData.ts, toga2-supply/src/pages/EditShipment/types.ts, toga2-supply/src/pages/Shipments/view/ShipmentsPage.tsx, toga2-supply/src/pages/Shipments/view/components/ShipmentsCardTableForm/ShipmentsCardTableForm.tsx, toga2-supply/src/pages/Shipments/api/ShipmentsApi.ts, toga2-supply/src/pages/Shipments/types.ts, toga2-supply/src/pages/FulfilledShipments/view/FulfilledShipmentsPage.tsx, toga2-supply/src/components/ui/CardTable/CardTable.tsx, toga2-supply/src/components/ui/CardTable/types.ts, toga2-supply/src/assets/pen-line.svg, _underscore/Model/Client/ItemFulfillment.php, _underscore/Trait/Netsuite/ItemFulfillment.php, _underscore/Component/Library/Carriers/Ups/Ups.php |
6
+ | [Fulfill & Ship](features/fulfill-and-ship.md) | Fulfill & Ship lets a warehouse user select sales-order line items, enter serials, pick a carrier/method, and in one action: create the Item Fulfillment records | toga2-supply/src/components/ui/Tables/BasicTable/BasicTable.tsx, toga2-supply/src/pages/ShipmentItems/view/forms/ShipmentItemsTable.tsx, toga2-supply/src/pages/ShipmentItems/api/ShipmentItemsApi.ts, toga2-supply/src/pages/ShipmentItems/types.ts, toga2-supply/src/pages/EditShipment/viewModel/FIELDS/RETURNLABELFIELDS.json, toga2-supply/src/pages/EditShipment/view/components/forms/EditShipmentForm.tsx, toga2-supply/src/components/ui/BaseInput/UnitSelect.tsx, toga2-supply/src/pages/EditShipment/view/modals/SerialNumbersModal.tsx, toga2-supply/src/pages/ShipmentItems/view/ShipmentItemsPage.tsx, toga2-supply/src/pages/EditShipment/view/EditShipmentPage.tsx, toga2-supply/src/pages/EditShipment/view/components/forms/EditShipmentForm.tsx, toga2-supply/src/pages/EditShipment/view/components/SelectedShipmentItemsTable.tsx, toga2-supply/src/pages/EditShipment/view/helpers/ShipmentDetailsForm/renderEditShipmentFormInput.tsx, toga2-supply/src/pages/EditShipment/viewModel/FIELDS/DUMMYUPDATESHIPMENTFIELDS.json, toga2-supply/src/pages/EditShipment/helpers/ShipmentDetailsForm/renderEditShipmentFormInput.tsx, toga2-supply/tailwind.config.cjs, toga2-supply/src/pages/EditShipment/view/modals/ReturnShippingModal.tsx, toga2-supply/src/styles/index.scss, toga2-supply/src/components/ui/BaseInput/BaseInput.tsx, toga2-supply/src/pages/EditShipment/api/UpdateShipmentApi.ts, toga2-supply/src/pages/EditShipment/viewModel/signatureTypes.ts, toga2-supply/src/pages/EditShipment/view/modals/SelectReturnAddressModal.tsx, toga2-supply/src/pages/EditShipment/helpers/ShipmentDetailsForm/formatShipmentData.ts, toga2-supply/src/pages/EditShipment/types.ts, toga2-supply/src/pages/Shipments/view/ShipmentsPage.tsx, toga2-supply/src/pages/Shipments/view/components/ShipmentsCardTableForm/ShipmentsCardTableForm.tsx, toga2-supply/src/pages/Shipments/api/ShipmentsApi.ts, toga2-supply/src/pages/Shipments/types.ts, toga2-supply/src/pages/FulfilledShipments/view/FulfilledShipmentsPage.tsx, toga2-supply/src/components/ui/CardTable/CardTable.tsx, toga2-supply/src/components/ui/CardTable/types.ts, toga2-supply/src/assets/pen-line.svg, _underscore/Model/Client/ItemFulfillment.php, _underscore/Trait/Netsuite/ItemFulfillment.php, _underscore/Component/Library/Carriers/Ups/Ups.php |
7
7
  | [AWS Amplify Build & Deploy (non-prod environments)](workflows/amplify-build-and-deploy.md) | How `toga2-supply` (React + Vite) builds and deploys on **AWS Amplify**. | toga2-supply/amplify.yml, toga2-supply/.gitattributes, toga2-supply/.github/workflows/sync-stage-environments.yml, toga2-supply/.env.qc-security |
@@ -11,6 +11,9 @@ owners: [mhammontree]
11
11
  files:
12
12
  - toga2-supply/src/components/ui/Tables/BasicTable/BasicTable.tsx
13
13
  - toga2-supply/src/pages/ShipmentItems/view/forms/ShipmentItemsTable.tsx
14
+ - toga2-supply/src/pages/ShipmentItems/api/ShipmentItemsApi.ts
15
+ - toga2-supply/src/pages/ShipmentItems/types.ts
16
+ - toga2-supply/src/pages/EditShipment/viewModel/FIELDS/RETURNLABELFIELDS.json
14
17
  - toga2-supply/src/pages/EditShipment/view/components/forms/EditShipmentForm.tsx
15
18
  - toga2-supply/src/components/ui/BaseInput/UnitSelect.tsx
16
19
  - toga2-supply/src/pages/EditShipment/view/modals/SerialNumbersModal.tsx
@@ -309,6 +312,33 @@ the call 403s and returns no label.
309
312
 
310
313
  ## Gotchas / known issues
311
314
 
315
+ - **⚠ The item-image tables hardcoded the placeholder and never requested the image.** The Select
316
+ Items (`ShipmentItemsTable`), Update Info, and Add-Serial (`SelectedShipmentItemsTable`) tables
317
+ rendered `imageUrl={placeholderImage}` as a **constant**, so a real item image could never appear.
318
+ The item image is exposed via **two computed SQL fields on the `_underscore` Item model** (referenced,
319
+ not modified this commit) — request **both** in the V2 field selectors and render
320
+ **`item._imageUrl || item._thumbnailImageUrl || placeholder`**:
321
+ - **`_imageUrl`** — resolves `Items.imageId` → `Images.sourceUrl` (the single **primary** image).
322
+ - **`_thumbnailImageUrl`** — resolves from **`ItemImages`** (per-item thumbnail/low/medium/high
323
+ resolutions) via `thumbnailImageId`, ordered `isDefault DESC`, **excluding** `sourceUrl LIKE
324
+ '%Manufacturer%'`.
325
+
326
+ Two distinct DB paths back an item image — `Items.imageId → Images` (one primary) and the `ItemImages`
327
+ table (multi-resolution, `isDefault` flag) — and in both cases the URL lives in `Images.sourceUrl`.
328
+ The `_imageUrl || _thumbnailImageUrl || placeholder` fallback covers imageId-only, ItemImages-only, or
329
+ neither. Wiring an image anywhere in the tool: request **both** computed fields (add to the API field
330
+ selectors + response types), don't hardcode the placeholder. Verified end-to-end against a client DB
331
+ with rolled-back test data. Files: `ShipmentItems/api/ShipmentItemsApi.ts`, `ShipmentItemsTable.tsx`,
332
+ `ShipmentItems/types.ts`, `EditShipment/api/UpdateShipmentApi.ts`, `EditShipmentForm.tsx`,
333
+ `SelectedShipmentItemsTable.tsx`, `EditShipment/types.ts`.
334
+ - **⚠ The shared `ExpandableCellContent` truncates by a FIXED CHARACTER COUNT, not by width — so it
335
+ can't be responsive.** `ExpandableCellContent` cuts a cell at `columnDataLength` characters
336
+ (e.g. 55), so a column truncates early regardless of available width and **does not react to
337
+ window/page resize**. For width-responsive truncation, render the cell as a plain
338
+ `<div className="truncate max-w-[NNvw]" title={full}>` — CSS overflow-ellipsis with a
339
+ viewport-relative max-width so the ellipsis point tracks the page size, and the native `title`
340
+ shows the full text on hover. (Applied to the Description column in `ShipmentItemsTable.tsx` /
341
+ `SelectedShipmentItemsTable.tsx`.)
312
342
  - **⚠ A portaled react-select menu loses the control's inherited font size.** `BaseInput`'s
313
343
  react-select menus now portal to `<body>` (`menuPortalTarget={document.body}` +
314
344
  `styles.menuPortal` `zIndex 9999`, on both single- and multi-select) so the menu can't be
@@ -588,6 +618,18 @@ not the base `_Model_Client_ItemFulfillment`. Tested with GroWrk; UPS support wa
588
618
  for Compass and is not yet in prod.
589
619
 
590
620
  ## Change history
621
+ - 2026-07-21 — TRUE-79191 (commit `dc900ea4e`, toga2-supply item-image + table polish): fixed the
622
+ **item image never rendering** — the Select Items / Update Info / Add-Serial tables hardcoded
623
+ `imageUrl={placeholderImage}` and never requested the image; now request the two `_underscore` Item
624
+ computed fields **`_imageUrl`** (`Items.imageId → Images.sourceUrl`) and **`_thumbnailImageUrl`**
625
+ (from `ItemImages` via `thumbnailImageId`, `isDefault DESC`, excluding `%Manufacturer%`) and render
626
+ `item._imageUrl || item._thumbnailImageUrl || placeholder` — documented the two-path Images/ItemImages
627
+ data model as a gotcha. Documented the **`ExpandableCellContent` fixed-char-count vs width** gotcha
628
+ (truncates at `columnDataLength` chars, not responsive; use `truncate max-w-[NNvw]` + native `title`
629
+ for width-responsive truncation, applied to the Description column). Added Reference 1/2 placeholder
630
+ hint text to the Edit Return Label modal (`RETURNLABELFIELDS.json`) to match Update Info
631
+ ("e.g. NetSuite Sales Order #" / "…Purchase Order #") — cosmetic, Ref 1/2 semantics already recorded.
632
+ (mhammontree)
591
633
  - 2026-07-21 — TRUE-79191 (toga2-supply UI fixes + Figma polish): documented three durable, reusable
592
634
  gotchas — (1) **`BasicTable` header/value misalignment** (daisyUI base `:where(th,td)` ~16px padding;
593
635
  always pass a horizontal `headerCellPadding` matching `cellPadding`, box-shadow-only seam for sticky
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-20
9
+ updated: 2026-07-21
10
10
  owners: [snaredla, tcox]
11
11
  files:
12
12
  - worker2/Worker/Vapi.php
@@ -218,6 +218,28 @@ character — a 37-char value) **passes** the readiness gate and the campaign is
218
218
  Vapi `POST /call/phone` **400s** at call time. Validate the assistant identifier's shape before
219
219
  assigning an assistant to any campaign.
220
220
 
221
+ ### Gotcha (HIGH — call quality) — a spoken callback time with a SPELLED-OUT number is silently dropped
222
+
223
+ `actionScheduleCallback` (`Worker/Vapi.php` ~L387–431) parses the assistant's
224
+ `structured.callbackTime` through `convertDateTime` (~L609–635), which relies on PHP's date
225
+ parser. PHP handles relative phrasings like `"10 minutes"` and `"tomorrow at 3pm"` — but **NOT
226
+ spelled-out numbers**. When the model emits `c_callbackTime = "in ten minutes"` (natural language,
227
+ number as a word), `convertDateTime` returns **null**, `actionScheduleCallback` `error_log`s
228
+ `"Cannot parse callbackTime"` and **aborts** — `dtNextContactRequested` is never set and `callType`
229
+ is never flipped to `CALLBACK`, so the promised callback **never happens** and nothing surfaces the
230
+ failure. Reproduced live 2026-07-21 (attempt 183, contact 248). Two recommended fixes (do both):
231
+ (a) constrain the Vapi structured-output `callbackTime` schema to ISO 8601 / numeric-relative so the
232
+ model can't spell the number out (see `../../ai-bdr/features/vapi-integration.md`); (b) defensively
233
+ normalize spelled-out numbers (`one`..`sixty`) in `convertDateTime` before parsing.
234
+
235
+ ### Gotcha (SECURITY) — the Vapi API bearer token is hardcoded in source
236
+
237
+ The outbound dialer `Worker/Ai/Bdr/Vapi.php` (**line 6**) declares the Vapi API bearer token as a
238
+ `const` in source — so it is **committed to git**. Treat any committed secret as **compromised and
239
+ rotate it**, and move it into the `[vapi]` section of the worker2 `Config/*.ini` files alongside the
240
+ other Vapi secrets (the pattern the rest of the integration already uses). Config, never source, is
241
+ the home for this credential. (Token value is deliberately not recorded here — location only.)
242
+
221
243
  ## Data model
222
244
 
223
245
  Writes to `ContactAttempts` (via Toga 2.0 API) — `dtStarted`, `dtEnded`, `transcript`,
@@ -245,6 +267,17 @@ status. See the `ai-bdr` skill / `_Model_True_ContactAttempt` for the full field
245
267
  - [VAPI Integration — assistants, tools, structured output](../../ai-bdr/features/vapi-integration.md)
246
268
 
247
269
  ## Change history
270
+ - 2026-07-21 — Full call loop **proven end-to-end in production** on the real campaign 2
271
+ (`26.05 - AI BDR - Ryan Nitti`): UI submit → funnel API → dialer → Vapi call → webhook write-back.
272
+ Live-verified the documented behavior: the `SCHEDULE_CALLBACK` → `c_callOutcome=CALLBACK_SCHEDULED`
273
+ / `c_actionToTake=SCHEDULE_CALLBACK` mapping, plus `c_prospectSentiment` + `c_qualificationScore`,
274
+ are all written by the webhook; and the CST timestamp conversion is correct (a scheduled call for
275
+ an 11:30 slot fired at 11:30:50 — `formatCstDateTime` verified live). Added two gotchas: (1) HIGH —
276
+ a spoken `callbackTime` with a **spelled-out** number (`"in ten minutes"`) makes `convertDateTime`
277
+ return null, so `actionScheduleCallback` aborts and the callback silently never happens (repro
278
+ attempt 183 / contact 248; fix both the Vapi schema and `convertDateTime`); (2) SECURITY — the Vapi
279
+ API bearer token is hardcoded as a `const` in `Worker/Ai/Bdr/Vapi.php` line 6 (committed to git) —
280
+ rotate it and move it to the `[vapi]` config section. (tcox)
248
281
  - 2026-07-20 — Source-verified corrections/extensions to the dialer eligibility rules: the campaign
249
282
  gate is split (`getActiveCampaignsForDialing` L177–193 = isActive+assistant, NO date; date range +
250
283
  non-empty `assistantIdentifier` in `validateCampaignReady` L759–789); `Campaigns_Contacts.status
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.394",
3
+ "version": "1.0.396",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",