toga-ai 1.0.197 → 1.0.198

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.
@@ -17,6 +17,7 @@ files:
17
17
  related:
18
18
  - ../architecture.md
19
19
  - vapi-integration.md
20
+ - ../../worker2/features/vapi-webhook-handler.md
20
21
  ---
21
22
 
22
23
  ## Summary
@@ -204,8 +205,23 @@ gracefully handles `"Information not available"`.
204
205
 
205
206
  ## Where the PHP code lives
206
207
 
207
- **Not in this repo.** The handler lives in the TOGA worker repo (`worker`
208
- for 1.0 or `worker2` for 2.0). Likely class shape:
208
+ **Now committed in `worker2`** as `_Worker_Vapi` (`worker2/Worker/Vapi.php`,
209
+ the webhook) alongside the outbound dialer `_Worker_Ai_Bdr_Vapi`
210
+ (`worker2/Worker/Ai/Bdr/Vapi.php`). Full detail:
211
+ **`2.0/apps/worker2/features/vapi-webhook-handler.md`**.
212
+
213
+ > **Corrections confirmed against the live `_Worker_Vapi` code (2026-06-24):**
214
+ > 1. **Structured output is in `artifact.structuredOutputs`** (keyed by a random
215
+ > UUID; first entry's `result`), **not** `analysis.structuredData` —
216
+ > `analysis{}` is always empty in practice.
217
+ > 2. **The webhook join key is `assistantOverrides.metadata.contactAttemptUuid`**
218
+ > (the dialer pre-creates the `ContactAttempt` and passes its UUID). `call.id`
219
+ > is stored only as `c_vapiCallIdentifier`, not used as the join key.
220
+ > 3. **Filter on `message.type`** — only `end-of-call-report` is processed;
221
+ > `status-update`/`hang`/etc. are acknowledged and skipped. Inbound and test
222
+ > calls carry no `contactAttemptUuid` and must be skipped gracefully.
223
+
224
+ Historical (pre-commit) shape guess:
209
225
 
210
226
  - 1.0: an `App_Controller_*` for the webhook (under the framework's HTTP
211
227
  surface), with an `App_Worker_*` or scheduled cron for cadence.
@@ -15,4 +15,5 @@
15
15
  | [Startech Webhook Handler (worker2)](features/startech-webhook-handler.md) | Receives inbound webhook events from Startech (Easeedesk) and creates or updates the corresponding ticket in TOGA 2.0. | worker2/Worker/Startech.php |
16
16
  | [Team Sprint Management & Reporting](features/team-sprint-management.md) | `_Worker_Team_Sprint` (file `Worker/Team/Sprint.php`) is the engine behind TOGA's internal **development-sprint process and reporting**. | worker2/Worker/Team/Sprint.php |
17
17
  | [Teams Meeting Transcript Export](features/teams-transcript-export.md) | `_Worker_Team_Transcripts` (action `Team/Transcripts/Export`) polls Microsoft Graph for Teams meeting transcripts produced by a set of organizers, classifies ea | worker2/Worker/Team/Transcripts.php, worker2/Config/production.ini, worker2/Database/TeamsTranscriptExports.sql, dbchanges2/Core/2026-06-18a - Teams Transcript Export schedule.sql |
18
+ | [VAPI Webhook Handler (worker2 — AI-BDR end-of-call processing)](features/vapi-webhook-handler.md) | `_Worker_Vapi` ([worker2/Worker/Vapi.php](worker2/Worker/Vapi.php)) is the **PHP side of the AI-BDR call loop** — the webhook that receives VAPI's end-of-call r | worker2/Worker/Vapi.php, worker2/Worker/Ai/Bdr/Vapi.php |
18
19
  | [WJE Freshservice Sync (worker2)](features/wje-freshservice-sync.md) | WJE ("WJE IT", helpdesk `wje.freshservice.com`) is a **Freshservice**-based help-desk client whose tickets, contacts, assets, groups, categories, and canned res | worker2/Worker/Wje.php, _underscore/Component/Api/Wje/Wje.php, _underscore/Model/Wje/Ticket.php, _underscore/Model/Wje/TicketNote.php, _underscore/Model/Wje/Contact.php, _underscore/Model/Wje/Unit.php, _underscore/Model/Wje/TicketTeam.php, _underscore/Model/Wje/TicketCategory.php, _underscore/Model/Wje/AssetType.php, _underscore/Model/Wje/PredefinedReply.php, library/app/api/wje.php, worker/crons/toga2/wje/import_supporting_records.php, worker/crons/toga2/wje/sync_togasupply_wje.php, worker/crons/notifications/reports/wje/wje_common.php, library/app/systemmonitor/wje.php, dbchanges2/Client_Wje/2024-10-04 - WjeOnboarding.sql |
@@ -0,0 +1,156 @@
1
+ ---
2
+ title: VAPI Webhook Handler (worker2 — AI-BDR end-of-call processing)
3
+ framework: "2.0"
4
+ repo: worker2
5
+ project: Worker
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-06-24
10
+ owners: [snaredla]
11
+ files:
12
+ - worker2/Worker/Vapi.php
13
+ - worker2/Worker/Ai/Bdr/Vapi.php
14
+ related:
15
+ - ../../ai-bdr/features/call-orchestration.md
16
+ - ../../ai-bdr/features/vapi-integration.md
17
+ ---
18
+
19
+ ## Summary
20
+
21
+ `_Worker_Vapi` ([worker2/Worker/Vapi.php](worker2/Worker/Vapi.php)) is the **PHP side of the
22
+ AI-BDR call loop** — the webhook that receives VAPI's end-of-call report, writes the call
23
+ results back onto the originating `ContactAttempt`, and dispatches the post-call action
24
+ (book meeting / schedule callback / nurture / DNC). This is the concrete handler that the
25
+ `ai-bdr` repo's `call-orchestration.md` referred to as "lives in worker/worker2 — add a
26
+ doc when committed."
27
+
28
+ It is the **counterpart** to the outbound dialer `_Worker_Ai_Bdr_Vapi`
29
+ ([worker2/Worker/Ai/Bdr/Vapi.php](worker2/Worker/Ai/Bdr/Vapi.php)), which places the calls.
30
+
31
+ ## Key files / entry points
32
+
33
+ | File | Role |
34
+ |---|---|
35
+ | `worker2/Worker/Vapi.php` (`_Worker_Vapi`) | Inbound **webhook** — processes `end-of-call-report`, writes back, dispatches actions |
36
+ | `worker2/Worker/Ai/Bdr/Vapi.php` (`_Worker_Ai_Bdr_Vapi`) | Outbound **dialer** — creates the `ContactAttempt`, places the VAPI call |
37
+
38
+ - Action route: `Vapi/Webhook` → `webhook.togahub.com/vapi` (VAPI server messages POST here).
39
+ - `initialize()` registers the `Client_True` DB connection (`_underscore::DB_CLIENT`).
40
+
41
+ ## How it works
42
+
43
+ 1. **`Webhook($payload, $headers)`** — JSON-decodes the body, takes `message`, hands off to
44
+ `processVapiEndOfCallPayload()`.
45
+ 2. **`processVapiEndOfCallPayload()`**:
46
+ - Reads `call.assistantOverrides.metadata.contactAttemptUuid` (falls back to
47
+ `call.metadata`).
48
+ - Loads `_Model_True_ContactAttempt` by that UUID. This is the **join key** back to our DB.
49
+ - Extracts structured output via `extractVapiStructuredOutput()`.
50
+ - `updateContactAttempt()` writes the results, then `executePostCallActions()` dispatches.
51
+ 3. **`extractVapiStructuredOutput($artifact)`** — returns the first
52
+ `artifact.structuredOutputs[*].result` array. **`analysis{}` is always empty — the data
53
+ lives in `artifact.structuredOutputs`, keyed by a random UUID.**
54
+ 4. **`updateContactAttempt()`** — maps `structured.phpWorkerAction` → `c_callOutcome` /
55
+ `c_actionToTake` via `$actionMap`; builds a payload of only non-empty `c_*` fields (so
56
+ blanks never overwrite existing data); converts VAPI ISO timestamps to **Central Time**;
57
+ persists via `_Component_Api_Toga::send('PUT', '/contact-attempts/{uuid}', …)` (Toga 2.0
58
+ REST API, **not** a direct DB write); also clears the contact's `dtNextContactRequested`.
59
+ 5. **`executePostCallActions()`** — `switch ($contactAttempt->c_actionToTake)`:
60
+ - `BOOK_CALCOM_MEETING` → `actionCalComBookMeeting` (Cal.com booking + queue
61
+ `Ai/Bdr/Netsuite/createLeadFromBookedMeeting` + mark COMPLETED)
62
+ - `SCHEDULE_CALLBACK` → `actionScheduleCallback` (sets `dtNextContactRequested` +
63
+ `contactCallTypeId=CALLBACK`)
64
+ - `SEND_NURTURE_EMAIL` → `actionSendNurtureEmail` (queue
65
+ `Ai/Bdr/Netsuite/createLeadFromNurtureEmail` + mark COMPLETED)
66
+ - `DO_NOT_CALL` → `actionFlagDnc` (`isOkayToCall=0` + EXCLUDE from PENDING/ACTIVE campaigns)
67
+ - `NO_ACTION` / default → reset the campaign-contact to PENDING
68
+
69
+ ### phpWorkerAction → outcome/action map
70
+
71
+ | `phpWorkerAction` | `c_callOutcome` | `c_actionToTake` |
72
+ |---|---|---|
73
+ | `BOOK_CALCOM_MEETING` | MEETING_BOOKED | BOOK_CALCOM_MEETING |
74
+ | `SCHEDULE_CALLBACK` | CALLBACK_SCHEDULED | SCHEDULE_CALLBACK |
75
+ | `SEND_NURTURE_EMAIL` | NURTURE_REQUESTED | SEND_NURTURE_EMAIL |
76
+ | `FLAG_DNC_REMOVE_FROM_CAMPAIGNS` | DNC_REQUESTED | DO_NOT_CALL |
77
+ | `MARK_NOT_INTERESTED` | NOT_INTERESTED | DO_NOT_CALL |
78
+ | `MARK_WRONG_PERSON` | WRONG_PERSON | DO_NOT_CALL |
79
+ | `MARK_VOICEMAIL_RETRY` | VOICEMAIL | NO_ACTION |
80
+ | `MARK_NO_ANSWER_RETRY` | NO_ANSWER | NO_ACTION |
81
+ | `MARK_GATEKEEPER_BLOCKED_RETRY` | GATEKEEPER_BLOCKED | NO_ACTION |
82
+
83
+ ## The contactAttemptUuid write-back mechanism
84
+
85
+ `contactAttemptUuid` is the **sole linkage** between a VAPI call and our DB:
86
+
87
+ 1. The dialer (`_Worker_Ai_Bdr_Vapi`) creates the `ContactAttempt` row **before** placing the
88
+ call ([Ai/Bdr/Vapi.php:438](worker2/Worker/Ai/Bdr/Vapi.php#L438),
89
+ [:694](worker2/Worker/Ai/Bdr/Vapi.php#L694)) and passes its UUID in
90
+ `assistantOverrides.metadata.contactAttemptUuid`
91
+ ([Ai/Bdr/Vapi.php:628](worker2/Worker/Ai/Bdr/Vapi.php#L628)).
92
+ 2. VAPI echoes that same metadata onto **every** server event for the call.
93
+ 3. The webhook loads the row by that UUID and writes results back to it.
94
+
95
+ `contactId` and `campaignId` are read from the **loaded `ContactAttempt` row**, never from the
96
+ webhook payload. `call.id` is stored only as `c_vapiCallIdentifier`.
97
+
98
+ ## Message-type filtering (the bug this doc was born from)
99
+
100
+ VAPI sends the webhook **many** server-message types: `status-update`, `hang`,
101
+ `speech-update`, `end-of-call-report`, etc. **Only `end-of-call-report` carries the call data
102
+ (`artifact` / structured output) and should be processed.** The handler must guard on
103
+ `message.type === 'end-of-call-report'` in `Webhook()` and acknowledge (`return 'ok'`) all
104
+ other types before they reach `processVapiEndOfCallPayload()`.
105
+
106
+ - **Regression origin:** commit `583f6be` "Added remaining webhooks" (2026-05-14) created this
107
+ file and broadened the VAPI subscription to all server-message types. Without a type guard,
108
+ every event was processed as an end-of-call report. First Sentry errors appeared ~late May.
109
+ - **Symptom:** `Warning: Undefined property: stdClass::$contactAttemptUuid` (promoted to an
110
+ `ErrorException` by the framework error handler, captured by Sentry).
111
+
112
+ ## Inbound / test calls vs worker-placed outbound calls
113
+
114
+ The metadata shape depends on **who placed the call**, not the event type:
115
+
116
+ - **Outbound (our dialer)** → metadata carries `contactAttemptUuid` (every event type,
117
+ including status-updates).
118
+ - **Inbound calls and dashboard test calls** → **no `contactAttemptUuid`** (they never went
119
+ through our dialer, so no `ContactAttempt` was pre-created). Test calls may instead carry
120
+ hand-typed metadata like `{contactId, campaignId, attemptNumber}`.
121
+
122
+ Therefore an `end-of-call-report` can legitimately arrive **without** a `contactAttemptUuid`
123
+ (inbound/test). Those have no attempt to write back to and should be **skipped gracefully**
124
+ (`return 'ok'`), not thrown — otherwise they raise `Contact Attempt not found` and fail the
125
+ job. `Contact Attempt not found` should be reserved for a report that *has* a
126
+ `contactAttemptUuid` but no matching row (a real data-integrity anomaly).
127
+
128
+ ## Data model
129
+
130
+ Writes to `ContactAttempts` (via Toga 2.0 API) — `dtStarted`, `dtEnded`, `transcript`,
131
+ `contactCallTypeId`, and many `c_*` fields (`c_callOutcome`, `c_actionToTake`,
132
+ `c_prospectSentiment`, `c_qualificationScore`, `c_callSummary`, `c_meetingTime`,
133
+ `c_meetingType`, `c_contactEmail`, `c_callbackTime`, `c_recordingUrl`, `c_callCost`,
134
+ `c_vapiCallIdentifier`, `c_dncRequested`, etc.). Also updates `Contacts`
135
+ (`dtNextContactRequested`, `contactCallTypeId`, `isOkayToCall`) and `Campaigns_Contacts`
136
+ status. See the `ai-bdr` skill / `_Model_True_ContactAttempt` for the full field list.
137
+
138
+ ## Gotchas / known issues
139
+
140
+ - **Structured data is in `artifact.structuredOutputs`, not `analysis.structuredData`.**
141
+ `analysis{}` is always empty. (Corrects the older `ai-bdr/call-orchestration.md`.)
142
+ - **Join key is `contactAttemptUuid`, not `call.id`.** `call.id` is only stored as
143
+ `c_vapiCallIdentifier`.
144
+ - **No message-type guard = errors.** Always filter to `end-of-call-report` first.
145
+ - **Persistence is via the Toga REST API**, not a direct model `save()` — failures are
146
+ caught and logged, not re-thrown.
147
+ - **Timestamps are stored in Central Time**; Cal.com booking converts `c_meetingTime` back to
148
+ UTC.
149
+
150
+ ## Related docs
151
+ - [AI-BDR Call Orchestration (the PHP↔VAPI contract)](../../ai-bdr/features/call-orchestration.md)
152
+ - [VAPI Integration — assistants, tools, structured output](../../ai-bdr/features/vapi-integration.md)
153
+
154
+ ## Change history
155
+ - 2026-06-24 — Initial doc: webhook handler, contactAttemptUuid write-back, message-type
156
+ filtering bug, inbound/test vs outbound metadata. (snaredla)
@@ -16,7 +16,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
16
16
  ## 2.0 framework
17
17
 
18
18
  - **_underscore** (_Underscore) _(framework core)_ — 14 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
19
- - **worker2** (Worker) — 14 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
19
+ - **worker2** (Worker) — 15 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
20
20
  - **api2** (API) — 6 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
21
21
  - **dbchanges2** (Database Changes) _(framework core)_ — 2 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
22
22
  - **toga2-supply** (TOGa Supply) — 3 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.197",
3
+ "version": "1.0.198",
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",