toga-ai 1.0.481 → 1.0.483
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.
- package/knowledge/2.0/apps/ai-bdr/features/vapi-integration.md +30 -3
- package/knowledge/2.0/apps/worker2/INDEX.md +1 -0
- package/knowledge/2.0/apps/worker2/features/callback-scheduling.md +278 -0
- package/knowledge/2.0/apps/worker2/features/vapi-webhook-handler.md +78 -15
- package/knowledge/sessions/2026-07-30-bdr-live-call-status-and-callback-bug-tcox.md +201 -0
- package/package.json +1 -1
|
@@ -6,8 +6,8 @@ project: AI-BDR
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-07-
|
|
10
|
-
owners: [akhokhani, tcox]
|
|
9
|
+
updated: 2026-07-30
|
|
10
|
+
owners: [akhokhani, tcox, bala]
|
|
11
11
|
files:
|
|
12
12
|
- ai-bdr/vapi/templates/assistant.template.json
|
|
13
13
|
- ai-bdr/vapi/templates/system-prompt.template.md
|
|
@@ -122,7 +122,7 @@ Returned in `message.analysis.structuredData` on every `end-of-call-report`:
|
|
|
122
122
|
| `contactEmail` | string | Email the prospect confirmed; empty if not collected |
|
|
123
123
|
| `emailMatchedKnown` | boolean | True if confirmed email matches `knownEmail` from variableValues |
|
|
124
124
|
| `meetingTime` | ISO 8601 | Present if `callOutcome=meeting_booked` |
|
|
125
|
-
| `callbackTime` | ISO 8601 | Present if `callOutcome=callback_scheduled
|
|
125
|
+
| `callbackTime` | ISO 8601 *(documented; unconstrained free text in practice)* | Present if `callOutcome=callback_scheduled`. **BUG — the single highest-value schema fix (rescoped 2026-07-30).** The PHP side stores this raw and parses it with PHP's relative-date grammar, which returns null for **22 of 32** realistic phrasings — see below. |
|
|
126
126
|
| `callbackContactPreference` | string | `phone` / `email` / `sms` |
|
|
127
127
|
| `callType` | string | Mirrors the `callType` variable injected on this call |
|
|
128
128
|
| `nextCallOpeningContext` | string | One-sentence coaching note for the next attempt's opener |
|
|
@@ -135,6 +135,25 @@ To change the schema: `python scripts/update_structured_output.py` (dry
|
|
|
135
135
|
run by default; pass `--apply` to write). Adding a field needs a paired
|
|
136
136
|
PHP worker handler change — coordinate before deploying.
|
|
137
137
|
|
|
138
|
+
### `callbackTime` — constraining it here is the recommended durable fix
|
|
139
|
+
|
|
140
|
+
The worker's `convertDateTime` is broken by the **preposition / filler word**, not (as previously
|
|
141
|
+
recorded) by spelled-out numbers: `"10 minutes"` parses but `"in 10 minutes"` does not;
|
|
142
|
+
`"tomorrow 3pm"` parses but `"tomorrow at 3pm"` does not. `in`, `at`, `around` and `sometime` all
|
|
143
|
+
break it, and `"later"` — which appears verbatim in the worker's own sample payload — is null.
|
|
144
|
+
Every failure means a **promised callback is silently never placed**.
|
|
145
|
+
|
|
146
|
+
**ISO 8601 parses cleanly on the PHP side today** (`2026-08-01T15:00:00Z` and the `.000Z` variant).
|
|
147
|
+
So: constrain this field's schema to **strict ISO 8601 with offset** and prompt the assistant to
|
|
148
|
+
resolve relative speech to an absolute timestamp before emitting. That removes the entire class of
|
|
149
|
+
failure without touching PHP — which is why it ranks above the PHP-side hardening.
|
|
150
|
+
|
|
151
|
+
**Unverified (2026-07-30):** the live assistant config could not be inspected from the dev box
|
|
152
|
+
(`C:\WWW\info` is the Next.js funnel — no `vapi/` templates, no `scripts/`, zero matches for
|
|
153
|
+
`callbackTime`). Confirm whether the live schema **already** constrains this field, and which
|
|
154
|
+
server-message types each assistant currently subscribes to, before changing anything. Full
|
|
155
|
+
analysis: [worker2 callback-scheduling.md](../../worker2/features/callback-scheduling.md).
|
|
156
|
+
|
|
138
157
|
## Environment variables (`.env.example`)
|
|
139
158
|
|
|
140
159
|
```
|
|
@@ -181,6 +200,14 @@ ensure UA spoofing is preserved or you'll see 403s with no body.
|
|
|
181
200
|
`callSummary` over a schema fork.
|
|
182
201
|
|
|
183
202
|
## Change history
|
|
203
|
+
- 2026-07-30 — **Rescoped the `callbackTime` bug and promoted the schema-side fix.** The parse
|
|
204
|
+
failure is caused by the preposition/filler word (`in`, `at`, `around`, `sometime`), **not**
|
|
205
|
+
spelled-out numbers — 22/32 realistic phrasings return null, `"tomorrow at 3pm"` among them.
|
|
206
|
+
Since ISO 8601 parses cleanly in PHP, constraining this field to strict ISO 8601 with offset
|
|
207
|
+
(plus prompting the assistant to resolve relative speech before emitting) is the **recommended
|
|
208
|
+
durable fix**. Flagged that the live assistant config could not be inspected, so it is unverified
|
|
209
|
+
whether the schema already constrains the field or which server-message types each assistant
|
|
210
|
+
subscribes to (this matters — the worker has no `message.type` guard). (tcox)
|
|
184
211
|
- 2026-07-21 — Live go-live findings. Recorded that the healthcare assistant runs
|
|
185
212
|
`firstMessageMode = "assistant-waits-for-user"` (Option B) so a silent answerer gets silence
|
|
186
213
|
(confirmed: 8s mutual-silence call, `endedReason=customer-ended-call`) — testers must speak first.
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
| [Deploy-Time Auto-Registration to the Shared ALB Target Group (non-production)](features/alb-target-group-auto-registration.md) | TOGA does **not** pay for EB-managed load-balancer registration, so an EB instance is normally **not** added to its environment's ALB target group — a fresh or | worker2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh, worker2/ebs/register_instance_to_shared_application_load_balancer.php, worker2/.platform/hooks/prebuild/_shared/040-write-instance-id.sh, worker2/.platform/hooks/prebuild/_shared/041-write-region.sh, worker2/.platform/hooks/postdeploy/015_install_composer.sh, api2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh, api2/ebs/register_instance_to_shared_application_load_balancer.php |
|
|
7
7
|
| [All-Client Email Queue Monitor (Monitor/Operations/EmailQueue)](features/all-client-email-queue-monitor.md) | `_Worker_Monitor_Operations::EmailQueue()` is the cross-client health check for the 2.0 outbound email queue (`Logs_<Client>.Email` — see [2.0 Email Send Pipeli | worker2/Worker/Monitor/Operations.php, _underscore/Database.php, _underscore/Query.php, dbchanges2/Logs_Client/2026-05-21 - Email.sql |
|
|
8
8
|
| [Automated PR Merger — Concurrent Force-Push Clobber Race](features/automated-pr-merger-force-push-race.md) | The automated PR merger `_Worker_Team_GitHub::Merge` (`worker2` `Worker/Team/Github.php`) merges approved PRs to `_production` by **force-pushing from a clone t | Worker/Team/Github.php |
|
|
9
|
+
| [Callback Scheduling ("call me back") — worker2 AI-BDR](features/callback-scheduling.md) | The **AI-BDR callback path**: what happens between a prospect saying *"call me back later"* on a Vapi call and the dialer actually placing that second call. | worker2/Worker/Vapi.php, worker2/Worker/Ai/Bdr/Vapi.php, worker2/Controller/Index.php |
|
|
9
10
|
| [ClickUp Connectivity Watchdog](features/clickup-connectivity-watchdog.md) | A cron watchdog that emails when the ClickUp integration looks disconnected during business hours. | worker2/Worker/Clickup/Health.php, worker2/Database/ClickupHealthWatchdog.sql |
|
|
10
11
|
| [ClickUp Design Sprint Automation (Final Design Outcome)](features/clickup-design-sprint-automation.md) | `_Worker_Clickup_Design` is meant to drive the design-sprint workflow in ClickUp via the API, replacing a set of native ClickUp automations. | worker2/Worker/Clickup/Design.php, worker2/Worker/Clickup.php, worker2/Controller/ClickupDesignTest.php, _underscore/Component/Api/Clickup/Clickup.php |
|
|
11
12
|
| [ClickUp GitHub-tab Auto-linking & Ticket-id Branch Naming](features/clickup-github-autolink.md) | How ClickUp surfaces branches/PRs/commits in a ticket's **GitHub tab**, and the branch / PR-title naming convention that triggers it. | |
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Callback Scheduling ("call me back") — worker2 AI-BDR
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: worker2
|
|
5
|
+
project: Worker
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-07-30
|
|
10
|
+
owners: [tcox, bala]
|
|
11
|
+
files:
|
|
12
|
+
- worker2/Worker/Vapi.php
|
|
13
|
+
- worker2/Worker/Ai/Bdr/Vapi.php
|
|
14
|
+
- worker2/Controller/Index.php
|
|
15
|
+
related:
|
|
16
|
+
- vapi-webhook-handler.md
|
|
17
|
+
- ../../ai-bdr/features/vapi-integration.md
|
|
18
|
+
- ../../ai-bdr/features/call-orchestration.md
|
|
19
|
+
- ../../ai-bdr/workflows/safe-call-loop-testing.md
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Summary
|
|
23
|
+
|
|
24
|
+
The **AI-BDR callback path**: what happens between a prospect saying *"call me back later"* on a
|
|
25
|
+
Vapi call and the dialer actually placing that second call. It spans the webhook
|
|
26
|
+
(`Worker/Vapi.php` — capture, persist, parse, schedule) and the dialer
|
|
27
|
+
(`Worker/Ai/Bdr/Vapi.php` — eligibility, redial).
|
|
28
|
+
|
|
29
|
+
**It is currently broken in production.** The requested callback is captured and stored
|
|
30
|
+
correctly, but `convertDateTime` cannot parse most of the phrasings prospects actually use, so
|
|
31
|
+
`actionScheduleCallback` aborts one line before the write and **no callback is ever scheduled**.
|
|
32
|
+
This doc records the source-verified root cause, three further defects found on the same path,
|
|
33
|
+
the recommended fix order, and the production queries that confirm it with real data.
|
|
34
|
+
|
|
35
|
+
> **Status / handoff (2026-07-30).** Diagnosis only — **nothing has been fixed and no worker2
|
|
36
|
+
> code was modified.** tcox investigated (read-only, `_production` @ `ef50345`); **bala is
|
|
37
|
+
> picking the topic up next.** Immediate next step: run **Q1** below against `Client_True` to
|
|
38
|
+
> confirm the root cause with production data, then decide whether the durable fix lands in the
|
|
39
|
+
> **Vapi assistant structured-output schema** (recommended) or in the PHP parser.
|
|
40
|
+
|
|
41
|
+
## Key files / entry points
|
|
42
|
+
|
|
43
|
+
| Location | Role |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `Worker/Vapi.php:99, :109–122, :144` | Extracts `callbackTime` from the end-of-call report |
|
|
46
|
+
| `Worker/Vapi.php:161–173, :189–194, :213–215, :256–266` | Persists `c_callbackTime` **raw** + outcome/action onto the attempt |
|
|
47
|
+
| `Worker/Vapi.php:387–431` (`actionScheduleCallback`) | Parses the time and writes `Contacts.dtNextContactRequested` |
|
|
48
|
+
| `Worker/Vapi.php:609–635` (`convertDateTime`) | The parser — **the failure point** |
|
|
49
|
+
| `Worker/Ai/Bdr/Vapi.php:234–241, :314–331` | Dialer eligibility — honours (or blocks) the callback |
|
|
50
|
+
|
|
51
|
+
## How it works (end to end)
|
|
52
|
+
|
|
53
|
+
1. **Capture.** The requested time arrives at
|
|
54
|
+
`message.artifact.structuredOutputs.<schema-uuid>.result.callbackTime`
|
|
55
|
+
(`Worker/Vapi.php:99` → `:109–122` → `:144`) — **not** in `analysis`, which is always empty.
|
|
56
|
+
The published schema documents ISO 8601
|
|
57
|
+
([vapi-integration.md](../../ai-bdr/features/vapi-integration.md)), but in practice the field
|
|
58
|
+
is **unconstrained free text**; the file's own sample payload contains
|
|
59
|
+
`"callbackTime": "later"` (`Worker/Vapi.php:45`).
|
|
60
|
+
2. **Persist (raw).** `updateContactAttempt` stores the string with **no conversion**
|
|
61
|
+
(`:213–215`) alongside `c_callOutcome='CALLBACK_SCHEDULED'` / `c_actionToTake='SCHEDULE_CALLBACK'`
|
|
62
|
+
(map at `:161–173`, applied `:189–194`), via `PUT /contact-attempts/{uuid}` (`:256–266`).
|
|
63
|
+
`c_callbackTime` is `VARCHAR(255)` (dbchanges2 `_modules/Ai/Bdr/2026-03-04 bdrCustomfields.sql:84`),
|
|
64
|
+
so a malformed value **survives persistence intact** and only dies at the act step.
|
|
65
|
+
3. **Act.** `executePostCallActions` dispatches `SCHEDULE_CALLBACK` → `actionScheduleCallback`,
|
|
66
|
+
which resolves the campaign timezone (`:400`), calls `self::convertDateTime($callbackTime, …)`
|
|
67
|
+
(`:401`), and on success writes `Contacts.dtNextContactRequested` (`:414`) and
|
|
68
|
+
`contactCallTypeId = CALLBACK` (`:417`) via `PUT /contacts/{uuid}` (`:420–430`).
|
|
69
|
+
4. **Redial.** The dialer treats a due `dtNextContactRequested` as the privileged path:
|
|
70
|
+
it **bypasses call windows, weekly cadence, call-window exception dates and prior DNC**
|
|
71
|
+
(`Ai/Bdr/Vapi.php:238–303`, `:314–331`). Callbacks also sort first in the per-run queue (`:334`).
|
|
72
|
+
|
|
73
|
+
## Root cause — `convertDateTime` cannot parse the times prospects actually say
|
|
74
|
+
|
|
75
|
+
`actionScheduleCallback` aborts when the parser returns null:
|
|
76
|
+
|
|
77
|
+
- `Worker/Vapi.php:399–406` — logs `[AI-BDR Webhook] Cannot parse callbackTime "…"` and **`return`s
|
|
78
|
+
early**. `dtNextContactRequested` is never written, `contactCallTypeId` is never flipped, and
|
|
79
|
+
nothing downstream ever schedules the call.
|
|
80
|
+
- `Worker/Vapi.php:609–635` — the parser, which leans entirely on PHP's relative-date grammar.
|
|
81
|
+
|
|
82
|
+
**Empirically measured** (parser extracted and executed on PHP 8.1.10): **22 of 32 realistic
|
|
83
|
+
phrasings return null.** Confirmed null: `later`, `in 10 minutes`, `in ten minutes`,
|
|
84
|
+
`tomorrow at 3pm`, `tomorrow at 3`, `Monday at 9am`, `in an hour`, `in 2 days`, `this afternoon`,
|
|
85
|
+
`tomorrow morning`, `end of day`, `around 2pm`, `15 minutes from now`, `next week sometime`.
|
|
86
|
+
|
|
87
|
+
### The correction — it is the PREPOSITION, not spelled-out numbers
|
|
88
|
+
|
|
89
|
+
An earlier note on [vapi-webhook-handler.md](vapi-webhook-handler.md) attributed this to
|
|
90
|
+
spelled-out numbers (`"in ten minutes"`). **That is understated and misleading.** The trigger is
|
|
91
|
+
the **preposition / filler word**:
|
|
92
|
+
|
|
93
|
+
| Parses | Returns null |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `10 minutes` | `in 10 minutes` |
|
|
96
|
+
| `tomorrow 3pm` | `tomorrow at 3pm` |
|
|
97
|
+
| `2026-08-01T15:00:00Z` (and the `.000Z` variant) | `around 2pm`, `next week sometime` |
|
|
98
|
+
|
|
99
|
+
The words **`in`, `at`, `around`, `sometime`** break PHP's relative-date grammar. Note that
|
|
100
|
+
`"tomorrow at 3pm"` — the exact phrasing the code comment at `:399` claims works — **fails**.
|
|
101
|
+
Spelled-out numbers are a real but secondary contributor. **ISO 8601 parses cleanly**, which is
|
|
102
|
+
why the durable fix belongs in the Vapi schema.
|
|
103
|
+
|
|
104
|
+
### Ruled out — do not re-investigate the downstream wiring
|
|
105
|
+
|
|
106
|
+
The "recorded as data but never scheduled as an action" hypothesis is **FALSE**; it fails one
|
|
107
|
+
line *before* the write.
|
|
108
|
+
|
|
109
|
+
- `actionScheduleCallback` **does** write `dtNextContactRequested` (`:414`) and
|
|
110
|
+
`contactCallTypeId` (`:417`) via `PUT /contacts/{uuid}` (`:420–430`). An exhaustive grep confirms
|
|
111
|
+
this is the **only** writer of `dtNextContactRequested` outside the dialer's own IMMEDIATE /
|
|
112
|
+
403-retry stamps (`Ai/Bdr/Vapi.php:421`, `:477`).
|
|
113
|
+
- The dialer **honours** it, bypassing call windows, cadence, exception dates and prior DNC.
|
|
114
|
+
- **`CALLBACK_SCHEDULED` is NOT a terminal outcome.** The exclusion at
|
|
115
|
+
`Ai/Bdr/Vapi.php:317–330` lists only `c_dncRequested=1`, `c_actionToTake='DO_NOT_CALL'`, and
|
|
116
|
+
`c_callOutcome IN ('MEETING_BOOKED','DNC_REQUESTED')` — and the whole block is short-circuited
|
|
117
|
+
when `dtNextContactRequested IS NOT NULL` (`:316`).
|
|
118
|
+
|
|
119
|
+
## Defect 2 (COMPLIANCE) — times that *do* parse are frequently wrong, sometimes in the past
|
|
120
|
+
|
|
121
|
+
There is **no future/sanity check** between the parse and the write (`Worker/Vapi.php:401–414`),
|
|
122
|
+
and dialer eligibility is simply `dtNextContactRequested <= NOW()` (`Ai/Bdr/Vapi.php:241`).
|
|
123
|
+
|
|
124
|
+
Measured at **2026-07-30 09:29 CT**, campaign timezone `America/New_York`:
|
|
125
|
+
|
|
126
|
+
| Spoken | Resolved to | Effect |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| `Thursday` | 2026-07-29 23:00 | **In the past** — redials on the very next cron minute |
|
|
129
|
+
| `tomorrow` | 2026-07-30 23:00 | An **11 PM call** (midnight ET rendered as 23:00 CT) |
|
|
130
|
+
| `3:00 PM tomorrow` | 2026-07-30 23:00 | Time silently dropped — **wrong day AND wrong time** |
|
|
131
|
+
| `2pm` | earlier today | In the past whenever the call ended after 2pm |
|
|
132
|
+
|
|
133
|
+
Because the callback path **deliberately bypasses `CampaignCallWindows`**, nothing downstream
|
|
134
|
+
catches an 11 PM dial or a redial 60 seconds later. **Treat this as TCPA/compliance exposure, not
|
|
135
|
+
just a UX bug.**
|
|
136
|
+
|
|
137
|
+
## Defect 3 — `maxAttemptsPerContact` is NOT bypassed for callbacks
|
|
138
|
+
|
|
139
|
+
The attempt-cap predicate sits **outside** the `dtNextContactRequested` OR-branch
|
|
140
|
+
(`Ai/Bdr/Vapi.php:234–235`; the branch begins at `:238`). Default cap is **3**
|
|
141
|
+
(dbchanges2 `Client/2026-06-03- BLANK_CLIENT_DATABASE.sql:1852`).
|
|
142
|
+
|
|
143
|
+
So a prospect who asks for a callback **on their 3rd attempt** gets `dtNextContactRequested` set
|
|
144
|
+
correctly and is then **permanently ineligible**. Callbacks bypass call windows, cadence,
|
|
145
|
+
exception dates and prior DNC — but **not** the attempt cap. This is a silent policy conflict:
|
|
146
|
+
the cap overrides an explicit customer request. Decide the policy explicitly (exempt
|
|
147
|
+
callback-requested contacts, or grant +1).
|
|
148
|
+
|
|
149
|
+
## Defect 4 — the failure is silent all the way out to Vapi
|
|
150
|
+
|
|
151
|
+
`actionScheduleCallback`'s write (`Worker/Vapi.php:420–430`) is the **only** write in the file
|
|
152
|
+
with **no try/catch** (compare `:255–284` and `:363–367`), and it is called with
|
|
153
|
+
`$throwExceptionOnApiError = true` — `_underscore` `Component/Api/Toga/Toga.php:167` / `:172`
|
|
154
|
+
throw. The controller then **swallows the throw and returns HTTP 200**
|
|
155
|
+
(`worker2/Controller/Index.php:511–526`), so **Vapi never retries** and the callback is lost with
|
|
156
|
+
no external signal. The only trace is `Core.WorkerJobs.failureReason`.
|
|
157
|
+
|
|
158
|
+
## Smaller defects found on the same path
|
|
159
|
+
|
|
160
|
+
- **An empty `Campaigns.timezone` silently kills the callback.** `Worker/Vapi.php:400` uses `??`,
|
|
161
|
+
which does **not** catch `''`. `Campaigns.timezone` is `varchar(64) NOT NULL` with **no default**
|
|
162
|
+
(blank DB `:1851`), so `''` is storable, `new DateTimeZone('')` throws, and the parse returns
|
|
163
|
+
null. Verified that `timezone='Central'` also fails — **only IANA identifiers work.**
|
|
164
|
+
- **No audit of what was actually scheduled.** Only the raw string is stored; the resolved
|
|
165
|
+
datetime is never persisted, so a failed parse cannot be deterministically re-run.
|
|
166
|
+
- **The attempt's call type gets clobbered.** `Worker/Vapi.php:178` writes the **contact's**
|
|
167
|
+
`contactCallTypeId` onto the **attempt**, overwriting what the dialer stamped at
|
|
168
|
+
`Ai/Bdr/Vapi.php:701` (and nulls it when the contact's is NULL).
|
|
169
|
+
- **`Contacts.contactCallTypeId` is never reset to SCHEDULED** after a callback completes, so
|
|
170
|
+
every subsequent call keeps taking the callback prompt branch (`Ai/Bdr/Vapi.php:371–372`).
|
|
171
|
+
- **`Campaigns.dateEnd` kills late callbacks.** `validateCampaignReady:784–786` throws once today
|
|
172
|
+
> `dateEnd`; it is caught at `ProcessCampaignCalls:144–146` and returned as `success:false`. A
|
|
173
|
+
callback promised for after the campaign end date **silently never fires**.
|
|
174
|
+
- **Per-run cap of 10** (`MAX_CALLS_PER_MINUTE` at `:8`, applied `:339–340`). Callbacks sort first
|
|
175
|
+
(`:334`) so this rarely bites, but >10 simultaneously-due callbacks queue.
|
|
176
|
+
|
|
177
|
+
## Recommended fix direction (decided 2026-07-30 — priority order)
|
|
178
|
+
|
|
179
|
+
1. **DURABLE FIX — Vapi assistant config side.** Constrain `callbackTime` in the structured-output
|
|
180
|
+
schema to **strict ISO 8601 with offset**, and prompt the assistant to resolve relative speech
|
|
181
|
+
to an absolute timestamp before emitting. ISO parses cleanly today, so this removes the whole
|
|
182
|
+
class of failure. See [vapi-integration.md](../../ai-bdr/features/vapi-integration.md).
|
|
183
|
+
2. **`Worker/Vapi.php:71–79` and `:135`** — add
|
|
184
|
+
`if (($message->type ?? '') !== 'end-of-call-report') return 'ok';` and stop force-stamping
|
|
185
|
+
`dtEnded` from `NOW()` (set it only from a real `endedAt`). Independent of the callback bug and
|
|
186
|
+
arguably **more urgent** — see the concurrency gotcha in
|
|
187
|
+
[vapi-webhook-handler.md](vapi-webhook-handler.md).
|
|
188
|
+
3. **`:609–635` and `:403–406`** — defensive normalization (strip leading `in `/`at `/`around `/
|
|
189
|
+
`sometime `, map spelled-out numbers) and, on failure, **schedule a flagged sane default rather
|
|
190
|
+
than `return`** — never silently drop a promised callback.
|
|
191
|
+
4. **`:401–414`** — clamp the parsed time: if `<= now`, push to the next valid slot; snap into the
|
|
192
|
+
campaign's call windows; reject beyond ~90 days.
|
|
193
|
+
5. **`Ai/Bdr/Vapi.php:234–235`** — decide the attempt-cap policy explicitly.
|
|
194
|
+
6. **`:420–430`** — add a try/catch matching the file's own pattern and make failures **loud**
|
|
195
|
+
(Sentry, not just `error_log`).
|
|
196
|
+
7. **`:400`** — use `?:` instead of `??`, validate against `DateTimeZone::listIdentifiers()`, and
|
|
197
|
+
add a DB default for `Campaigns.timezone`.
|
|
198
|
+
8. **dbchanges2 `_modules/Ai/Bdr/`** — add a resolved `c_callbackTimeResolved DATETIME` column for
|
|
199
|
+
auditability and re-runnability.
|
|
200
|
+
|
|
201
|
+
## Diagnostic queries (run against `Client_True`)
|
|
202
|
+
|
|
203
|
+
**Q1 — decisive.** Join attempts to contacts for callbacks in the last 30 days:
|
|
204
|
+
|
|
205
|
+
```sql
|
|
206
|
+
SELECT ca.attemptId, ca.contactId, ca.campaignId, ca.dtStarted, ca.dtEnded,
|
|
207
|
+
ca.c_callOutcome, ca.c_actionToTake, ca.c_callbackTime,
|
|
208
|
+
c.dtNextContactRequested, c.contactCallTypeId, c.isOkayToCall
|
|
209
|
+
FROM ContactAttempts ca
|
|
210
|
+
JOIN Contacts c ON c.contactId = ca.contactId
|
|
211
|
+
WHERE ca.c_callOutcome = 'CALLBACK_SCHEDULED'
|
|
212
|
+
AND ca.dtEnded >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
|
213
|
+
ORDER BY ca.dtEnded DESC;
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Interpretation:
|
|
217
|
+
- `c_callbackTime` non-empty **AND** `dtNextContactRequested IS NULL` → **the parse-failure root
|
|
218
|
+
cause** (or the silent throw of Defect 4).
|
|
219
|
+
- `c_callbackTime` empty/NULL → the model never emitted the field.
|
|
220
|
+
- `dtNextContactRequested < ca.dtEnded` → **scheduled in the past** (Defect 2).
|
|
221
|
+
- Set correctly → scheduling worked; move to Q3 (attempt cap).
|
|
222
|
+
|
|
223
|
+
**Q2 — what the parser must actually handle:**
|
|
224
|
+
|
|
225
|
+
```sql
|
|
226
|
+
SELECT c_callbackTime, COUNT(*) AS n
|
|
227
|
+
FROM ContactAttempts
|
|
228
|
+
WHERE c_callbackTime IS NOT NULL AND c_callbackTime <> ''
|
|
229
|
+
GROUP BY c_callbackTime
|
|
230
|
+
ORDER BY n DESC;
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
**Q3** — contacts with a due `dtNextContactRequested` grouped against `maxAttemptsPerContact`, to
|
|
234
|
+
flag **BLOCKED BY ATTEMPT CAP**.
|
|
235
|
+
**Q4** — open attempts (`dtStarted IS NOT NULL AND dtEnded IS NULL`) with hours-open; these wedge a
|
|
236
|
+
contact out of **all** future dialing.
|
|
237
|
+
**Q5** — active-campaign config sanity: flag blank `timezone` and expired `dateEnd`.
|
|
238
|
+
|
|
239
|
+
Also:
|
|
240
|
+
- Grep the worker log / Sentry for **`Cannot parse callbackTime`** — **every hit is a lost callback.**
|
|
241
|
+
- In `Core`, surface the silent throw:
|
|
242
|
+
```sql
|
|
243
|
+
SELECT id, dtCompleted, failureReason FROM WorkerJobs
|
|
244
|
+
WHERE isSuccess = 0 AND failureReason LIKE '%Vapi%'
|
|
245
|
+
ORDER BY dtCompleted DESC LIMIT 50;
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
## Verification gaps (do not treat these as confirmed)
|
|
249
|
+
|
|
250
|
+
- **No production DB access from a dev laptop** (the client DB needs VPN) — every production claim
|
|
251
|
+
above is a **code/schema inference pending Q1–Q5**.
|
|
252
|
+
- **The live Vapi assistant config could not be inspected.** `C:\WWW\info` is the Next.js funnel:
|
|
253
|
+
no `vapi/` templates, no `scripts/`, zero matches for `callbackTime`. So it is **unconfirmed**
|
|
254
|
+
whether the live structured-output schema already constrains the field, and which server-message
|
|
255
|
+
types each assistant currently subscribes to.
|
|
256
|
+
- **Parser results verified on PHP 8.1.10 only** (an `upgrade-to-php8.5` branch exists; PHP's
|
|
257
|
+
relative-date grammar has been stable across 7.x–8.x, so results are not expected to differ).
|
|
258
|
+
- **Vapi's actual event ordering** — whether a `status-update` can land *after* the
|
|
259
|
+
`end-of-call-report` — could not be observed.
|
|
260
|
+
- **api2's PUT semantics for an explicit `null` were not confirmed from source.** If api2 ignores
|
|
261
|
+
an explicit null, the clearing hazard in the webhook doc's concurrency gotcha disappears. Worth a
|
|
262
|
+
one-line check.
|
|
263
|
+
|
|
264
|
+
## Change history
|
|
265
|
+
- 2026-07-30 — Initial doc. Read-only root-cause investigation of the production "call me back
|
|
266
|
+
never happens" bug (worker2 `_production` @ `ef50345`, `_underscore` `_production` @ `5047d135`).
|
|
267
|
+
Root cause: `convertDateTime` returns null for 22/32 realistic phrasings, so
|
|
268
|
+
`actionScheduleCallback` aborts before writing `dtNextContactRequested`. **Corrects** the earlier
|
|
269
|
+
"spelled-out numbers" attribution — the real trigger is the preposition/filler word (`in`, `at`,
|
|
270
|
+
`around`, `sometime`); `"tomorrow at 3pm"` (the phrasing the code comment claims works) fails,
|
|
271
|
+
ISO 8601 parses cleanly. Ruled out the downstream wiring (the write, the dialer bypass, and
|
|
272
|
+
`CALLBACK_SCHEDULED` not being terminal are all fine). Also recorded: parsed times are often
|
|
273
|
+
wrong/in the past with call windows bypassed (compliance exposure); `maxAttemptsPerContact` is
|
|
274
|
+
**not** bypassed for callbacks; the failure is silent out to Vapi (HTTP 200, no retry); plus the
|
|
275
|
+
fix priority order and the Q1–Q5 production queries. Diagnosis only — no code changed; handed off
|
|
276
|
+
to bala. (tcox)
|
|
277
|
+
</content>
|
|
278
|
+
</invoke>
|
|
@@ -6,13 +6,14 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-07-
|
|
10
|
-
owners: [snaredla, tcox]
|
|
9
|
+
updated: 2026-07-30
|
|
10
|
+
owners: [snaredla, tcox, bala]
|
|
11
11
|
files:
|
|
12
12
|
- worker2/Worker/Vapi.php
|
|
13
13
|
- worker2/Worker/Ai/Bdr/Vapi.php
|
|
14
14
|
- worker2/Controller/Index.php
|
|
15
15
|
related:
|
|
16
|
+
- callback-scheduling.md
|
|
16
17
|
- ../../ai-bdr/features/call-orchestration.md
|
|
17
18
|
- ../../ai-bdr/features/vapi-integration.md
|
|
18
19
|
- ../../ai-bdr/features/live-call-status.md
|
|
@@ -114,6 +115,30 @@ other types before they reach `processVapiEndOfCallPayload()`.
|
|
|
114
115
|
- **Symptom:** `Warning: Undefined property: stdClass::$contactAttemptUuid` (promoted to an
|
|
115
116
|
`ErrorException` by the framework error handler, captured by Sentry).
|
|
116
117
|
|
|
118
|
+
### The guard is STILL ABSENT on `_production` — and it is a concurrency hazard, not a data-quality one
|
|
119
|
+
|
|
120
|
+
Confirmed 2026-07-30 on `_production` @ `ef50345`: the string `end-of-call-report` appears **only
|
|
121
|
+
in the sample-payload comment at `Worker/Vapi.php:14`**, and a grep for `message->type` across all
|
|
122
|
+
of worker2 returns **zero hits**. `Webhook()` calls `processVapiEndOfCallPayload()`
|
|
123
|
+
**unconditionally** (`:71–79`). Verified consequences:
|
|
124
|
+
|
|
125
|
+
- **`dtEnded` is force-stamped `NOW()` on EVERY event** (`?: date(…)` at `:135`). So a **mid-call
|
|
126
|
+
`status-update` closes the attempt while the call is still live**, which releases the
|
|
127
|
+
in-progress lock at `Ai/Bdr/Vapi.php:305–313` and lets the dialer place **a second concurrent
|
|
128
|
+
call to the same person, mid-conversation.** `status-update` is live-subscribed on the
|
|
129
|
+
healthcare assistant ([vapi-integration.md](../../ai-bdr/features/vapi-integration.md)).
|
|
130
|
+
- It also **NULLs `Contacts.dtNextContactRequested` on every event** (`:268–281`).
|
|
131
|
+
- **Ruled out:** it does *not* blank `c_callbackTime` / `c_callOutcome` — the `!== ''` guards at
|
|
132
|
+
`:189–215` prevent that.
|
|
133
|
+
- **Replay hazard.** Within a single request the order is clear-then-set, so one event is
|
|
134
|
+
self-consistent. But a late or duplicate event arriving **after** the report re-runs
|
|
135
|
+
`actionScheduleCallback` against the **raw stored string re-anchored to a new "now"** — so
|
|
136
|
+
relative times drift forward on each replay, and unparseable ones clear
|
|
137
|
+
`dtNextContactRequested` **without restoring it**, leaving it permanently NULL.
|
|
138
|
+
|
|
139
|
+
Fix: `if (($message->type ?? '') !== 'end-of-call-report') return 'ok';` at `:71–79`, and stop
|
|
140
|
+
force-stamping `dtEnded` from `NOW()` — set it only from a real `endedAt`.
|
|
141
|
+
|
|
117
142
|
## Inbound / test calls vs worker-placed outbound calls
|
|
118
143
|
|
|
119
144
|
The metadata shape depends on **who placed the call**, not the event type:
|
|
@@ -194,6 +219,13 @@ above is easy to over-read:
|
|
|
194
219
|
The exclusion (L314–331: `c_dncRequested`, `DO_NOT_CALL`, `MEETING_BOOKED` / `DNC_REQUESTED`
|
|
195
220
|
outcomes) applies only on the non-requested path; a customer who actively re-engages
|
|
196
221
|
(`dtNextContactRequested`) is dialed regardless of a prior terminal outcome.
|
|
222
|
+
- **The attempt cap is the ONE gate the requested path does NOT bypass** (source-verified
|
|
223
|
+
2026-07-30). The `attemptCount < Campaigns.maxAttemptsPerContact` predicate sits **outside** the
|
|
224
|
+
`dtNextContactRequested` OR-branch (`Ai/Bdr/Vapi.php:234–235`; the branch starts at `:238`), and
|
|
225
|
+
the default cap is **3**. So a prospect who asks for a callback on their **3rd** attempt gets
|
|
226
|
+
`dtNextContactRequested` set correctly and is then **permanently ineligible** — the cap silently
|
|
227
|
+
overrides an explicit customer request. See
|
|
228
|
+
[callback-scheduling.md](callback-scheduling.md).
|
|
197
229
|
- **The normal (non-requested) path requires a `CampaignCallWindows` row to EXIST for the current
|
|
198
230
|
day/time.** A campaign with **no** call-window rows can therefore **only ever dial contacts with a
|
|
199
231
|
pending `dtNextContactRequested`** — this is the **safest launch posture for a web-funnel-only
|
|
@@ -239,19 +271,30 @@ character — a 37-char value) **passes** the readiness gate and the campaign is
|
|
|
239
271
|
Vapi `POST /call/phone` **400s** at call time. Validate the assistant identifier's shape before
|
|
240
272
|
assigning an assistant to any campaign.
|
|
241
273
|
|
|
242
|
-
### Gotcha (HIGH — call quality) —
|
|
274
|
+
### Gotcha (HIGH — call quality) — most spoken callback times are silently dropped
|
|
275
|
+
|
|
276
|
+
> **CORRECTED 2026-07-30.** This gotcha previously blamed **spelled-out numbers**
|
|
277
|
+
> (`"in ten minutes"`). That attribution was **understated and misleading** — the real trigger is
|
|
278
|
+
> the **preposition / filler word**, and the failure rate is far higher than the old wording
|
|
279
|
+
> implied (**22 of 32** realistic phrasings return null, measured on PHP 8.1.10).
|
|
280
|
+
|
|
281
|
+
`actionScheduleCallback` (`Worker/Vapi.php` ~L387–431) parses `structured.callbackTime` through
|
|
282
|
+
`convertDateTime` (~L609–635), which leans entirely on PHP's relative-date grammar. When it
|
|
283
|
+
returns null the method `error_log`s `"Cannot parse callbackTime"` and **`return`s early**
|
|
284
|
+
(`:399–406`) — `dtNextContactRequested` is never set and `callType` is never flipped to
|
|
285
|
+
`CALLBACK`, so the promised callback **never happens** and nothing surfaces the failure.
|
|
286
|
+
|
|
287
|
+
The words **`in`, `at`, `around`, `sometime`** break the parse: `"10 minutes"` works but
|
|
288
|
+
`"in 10 minutes"` does not; `"tomorrow 3pm"` works but **`"tomorrow at 3pm"` — the exact example
|
|
289
|
+
the code comment at `:399` claims works — does not.** Also null: `later`, `Monday at 9am`,
|
|
290
|
+
`in an hour`, `in 2 days`, `this afternoon`, `tomorrow morning`, `end of day`, `15 minutes from now`.
|
|
291
|
+
**ISO 8601 parses cleanly** (`2026-08-01T15:00:00Z` and the `.000Z` variant), which is why the
|
|
292
|
+
durable fix belongs in the Vapi structured-output schema.
|
|
243
293
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
number as a word), `convertDateTime` returns **null**, `actionScheduleCallback` `error_log`s
|
|
249
|
-
`"Cannot parse callbackTime"` and **aborts** — `dtNextContactRequested` is never set and `callType`
|
|
250
|
-
is never flipped to `CALLBACK`, so the promised callback **never happens** and nothing surfaces the
|
|
251
|
-
failure. Reproduced live 2026-07-21 (attempt 183, contact 248). Two recommended fixes (do both):
|
|
252
|
-
(a) constrain the Vapi structured-output `callbackTime` schema to ISO 8601 / numeric-relative so the
|
|
253
|
-
model can't spell the number out (see `../../ai-bdr/features/vapi-integration.md`); (b) defensively
|
|
254
|
-
normalize spelled-out numbers (`one`..`sixty`) in `convertDateTime` before parsing.
|
|
294
|
+
**Full root-cause analysis, three further defects on the same path (wrong/past times with call
|
|
295
|
+
windows bypassed; `maxAttemptsPerContact` not bypassed for callbacks; the silent HTTP-200
|
|
296
|
+
failure), the fix priority order, and the production diagnostic queries live in
|
|
297
|
+
[callback-scheduling.md](callback-scheduling.md).**
|
|
255
298
|
|
|
256
299
|
### Gotcha (SECURITY) — the Vapi API bearer token is hardcoded in source
|
|
257
300
|
|
|
@@ -287,7 +330,13 @@ status. See the `ai-bdr` skill / `_Model_True_ContactAttempt` for the full field
|
|
|
287
330
|
`c_vapiCallIdentifier`.
|
|
288
331
|
- **No message-type guard = errors.** Always filter to `end-of-call-report` first.
|
|
289
332
|
- **Persistence is via the Toga REST API**, not a direct model `save()` — failures are
|
|
290
|
-
caught and logged, not re-thrown.
|
|
333
|
+
caught and logged, not re-thrown. **One exception:** `actionScheduleCallback`'s
|
|
334
|
+
`PUT /contacts/{uuid}` (`:420–430`) is the only write in the file **without** a try/catch
|
|
335
|
+
(compare `:255–284` and `:363–367`) and is called with `$throwExceptionOnApiError = true`
|
|
336
|
+
(`_underscore Component/Api/Toga/Toga.php:167`, `:172` throw). Because
|
|
337
|
+
`Controller/Index.php:511–526` swallows the throw and still returns **HTTP 200**, Vapi never
|
|
338
|
+
retries and the callback is lost with **no external signal** — the only trace is
|
|
339
|
+
`Core.WorkerJobs.failureReason`.
|
|
291
340
|
- **Timestamps are stored in Central Time**; Cal.com booking converts `c_meetingTime` back to
|
|
292
341
|
UTC.
|
|
293
342
|
|
|
@@ -296,6 +345,20 @@ status. See the `ai-bdr` skill / `_Model_True_ContactAttempt` for the full field
|
|
|
296
345
|
- [VAPI Integration — assistants, tools, structured output](../../ai-bdr/features/vapi-integration.md)
|
|
297
346
|
|
|
298
347
|
## Change history
|
|
348
|
+
- 2026-07-30 — **Corrected** the HIGH callback gotcha: the parse failure is driven by the
|
|
349
|
+
**preposition/filler word** (`in`, `at`, `around`, `sometime`), **not** spelled-out numbers —
|
|
350
|
+
`"tomorrow at 3pm"` (the phrasing the code comment claims works) fails, and 22/32 realistic
|
|
351
|
+
phrasings return null. Split the full root cause + three further defects + fix order + production
|
|
352
|
+
queries into the new [callback-scheduling.md](callback-scheduling.md). Also, from the same
|
|
353
|
+
read-only `_production` @ `ef50345` investigation: the **`message.type` guard is still absent**
|
|
354
|
+
(zero `message->type` hits repo-wide) and is a **concurrency hazard** — `dtEnded` is force-stamped
|
|
355
|
+
`NOW()` on every event, so a mid-call `status-update` closes the attempt, releases the
|
|
356
|
+
in-progress lock and lets the dialer place a second concurrent call to the same person; plus a
|
|
357
|
+
replay hazard that can leave `dtNextContactRequested` permanently NULL. Recorded that the attempt
|
|
358
|
+
cap is the one gate the requested path does **not** bypass, and that
|
|
359
|
+
`actionScheduleCallback`'s write is the file's only un-try/catch'd write while the controller
|
|
360
|
+
still returns HTTP 200 (silent loss, no Vapi retry). Diagnosis only — no code changed; handed off
|
|
361
|
+
to bala. (tcox)
|
|
299
362
|
- 2026-07-23 — Broadened the SECURITY credential-rotation gotcha: the AI-BDR rotation ticket now
|
|
300
363
|
also covers the **HubSpot access token** and **Toga API client secret** committed to git in the
|
|
301
364
|
old `info` repo's tracked `.env.*` files (treat as compromised), alongside the hardcoded Vapi
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: session
|
|
3
|
+
slug: bdr-live-call-status-and-callback-bug
|
|
4
|
+
title: BDR funnel live call status + real summary email, and AI-BDR callback root cause
|
|
5
|
+
author: tcox
|
|
6
|
+
repos: [bdr, worker2]
|
|
7
|
+
framework: "2.0"
|
|
8
|
+
client: shared
|
|
9
|
+
status: active
|
|
10
|
+
created: 2026-07-30
|
|
11
|
+
updated: 2026-07-30
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Session: bdr-live-call-status-and-callback-bug
|
|
15
|
+
**Date:** 2026-07-30
|
|
16
|
+
**Project/Repo:** bdr (2.0), plus a read-only worker2 investigation
|
|
17
|
+
**Task:** Make the BDR funnel usable by anyone (remove the contact-reference gate), drive the Success screen from real call data instead of a timed simulation, send the call summary by real email, and then root-cause a production bug where a callback requested during an AI-BDR call never happens.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## What WORKED
|
|
22
|
+
|
|
23
|
+
- **Contact-reference gate removed.** Deleted the `hasLead` submit-block and the
|
|
24
|
+
`phone.missingContactNotice` copy key from `src/flow/screens/CallNow.tsx`,
|
|
25
|
+
`src/flow/screens/Schedule.tsx`, `src/content/schema.ts`, `src/content/default.ts`.
|
|
26
|
+
Evidence: grep for `hasLead|missingContactNotice` across the repo returns zero matches;
|
|
27
|
+
design-parity audit against `C:\WWW\BDR\mockup` confirmed the removal restored mockup
|
|
28
|
+
parity (the mockup never had the notice).
|
|
29
|
+
- **Vapi's own API confirmed as a working live call-status source.** `GET https://api.vapi.ai/call`
|
|
30
|
+
returned HTTP 200 with the org's calls. Verified field shape: `id, status, type, createdAt,
|
|
31
|
+
startedAt, endedAt, endedReason, summary, transcript, recordingUrl, customer, analysis,
|
|
32
|
+
messages, cost`. Status enum `queued|ringing|in-progress|forwarding|ended`. Timestamps are
|
|
33
|
+
ISO-8601 UTC with `Z`. **Key fact: `createdAt` = placed, `startedAt` = ANSWERED,
|
|
34
|
+
`endedAt` = hangup.** A real call showed `createdAt 18:53:08 / startedAt 18:53:18 /
|
|
35
|
+
endedAt 18:53:35, endedReason "voicemail"` — the 10s gap is ring time.
|
|
36
|
+
- **Fixed the worker dispatcher reply-prefix bug** in `src/server/workerDialer.ts`. The worker
|
|
37
|
+
wraps every reply as `[<requestId>] Successfully Executed (<n> seconds): <json>`, so the old
|
|
38
|
+
`response.json()` always threw: it logged "immediate dial not placed" on every SUCCESSFUL
|
|
39
|
+
dial and discarded the response body. Now strips the prefix before parsing and returns a
|
|
40
|
+
typed `DialResult` carrying `vapiCallId` / `attemptId`. Evidence: regression test asserts a
|
|
41
|
+
dispatcher-wrapped success reply no longer logs the false warning.
|
|
42
|
+
- **Live call lifecycle on the Success screen.** New `/api/call-status` route, `vapiCall.ts`
|
|
43
|
+
server reader, `useCallStatusViewModel` polling hook. Mapping: `queued`/`ringing` -> dialing
|
|
44
|
+
with NO talk timer; `in-progress`/`forwarding` with `startedAt` -> active, timer counted from
|
|
45
|
+
the ANSWER time with server-clock-offset correction; `ended` -> frozen true talk duration;
|
|
46
|
+
ended with no `startedAt` or a voicemail/no-answer/busy reason -> a distinct not-answered
|
|
47
|
+
state that shows no timer and suppresses the recap card. The old simulation is retained
|
|
48
|
+
verbatim as the fallback whenever status is unavailable.
|
|
49
|
+
- **Post-hangup summary grace.** Vapi writes `summary` asynchronously after hangup, so the
|
|
50
|
+
first ended poll is usually `summary: null`. Added a bounded 30s / 2s-cadence grace that
|
|
51
|
+
keeps polling after ended until the real summary lands, with the screen already showing the
|
|
52
|
+
ended state. Frozen duration provably unchanged across the window (tested).
|
|
53
|
+
- **Real email send of the call summary.** New `/api/share-summary` route + `src/server/mailer.ts`
|
|
54
|
+
(nodemailer) + shared `src/lib/summaryText.ts`, so the Copy button and the emailed body are
|
|
55
|
+
byte-identical by construction. The client may only supply recipient + campaign id + bounded
|
|
56
|
+
variant index + signed token; never body or subject text.
|
|
57
|
+
- **mailto fallback removed entirely** (developer decision mid-session). `src/lib/shareMailto.ts`
|
|
58
|
+
and its test deleted; Send now only ever reaches sent or failed-with-retry.
|
|
59
|
+
- **Security hardening after the CSO audit returned BLOCK** (it flagged an unauthenticated
|
|
60
|
+
arbitrary-recipient mailer). Applied: purpose-scoped HMAC tokens (`status` vs `share`), token
|
|
61
|
+
moved from the URL query string to an `x-status-token` header, a valid share token now
|
|
62
|
+
REQUIRED for every send, Content-Type and `sec-fetch-site` checks, strict recipient validation
|
|
63
|
+
(rejects angle-bracket display-name form, commas, all non-ASCII), SMTP `requireTLS` + TLS 1.2
|
|
64
|
+
+ timeouts, `Cache-Control: no-store, private` on every envelope, exact ACL error-code matching
|
|
65
|
+
that fails CLOSED on an unknown 403, upstream AbortSignal timeouts, rejection of future-dated
|
|
66
|
+
token issuance. The Vapi call id travels only inside signed claims, never as a client param.
|
|
67
|
+
- **Final verification:** `npx tsc --noEmit` clean, `npm run lint` clean, `npm test`
|
|
68
|
+
**306/306 passing across 20 files**.
|
|
69
|
+
- **Credentials located and wired** (locations only): 2.0 SMTP settings are hardcoded constants
|
|
70
|
+
in `_underscore/Email.php` (SES us-west-2, port 587, TLS); the Vapi bearer credential is a
|
|
71
|
+
hardcoded constant in `worker2/Worker/Ai/Bdr/Vapi.php`. BDR consumes both as server-only env
|
|
72
|
+
vars `TOGA_SMTP_HOST/PORT/USER/PASS`, `TOGA_EMAIL_FROM`, `TOGA_VAPI_TOKEN` in the gitignored
|
|
73
|
+
`.env.local` (documented in `.env.example`).
|
|
74
|
+
- **Two knowledge captures pushed:** `6ed04d3` (8 docs — live-call-status, the api2 v2 query
|
|
75
|
+
contract, and updates) and `968404e` (3 docs — the callback root cause, with `bala` added as
|
|
76
|
+
co-owner for handoff).
|
|
77
|
+
- **Callback bug root-caused with empirical proof.** Extracted worker2's `convertDateTime` and
|
|
78
|
+
executed it on PHP 8.1.10: 22 of 32 realistic phrasings return null.
|
|
79
|
+
|
|
80
|
+
## What did NOT work — DO NOT RETRY THESE
|
|
81
|
+
|
|
82
|
+
- **Reading `ContactAttempts` through api2 — HTTP 403 `EZ-1`, record-level block.** Re-verified
|
|
83
|
+
live on 2026-07-30 against `api.togahub.com` with the BDR client credential, on ALL variants:
|
|
84
|
+
nested `/contacts/{uuid}/contact-attempts`, flat `/contact-attempts`, both with and without an
|
|
85
|
+
explicit `fields=` list. Response is `data.contactAttempts: null`. The pending read-only ACL
|
|
86
|
+
grant has still NOT landed. Do not build anything against this resource until it does.
|
|
87
|
+
- **Selecting `Contacts.id`** — `/contacts/{uuid}?fields=id` returns 403 `EZ-2` with
|
|
88
|
+
`identifiers.fields: ["id"]`. The client has no field grant on `Contacts.id`. Filter on
|
|
89
|
+
`Contacts.uuid` instead (api2 does not ACL-check `where`/`join`, only `fields`).
|
|
90
|
+
- **Direct production database access from this laptop.** TCP 3306 to the
|
|
91
|
+
`*.client.database.togahub.com` writer host **times out** (VPN required). This machine also
|
|
92
|
+
has no mysql client, no PHP, and no Python (only the Windows Store stub). Do not plan work
|
|
93
|
+
that assumes direct SQL — hand the developer ready-to-run SQL with pre-flight reads instead.
|
|
94
|
+
- **Reading the api2 auth token from `data.token`** — wrong path, the response has no such key.
|
|
95
|
+
The access token is at **`data.tokens.access`**.
|
|
96
|
+
- **Sharing the raw Claude Code session transcript (`~/.claude/projects/c--WWW/<id>.jsonl`) for
|
|
97
|
+
handoff.** Verified it contains live credential VALUES in plaintext: SMTP password (5
|
|
98
|
+
occurrences), Vapi token (2), Toga client API secret (4), HubSpot access token (4). Use
|
|
99
|
+
`/session-save` + `/session-resume` and the knowledge docs for handoff instead.
|
|
100
|
+
- **(worker2, the production bug) `convertDateTime` parsing natural-language callback times.**
|
|
101
|
+
22/32 phrasings return null on PHP 8.1.10. The trigger is the PREPOSITION/FILLER WORD, not
|
|
102
|
+
spelled-out numbers as previously recorded: `"10 minutes"` parses but `"in 10 minutes"` does
|
|
103
|
+
not; `"tomorrow 3pm"` parses but `"tomorrow at 3pm"` does not — and that is the exact example
|
|
104
|
+
the code comment claims works.
|
|
105
|
+
|
|
106
|
+
## Not tried yet (candidates for next session)
|
|
107
|
+
|
|
108
|
+
- A **live end-to-end call test** of the new UI. Not possible yet: campaign 2's `dateEnd` lapsed
|
|
109
|
+
on 2026-07-28 and the re-activation SQL had not been run at save time.
|
|
110
|
+
- **Verifying a real SMTP send.** Credentials are wired but no successful delivery has been
|
|
111
|
+
observed; `TOGA_EMAIL_FROM=DevTeam@goagilant.com` may or may not be an SES-verified identity.
|
|
112
|
+
- **Running Q1-Q5 from `2.0/apps/worker2/features/callback-scheduling.md`** against `Client_True`
|
|
113
|
+
to confirm the callback root cause with production data. Q1 is decisive.
|
|
114
|
+
- **Fixing any of the worker2 callback defects** — this session was diagnosis only; no worker2
|
|
115
|
+
code was modified. bala is picking this up.
|
|
116
|
+
- **Adding the `TOGA_*` env vars to the Amplify console** so the deployed site gets live status
|
|
117
|
+
and email (local `.env.local` only affects dev).
|
|
118
|
+
- **Binding the email recipient to the contact's on-file address** — the stricter anti-abuse
|
|
119
|
+
option; currently a token holder can email an arbitrary recipient (accepted residual risk).
|
|
120
|
+
- **Splitting BDR knowledge out of `2.0/apps/ai-bdr/` into its own `2.0/apps/bdr/` folder with an
|
|
121
|
+
`architecture.md`.** Flagged twice; an elevated call that needs a deliberate decision.
|
|
122
|
+
|
|
123
|
+
## Current file state
|
|
124
|
+
|
|
125
|
+
All BDR changes are **uncommitted on branch `TRUE-80435`** (working tree only — nothing was
|
|
126
|
+
committed or pushed all session, per the git-workflow rule).
|
|
127
|
+
|
|
128
|
+
| File | Status | Notes |
|
|
129
|
+
|------|--------|-------|
|
|
130
|
+
| `bdr src/flow/screens/CallNow.tsx` | modified | gate + notice removed; submit enables on the digit rule alone |
|
|
131
|
+
| `bdr src/flow/screens/Schedule.tsx` | modified | same removal |
|
|
132
|
+
| `bdr src/flow/screens/Success.tsx` | modified | live stages via a kind-keyed lookup; simulation path preserved verbatim |
|
|
133
|
+
| `bdr src/flow/screens/CallSummary.tsx` | modified | async send with sending/sent/failed; real-summary rendering; no mailto |
|
|
134
|
+
| `bdr src/flow/useCallStatusViewModel.ts` | new | poll loop + pure reducer, cadence, caps, summary grace |
|
|
135
|
+
| `bdr src/flow/callStatusApi.ts` | new | client fetcher, sends the token in a header |
|
|
136
|
+
| `bdr src/flow/shareSummaryApi.ts` | new | maps send outcomes to sent/unavailable/failed |
|
|
137
|
+
| `bdr src/flow/leadActions.ts` | modified | returns a handle `{ok, uuid?, statusToken?, shareToken?}`; `simulated` flag |
|
|
138
|
+
| `bdr src/flow/useAgentFlow.ts` | modified | stores the call handle under the submitGen guard |
|
|
139
|
+
| `bdr src/flow/AgentFlowRoot.tsx` | modified | passes the handle to calling-mode Success only |
|
|
140
|
+
| `bdr src/server/vapiCall.ts` | new | reads `GET api.vapi.ai/call/{id}`; maps to the CallStatus union |
|
|
141
|
+
| `bdr src/server/callStatus.ts` | modified | ContactAttempts path retained as the (403) fallback |
|
|
142
|
+
| `bdr src/server/statusToken.ts` | new | purpose-scoped HMAC, TTL, skew, optional `vapiCallId` claim |
|
|
143
|
+
| `bdr src/server/mailer.ts` | new | nodemailer, requireTLS, timeouts, scrubbed logs |
|
|
144
|
+
| `bdr src/server/workerDialer.ts` | modified | **prefix bug fixed**; returns `DialResult` with `vapiCallId` |
|
|
145
|
+
| `bdr src/server/callbackService.ts` | modified | surfaces `vapiCallId` |
|
|
146
|
+
| `bdr src/server/toga.ts` | modified | added `togaApiGetEnvelope` (needed to read EZ-1/EZ-2 out of a 403) |
|
|
147
|
+
| `bdr src/lib/summaryText.ts` | new | single renderer shared by Copy and the email |
|
|
148
|
+
| `bdr src/lib/emailFormat.ts` | new | strict recipient validation |
|
|
149
|
+
| `bdr src/lib/callStatus.ts` | new | shared CallStatus union + header constant |
|
|
150
|
+
| `bdr src/app/api/call-status/route.ts` | new | token-gated, never 500s, `no-store` |
|
|
151
|
+
| `bdr src/app/api/share-summary/route.ts` | new | share-token required, server-built content only |
|
|
152
|
+
| `bdr src/app/api/call-now/route.ts` | modified | mints both tokens with the `vapiCallId` claim |
|
|
153
|
+
| `bdr src/lib/shareMailto.ts` | **deleted** | mailto fallback removed by developer decision |
|
|
154
|
+
| `bdr src/content/schema.ts` + `default.ts` | modified | live-stage + send-state copy; notice key removed |
|
|
155
|
+
| `bdr src/proxy.ts` | modified | matcher + limiters for the two new routes |
|
|
156
|
+
| `bdr .env.example` | modified | documents SMTP + `TOGA_VAPI_TOKEN` |
|
|
157
|
+
| `bdr .env.local` | modified | real values added locally (gitignored) |
|
|
158
|
+
| `bdr package.json` | modified | added `nodemailer` + `@types/nodemailer` |
|
|
159
|
+
| worker2 / _underscore / api2 | **unmodified** | read-only investigation only |
|
|
160
|
+
|
|
161
|
+
## Decisions made
|
|
162
|
+
|
|
163
|
+
- **Vapi's API is the live call-status source, not `ContactAttempts`.** Rationale: ContactAttempts
|
|
164
|
+
is 403-blocked AND structurally inferior — `dtStarted` is DIAL time, so it cannot distinguish
|
|
165
|
+
ringing from talking, and `dtStarted` is retroactively rewritten by the webhook. Vapi exposes
|
|
166
|
+
ringing vs answered vs ended plus a real summary. It stays the better source even after the
|
|
167
|
+
grant lands. Rejected: waiting for the ACL grant; continuing to simulate.
|
|
168
|
+
- **The backend contract is unchanged for anonymous submits** (developer decision). The funnel
|
|
169
|
+
loads and accepts a phone number for anyone, but calls still only fire for campaign-linked
|
|
170
|
+
contacts, matching the original BDR backend. Rejected: server-side anonymous contact creation
|
|
171
|
+
(would have required a DNC pre-check that the 403 blocks anyway).
|
|
172
|
+
- **The mailto fallback was removed entirely** (developer decision). Consequence, accepted: with
|
|
173
|
+
SMTP unconfigured the Send button can only reach the failed state.
|
|
174
|
+
- **Token purpose-scoping and header transport**, adopted in response to the CSO BLOCK verdict.
|
|
175
|
+
Rejected: leaving one token to serve both read and send, and leaving it in the query string
|
|
176
|
+
(a 30-minute bearer capability over a named individual's call summary would land in access logs).
|
|
177
|
+
- **Accepted residual risk:** a token holder can email the summary to any address they type
|
|
178
|
+
(preserves the share-with-a-colleague use case). The stricter alternative is on the not-tried list.
|
|
179
|
+
- **BDR knowledge stays filed under `2.0/apps/ai-bdr/`** rather than restructuring mid-capture.
|
|
180
|
+
|
|
181
|
+
## Blockers
|
|
182
|
+
|
|
183
|
+
- **`ContactAttempts` read-only ACL grant still pending** (403 EZ-1). Blocks only the fallback
|
|
184
|
+
path — the Vapi path works without it. Note for whoever files it: the `c_*` fields are granted
|
|
185
|
+
through `AclCustomFieldPermissions`, a DIFFERENT table from the standard fields.
|
|
186
|
+
- **Campaign 2 (`26.05 - AI BDR - Ryan Nitti`, id 2) `dateEnd` lapsed 2026-07-28**, so no calls
|
|
187
|
+
place at all until it is extended. SQL was handed to the developer; the production DB is not
|
|
188
|
+
reachable from this machine.
|
|
189
|
+
- **No live end-to-end verification yet** of the call UI or a real email delivery.
|
|
190
|
+
|
|
191
|
+
## Exact next step
|
|
192
|
+
|
|
193
|
+
> In `Client_True`, run the campaign-2 re-activation pre-flight reads first (confirm the dialable
|
|
194
|
+
> count on campaign 2 is 0 or 1 and that contact 248 has no open `ContactAttempts` row with
|
|
195
|
+
> `dtEnded IS NULL`), then flip all campaign-2 contacts except 248 to `isOkayToCall = 0` and
|
|
196
|
+
> extend `Campaigns.dateEnd`. Then restart `npm run dev` in `C:\WWW\BDR`, open the funnel from
|
|
197
|
+
> the `?hsContactId=` link, tap Call Now, and confirm the Success screen shows Ringing with no
|
|
198
|
+
> timer, the timer starting at 00:00 on answer, and Call ended with the real Vapi summary.
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
_Saved by /session-save on 2026-07-30_
|
package/package.json
CHANGED