toga-ai 1.0.476 → 1.0.478
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/1.0/apps/tools/features/design-demo-admin.md +42 -1
- package/knowledge/1.0/standards/frontend.md +75 -0
- package/knowledge/2.0/apps/_underscore/architecture.md +17 -0
- package/knowledge/2.0/apps/_underscore/features/email-send-pipeline.md +15 -2
- package/knowledge/2.0/apps/ai-bdr/INDEX.md +2 -1
- package/knowledge/2.0/apps/ai-bdr/features/live-call-status.md +156 -0
- package/knowledge/2.0/apps/ai-bdr/features/web-funnel-app.md +191 -39
- package/knowledge/2.0/apps/ai-bdr/workflows/safe-call-loop-testing.md +43 -1
- package/knowledge/2.0/apps/api2/INDEX.md +1 -0
- package/knowledge/2.0/apps/api2/features/v2-api-error-codes.md +32 -1
- package/knowledge/2.0/apps/api2/features/v2-rest-query-contract.md +138 -0
- package/knowledge/2.0/apps/dbchanges2/architecture.md +117 -0
- package/knowledge/2.0/apps/worker2/INDEX.md +1 -1
- package/knowledge/2.0/apps/worker2/features/creating-worker-actions.md +27 -2
- package/knowledge/2.0/apps/worker2/features/vapi-webhook-handler.md +35 -1
- package/knowledge/2.0/standards/framework-rules.md +24 -0
- package/knowledge/INDEX.md +2 -2
- package/knowledge/registry.json +2 -1
- package/package.json +1 -1
|
@@ -6,7 +6,7 @@ project: Tools
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-07-
|
|
9
|
+
updated: 2026-07-30
|
|
10
10
|
owners: [jcardinal]
|
|
11
11
|
files:
|
|
12
12
|
- tools/_/app/design/github.php
|
|
@@ -19,6 +19,7 @@ files:
|
|
|
19
19
|
- tools/composer.json
|
|
20
20
|
related:
|
|
21
21
|
- ../architecture.md
|
|
22
|
+
- ../../../standards/frontend.md
|
|
22
23
|
- ../features/persona-gated-navigation.md
|
|
23
24
|
- ../features/saml-sso-auth.md
|
|
24
25
|
- ../workflows/deploy-to-elastic-beanstalk-al2023.md
|
|
@@ -92,6 +93,29 @@ on the next write of that project.json. Tab management via a per-pill "⋯" menu
|
|
|
92
93
|
- **Reorder** — a single-file `tabs.json` commit, validated as a **permutation** of the
|
|
93
94
|
existing list.
|
|
94
95
|
|
|
96
|
+
### Text-storage invariant: JSON holds plain text, escape once at display
|
|
97
|
+
|
|
98
|
+
`tabs.json` tab names and `project.json` `title` values are stored as **plain text**. The
|
|
99
|
+
single correct escape happens at display time in `assets/js/design.js` `esc()` (and in
|
|
100
|
+
`buildStub()`, which `htmlspecialchars()` the title into the generated `index.html`).
|
|
101
|
+
|
|
102
|
+
`App_Design_Github::decodeEntities()` (one pass of `html_entity_decode` with
|
|
103
|
+
`ENT_QUOTES | UTF-8`) enforces that invariant at two chokepoints:
|
|
104
|
+
|
|
105
|
+
- **On read out of storage** — `loadTabs()`, `normalizeTabs()` (including the legacy scalar
|
|
106
|
+
`tab`), and the project read's `title`.
|
|
107
|
+
- **On names/titles read off a request** — `createTab()`, `renameTab()` (both old and new
|
|
108
|
+
name), `deleteTab()`, `reorderTabs()`, `filterKnownTabs()`, `createProject()`, and
|
|
109
|
+
`upload()` titles.
|
|
110
|
+
|
|
111
|
+
Both sides are required: decoding only reads would make `renameTab()` compare a decoded
|
|
112
|
+
`H&H` against a stored `H&H` and fail with *"No such tab."*; decoding only writes would
|
|
113
|
+
leave already-bad stored data permanently double-escaped on screen.
|
|
114
|
+
|
|
115
|
+
**Self-healing:** every tab write rewrites `tabs.json` and each affected `project.json` in
|
|
116
|
+
full, so pre-encoded stored values are re-persisted as plain text on the next
|
|
117
|
+
rename/delete/reorder. No manual repair of the `forward` repo is needed.
|
|
118
|
+
|
|
95
119
|
### Actions & UX
|
|
96
120
|
|
|
97
121
|
- **Publish a version** — uploads a self-contained HTML export as the next `vN/index.html`,
|
|
@@ -147,10 +171,27 @@ None — internal/shared design-team tool.
|
|
|
147
171
|
`curl_close()` calls. Audit ported code for other deprecated-in-8.x calls.
|
|
148
172
|
- **Contents API inlines only ≤1 MB** — read exports with the raw media type (see above).
|
|
149
173
|
- **Publishing pushes to `forward`'s `_main`** and triggers EB auto-deploy; no staging.
|
|
174
|
+
- **Double-escaped names (`H&H` shown literally, rename fails with "No such tab.")** —
|
|
175
|
+
caused by values that arrived **already HTML-encoded** and were then correctly escaped once
|
|
176
|
+
more at display. The render path was never wrong; no current code path produces the
|
|
177
|
+
encoding (`createTab`/`createProject` and the JS transport `window.prompt` →
|
|
178
|
+
`URLSearchParams` → `$postField` trim/`mb_substr` are clean), so encoded values entered from
|
|
179
|
+
outside — a paste from rendered HTML, or a pre-fix build. Fixed defensively with
|
|
180
|
+
`decodeEntities()` on both the read and request sides (see above). Generated project stubs
|
|
181
|
+
were affected identically, because `buildStub()`'s single correct `htmlspecialchars()` was a
|
|
182
|
+
*second* escape on a pre-encoded title; storing titles plain fixes the stubs too.
|
|
150
183
|
- Never hardcode the token in tracked source — a leaked `ghp_`/PAT must be rotated.
|
|
151
184
|
|
|
152
185
|
## Change history
|
|
153
186
|
|
|
187
|
+
- 2026-07-30 — Fixed HTML-entity double-escaping of tab names and project titles (a tab named
|
|
188
|
+
`H&H` rendered as `H&H`, and renaming it failed with "No such tab."). Added
|
|
189
|
+
`App_Design_Github::decodeEntities()` and applied it on every read out of storage
|
|
190
|
+
(`loadTabs`, `normalizeTabs` incl. legacy scalar `tab`, project `title`) and on every
|
|
191
|
+
name/title read off a request (`createTab`, `renameTab`, `deleteTab`, `reorderTabs`,
|
|
192
|
+
`filterKnownTabs`, `createProject`, `upload`), so all comparisons are decoded-vs-decoded and
|
|
193
|
+
storage re-persists plain text. Defensive — no current code path produced the encoding.
|
|
194
|
+
Verified by the developer; not yet committed to `tools`. (jcardinal)
|
|
154
195
|
- 2026-07-24 — Moved the Design Demo Admin from `forward` into the SSO-protected `tools` app
|
|
155
196
|
(`App_Design_Github` + `/design` MVC route + namespaced assets + nav entry); still commits
|
|
156
197
|
to `agilantsolutions/forward` `_main` and demos stay hosted at `demo.togatech.com`. Added
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Front-End Standards
|
|
3
|
+
framework: "1.0"
|
|
4
|
+
project: Library
|
|
5
|
+
client: shared
|
|
6
|
+
type: standard
|
|
7
|
+
status: active
|
|
8
|
+
updated: 2026-07-30
|
|
9
|
+
owners: [jcardinal]
|
|
10
|
+
files: []
|
|
11
|
+
related:
|
|
12
|
+
- ./backend-php.md
|
|
13
|
+
- ./framework-rules.md
|
|
14
|
+
- ../apps/tools/features/design-demo-admin.md
|
|
15
|
+
- ../apps/tools/features/talos-kb-documents-admin.md
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# Front-End Standards (1.0 Legacy — `Browser_` / server-rendered UI)
|
|
19
|
+
|
|
20
|
+
> **Scope.** Server-rendered UI and browser assets for the legacy **1.0** framework
|
|
21
|
+
> (`Browser_` classes, `mvc/` get/post handlers, `assets/js`, `assets/css`). 1.0 is in
|
|
22
|
+
> maintenance mode and historically inconsistent — match the file you are editing, and
|
|
23
|
+
> prefer the cleaner pattern for new code. Do not retrofit 2.0 front-end conventions.
|
|
24
|
+
|
|
25
|
+
## Output escaping: storage holds plain text, escape exactly once at display
|
|
26
|
+
|
|
27
|
+
**Rule.** Persisted values — database columns, JSON manifests, config — hold **plain,
|
|
28
|
+
unescaped text**. HTML escaping happens **exactly once**, at the moment the value is
|
|
29
|
+
written into an HTML context (`htmlspecialchars($v, ENT_QUOTES, 'UTF-8')` server-side, or
|
|
30
|
+
the view's `esc()` helper client-side).
|
|
31
|
+
|
|
32
|
+
**Never** escape on the way *in* to storage. An escaped value in storage is a latent bug:
|
|
33
|
+
the display layer escapes it a second time, and the user sees `H&H` instead of `H&H`.
|
|
34
|
+
|
|
35
|
+
### Why it recurs
|
|
36
|
+
|
|
37
|
+
The failure mode is a **round trip**, not a bad escape function:
|
|
38
|
+
|
|
39
|
+
1. A value is rendered into an edit form / prompt already escaped for HTML.
|
|
40
|
+
2. The user submits that form unchanged.
|
|
41
|
+
3. The escaped string is stored verbatim — storage now holds `H&H`.
|
|
42
|
+
4. Display escapes correctly once more → `H&H` on screen.
|
|
43
|
+
|
|
44
|
+
Each round trip adds a level. Equality comparisons then fail too, because a freshly typed
|
|
45
|
+
`H&H` no longer matches the stored `H&H` — which surfaces as a confusing
|
|
46
|
+
"record not found" rather than as a display bug.
|
|
47
|
+
|
|
48
|
+
### Required practice
|
|
49
|
+
|
|
50
|
+
- Pre-fill form inputs and JS prompts with the **plain** value; let the templating layer
|
|
51
|
+
do the one escape it owns.
|
|
52
|
+
- When a store may already contain encoded data, **decode on both sides** of the boundary:
|
|
53
|
+
a single `html_entity_decode($v, ENT_QUOTES, 'UTF-8')` on every read out of storage
|
|
54
|
+
**and** on every value read off a request. Decoding only one side leaves either broken
|
|
55
|
+
comparisons or undisplayable legacy rows.
|
|
56
|
+
- Prefer stores that **rewrite records in full** on update, so the decode-on-write is
|
|
57
|
+
self-healing and no manual data repair is needed.
|
|
58
|
+
- Escape for the **right context**: `htmlspecialchars` for HTML text/attributes,
|
|
59
|
+
`json_encode` (with `JSON_HEX_*` for inline `<script>`) for JavaScript. Never
|
|
60
|
+
hand-build JSON or rely on `htmlspecialchars` inside a JS context.
|
|
61
|
+
|
|
62
|
+
### Precedents in the codebase
|
|
63
|
+
|
|
64
|
+
- `tools/mvc/talos/vocabulary/post.php:74-80` — inline comment recording the rule after
|
|
65
|
+
form inputs pre-filled with `htmlspecialchars()` output accumulated encoding.
|
|
66
|
+
- `tools/_/app/design/github.php` — `App_Design_Github::decodeEntities()` applied to tab
|
|
67
|
+
names and project titles on both the storage-read and request-read sides.
|
|
68
|
+
|
|
69
|
+
## Change history
|
|
70
|
+
|
|
71
|
+
- 2026-07-30 — Created. Establishes the escape-once/plain-text-storage invariant after a
|
|
72
|
+
second independent occurrence in `tools` (Design Demo Admin, following the Talos
|
|
73
|
+
vocabulary tool). (jcardinal)
|
|
74
|
+
</content>
|
|
75
|
+
</invoke>
|
|
@@ -194,6 +194,23 @@ All non-production environments are **all-in-one**: a single cluster per environ
|
|
|
194
194
|
holds `Core`, `Client_<Tenant>`, `Archive_<Tenant>`, and `Logs_<Tenant>` together
|
|
195
195
|
(no family split). Region/reader routing still applies per cluster.
|
|
196
196
|
|
|
197
|
+
> **⚠ Consequence — never write a query that spans two database families.** Because
|
|
198
|
+
> production splits the families across the four dedicated clusters above, a single SQL
|
|
199
|
+
> statement that joins or sub-selects across them (e.g. `Client_Towfoundation` ↔ `Core`,
|
|
200
|
+
> or `Client_<Tenant>` ↔ `Archive_<Tenant>`) **cannot resolve in production** — the foreign
|
|
201
|
+
> schema is not on that server. And because **every non-production environment is all-in-one,
|
|
202
|
+
> such a query runs perfectly in local/dev/QA/stage**, so the failure surfaces only after
|
|
203
|
+
> release. Treat a successful non-prod run as **no evidence** that a query is cluster-safe.
|
|
204
|
+
> Cross-family data must be assembled in **PHP across two connections** (`_Database` named
|
|
205
|
+
> connections, one per family), never in one statement. Note the corollary that platform
|
|
206
|
+
> databases `Core`, `Forecast`, and `Team` **do** share `prod-core`, so those are same-cluster
|
|
207
|
+
> — but a cross-*database* query is still discouraged, and in `dbchanges2` it is forbidden
|
|
208
|
+
> outright.
|
|
209
|
+
>
|
|
210
|
+
> For **`dbchanges2` migrations this is a hard, mechanically-enforced rule**: a `.sql` file may
|
|
211
|
+
> only reference tables in the one database its folder targets. See
|
|
212
|
+
> [dbchanges2 → Database isolation](../dbchanges2/architecture.md).
|
|
213
|
+
|
|
197
214
|
| Environment (aliases) | Host |
|
|
198
215
|
|---|---|
|
|
199
216
|
| `dev-sandbox` / `sandbox-dev` / developer sandbox | `dev.sandbox.database.togahub.com` |
|
|
@@ -6,13 +6,14 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-07-
|
|
10
|
-
owners: ["bala"]
|
|
9
|
+
updated: 2026-07-29
|
|
10
|
+
owners: ["bala", tcox]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Email.php
|
|
13
13
|
- worker2/Worker/Infrastructure/Email/Send.php
|
|
14
14
|
related:
|
|
15
15
|
- email-template-sending.md
|
|
16
|
+
- ../../ai-bdr/features/web-funnel-app.md
|
|
16
17
|
---
|
|
17
18
|
|
|
18
19
|
## Summary
|
|
@@ -60,8 +61,20 @@ SMTP** using PHPMailer, then flips each row to `SENT` or `FAILED`. So a 2.0 emai
|
|
|
60
61
|
copy the values. Remediation: treat as compromised, rotate in AWS SES, and move them into
|
|
61
62
|
`Config/[environment].ini` (read via `_Config`). Also tracked on
|
|
62
63
|
[`email-template-sending.md`](email-template-sending.md).
|
|
64
|
+
- **Rotation now has a NON-PHP consumer (2026-07-29).** The BDR web funnel (Node/Next, repo
|
|
65
|
+
`bdr`) sends its call-summary email with **nodemailer against the same SES SMTP settings**,
|
|
66
|
+
read from server-only env vars (`TOGA_SMTP_HOST/PORT/USER/PASS`, `TOGA_EMAIL_FROM`) in
|
|
67
|
+
`.env.local` and the Amplify console. Rotating the constants in `Email.php` **must** therefore
|
|
68
|
+
be paired with updating BDR's env, or BDR's Send button can only reach its *failed* state (it
|
|
69
|
+
has no mail-app fallback). Settings BDR mirrors: AWS SES **us-west-2**, port **587**, TLS;
|
|
70
|
+
`DevTeam@goagilant.com` is a known-good verified sender identity in worker2 config. See
|
|
71
|
+
[ai-bdr web-funnel-app.md](../../ai-bdr/features/web-funnel-app.md).
|
|
63
72
|
|
|
64
73
|
## Change history
|
|
74
|
+
- 2026-07-29 — Noted that the SES SMTP credentials/settings now have a **non-PHP consumer**: the
|
|
75
|
+
BDR funnel sends via nodemailer against the same SES host (us-west-2, port 587, TLS) using
|
|
76
|
+
`TOGA_SMTP_*` env vars, so a rotation must be coordinated with BDR's `.env.local` + Amplify
|
|
77
|
+
vars or BDR's summary email goes dark. (tcox)
|
|
65
78
|
- 2026-07-28 — Created: documented that `_Email::send()` **queues** (writes a `PENDING`
|
|
66
79
|
`Logs_[Client].Email` row + attachment BLOBs) rather than transmitting, and that the worker2
|
|
67
80
|
`Infrastructure/Email/Send` cron (`Core.CronJobs` `* * * * *`) does the actual SES SMTP send
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
| [AI-BDR Architecture](architecture.md) | **AI-BDR** is TOGA's outbound AI sales-development representative. | ai-bdr/README.md, ai-bdr/requirements.txt, ai-bdr/.env.example, 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/vapi/templates/system-prompt.template.md, ai-bdr/vapi/templates/assistant.template.json, 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/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 |
|
|
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
|
+
| [Live Call Status — Vapi call API → funnel Success screen lifecycle](features/live-call-status.md) | The BDR funnel's Success screen shows the **real** state of the call it just requested — dialing / ringing / answered-and-talking / ended / not-answered — inste | bdr/src/server/vapiCall.ts, bdr/src/server/callStatus.ts, bdr/src/server/statusToken.ts, bdr/src/server/callbackService.ts, bdr/src/server/workerDialer.ts, bdr/src/lib/callStatus.ts, bdr/src/app/api/call-status/route.ts, bdr/src/app/api/call-now/route.ts, bdr/src/flow/useCallStatusViewModel.ts, bdr/src/flow/callStatusApi.ts, bdr/src/flow/screens/Success.tsx, bdr/src/content/schema.ts, bdr/src/content/default.ts, bdr/src/proxy.ts |
|
|
8
9
|
| [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/src/server/hubspot.ts, bdr/src/server/leadSink.ts, bdr/src/server/callbackService.ts, bdr/src/server/workerDialer.ts, bdr/src/lib/
|
|
10
|
+
| [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/server/mailer.ts, bdr/src/server/statusToken.ts, bdr/src/server/vapiCall.ts, bdr/src/server/callStatus.ts, bdr/src/lib/summaryText.ts, bdr/src/lib/emailFormat.ts, bdr/src/lib/callStatus.ts, bdr/src/app/api/share-summary/route.ts, bdr/src/app/api/call-status/route.ts, bdr/src/flow/shareSummaryApi.ts, bdr/src/flow/callStatusApi.ts, bdr/src/flow/useCallStatusViewModel.ts, bdr/src/flow/screens/Success.tsx, 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/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
11
|
| [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
12
|
| [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
13
|
| [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 |
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Live Call Status — Vapi call API → funnel Success screen lifecycle
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: ai-bdr
|
|
5
|
+
project: AI-BDR
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-07-29
|
|
10
|
+
owners: [tcox]
|
|
11
|
+
files:
|
|
12
|
+
- bdr/src/server/vapiCall.ts
|
|
13
|
+
- bdr/src/server/callStatus.ts
|
|
14
|
+
- bdr/src/server/statusToken.ts
|
|
15
|
+
- bdr/src/server/callbackService.ts
|
|
16
|
+
- bdr/src/server/workerDialer.ts
|
|
17
|
+
- bdr/src/lib/callStatus.ts
|
|
18
|
+
- bdr/src/app/api/call-status/route.ts
|
|
19
|
+
- bdr/src/app/api/call-now/route.ts
|
|
20
|
+
- bdr/src/flow/useCallStatusViewModel.ts
|
|
21
|
+
- bdr/src/flow/callStatusApi.ts
|
|
22
|
+
- bdr/src/flow/screens/Success.tsx
|
|
23
|
+
- bdr/src/content/schema.ts
|
|
24
|
+
- bdr/src/content/default.ts
|
|
25
|
+
- bdr/src/proxy.ts
|
|
26
|
+
related:
|
|
27
|
+
- web-funnel-app.md
|
|
28
|
+
- vapi-integration.md
|
|
29
|
+
- call-orchestration.md
|
|
30
|
+
- ../../worker2/features/vapi-webhook-handler.md
|
|
31
|
+
- ../../api2/features/v2-rest-query-contract.md
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Summary
|
|
35
|
+
|
|
36
|
+
The BDR funnel's Success screen shows the **real** state of the call it just requested —
|
|
37
|
+
dialing / ringing / answered-and-talking / ended / not-answered — instead of the old ~5s
|
|
38
|
+
scripted "Connecting" theater. The status source is **Vapi's own call API**
|
|
39
|
+
(`GET https://api.vapi.ai/call/{id}`), **not** Toga `ContactAttempts`, because the BDR API
|
|
40
|
+
client is still hard-blocked (`EZ-1`) on `ContactAttempts` — and because Vapi is strictly
|
|
41
|
+
better data even after that ACL grant lands (see "Why Vapi, not ContactAttempts").
|
|
42
|
+
|
|
43
|
+
## How it works
|
|
44
|
+
|
|
45
|
+
1. **Call Now** writes the contact (`src/server/callbackService.ts`), then fast-dials the
|
|
46
|
+
worker action `Ai/Bdr/Vapi/InitiateOutboundCall` (`src/server/workerDialer.ts`). The
|
|
47
|
+
worker's reply carries the **Vapi call id** — see the response-prefix gotcha below and
|
|
48
|
+
[worker2 vapi-webhook-handler.md](../../worker2/features/vapi-webhook-handler.md) for the
|
|
49
|
+
full return shape.
|
|
50
|
+
2. `/api/call-now` mints a **purpose-scoped, signed status token** (`src/server/statusToken.ts`)
|
|
51
|
+
whose claims carry the Vapi call id. The call id itself **never crosses to the client and is
|
|
52
|
+
never accepted as a client parameter** (see Security).
|
|
53
|
+
3. The Success screen polls `/api/call-status` (`src/flow/callStatusApi.ts` →
|
|
54
|
+
`src/flow/useCallStatusViewModel.ts`), passing the token in the **`x-status-token` header**.
|
|
55
|
+
4. The route reads Vapi server-side (`src/server/vapiCall.ts`), normalizes to the funnel's own
|
|
56
|
+
state machine (`src/lib/callStatus.ts`), and returns it with `Cache-Control: no-store, private`.
|
|
57
|
+
|
|
58
|
+
### State mapping (`src/lib/callStatus.ts`)
|
|
59
|
+
|
|
60
|
+
| Vapi `status` | Condition | Funnel state | Talk timer |
|
|
61
|
+
|---|---|---|---|
|
|
62
|
+
| `queued`, `ringing` | — (or `startedAt` absent) | **dialing** | none |
|
|
63
|
+
| `in-progress`, `forwarding` | `startedAt` present | **active** | counts from the **answer** time, corrected by a server-clock offset |
|
|
64
|
+
| `ended` | `startedAt` present | **ended** | frozen at the true talk duration (`endedAt - startedAt`) |
|
|
65
|
+
| `ended` | no `startedAt`, **or** `endedReason` matches voicemail / no-answer / did-not-answer / busy | **not answered** | none; the recap card is **suppressed** |
|
|
66
|
+
|
|
67
|
+
The "not answered" state exists specifically so the screen never shows an authored sample
|
|
68
|
+
conversation to someone whose call nobody took.
|
|
69
|
+
|
|
70
|
+
- **Post-hangup grace polling:** after `ended`, the client keeps polling every **2s for a
|
|
71
|
+
bounded 30s** — purely because Vapi writes `summary` asynchronously (below).
|
|
72
|
+
- **Simulation is the graceful fallback**, not the default: it runs whenever status is
|
|
73
|
+
unavailable (no handle, anonymous visit, missing `TOGA_VAPI_TOKEN`, upstream failure).
|
|
74
|
+
- **Scheduled-mode submits never poll** — there is nothing live to watch.
|
|
75
|
+
|
|
76
|
+
## The Vapi call read (`GET https://api.vapi.ai/call/{id}`)
|
|
77
|
+
|
|
78
|
+
Authenticated with the **same Vapi bearer credential worker2 dials with** (BDR reads it from
|
|
79
|
+
the server-only env var `TOGA_VAPI_TOKEN`; see web-funnel-app.md for where the value lives).
|
|
80
|
+
Verified live response fields: `id`, `status`, `type`, `createdAt`, `startedAt`, `endedAt`,
|
|
81
|
+
`endedReason`, `summary`, `transcript`, `recordingUrl`, `customer`, `analysis`, `messages`,
|
|
82
|
+
`cost`, `twilioCallSid`.
|
|
83
|
+
|
|
84
|
+
**Status enum:** `queued` → `ringing` → `in-progress` (`forwarding`) → `ended`.
|
|
85
|
+
|
|
86
|
+
**Timestamp semantics — the load-bearing insight:**
|
|
87
|
+
|
|
88
|
+
- `createdAt` = the call was **placed**.
|
|
89
|
+
- `startedAt` = the call was **ANSWERED**.
|
|
90
|
+
- `endedAt` = hangup.
|
|
91
|
+
- `createdAt → startedAt` is therefore **ring time** (observed 10s on a real call). Any talk
|
|
92
|
+
timer must count from `startedAt`, never `createdAt`.
|
|
93
|
+
|
|
94
|
+
**Timestamps are ISO-8601 UTC with a `Z`** — parse them directly. Do **NOT** apply the
|
|
95
|
+
Central-time conversion that `ContactAttempts` datetimes need (api2 serializes with a Central
|
|
96
|
+
offset because it runs `America/Chicago`; Vapi does not).
|
|
97
|
+
|
|
98
|
+
## Why Vapi, not ContactAttempts
|
|
99
|
+
|
|
100
|
+
- **ContactAttempts is hard-blocked for the BDR API client (still open as of 2026-07-29).**
|
|
101
|
+
Re-verified live against `api.togahub.com`: `EZ-1` on **every** variant — nested
|
|
102
|
+
`/contacts/{uuid}/contact-attempts`, flat `/contact-attempts`, with and without an explicit
|
|
103
|
+
`fields=` list. It is a **record-level** block (`data.contactAttempts: null`), not
|
|
104
|
+
field-level. Separately `/contacts/{uuid}?fields=id` returns `EZ-2` denying `Contacts.id`.
|
|
105
|
+
The read-only ACL grant asked for on 2026-07-21 is **still pending**. This supersedes the
|
|
106
|
+
older "not tried yet" note.
|
|
107
|
+
- **Even after the grant lands, Vapi wins for live UI.** `ContactAttempts.dtStarted` is the
|
|
108
|
+
**dial** time — it cannot distinguish "ringing" from "talking," so it can never drive an
|
|
109
|
+
honest talk timer or an answered/not-answered distinction. Vapi's `startedAt` can.
|
|
110
|
+
- ContactAttempts remains the right source for **durable, post-call** reporting (it is what the
|
|
111
|
+
end-of-call webhook writes back to).
|
|
112
|
+
|
|
113
|
+
## Security
|
|
114
|
+
|
|
115
|
+
The full security model for these public unauthenticated endpoints is documented in
|
|
116
|
+
[web-funnel-app.md](web-funnel-app.md#public-unauthenticated-endpoint-security-model). The two
|
|
117
|
+
rules that are specific to this feature:
|
|
118
|
+
|
|
119
|
+
- **The Vapi call id travels only inside signed token claims.** It is never accepted as a
|
|
120
|
+
client parameter — otherwise anyone could read **any** call in the org's Vapi account,
|
|
121
|
+
including transcripts and recordings.
|
|
122
|
+
- **Tokens are purpose-scoped** (`status` vs `share`) so a status-poll capability is not
|
|
123
|
+
silently also a send-an-email capability.
|
|
124
|
+
|
|
125
|
+
## Gotchas / known issues
|
|
126
|
+
|
|
127
|
+
- **`summary` is written ASYNCHRONOUSLY, a few seconds AFTER hangup.** It is commonly `null`
|
|
128
|
+
on the first read that shows `status: "ended"`. `endedReason`, by contrast, is set
|
|
129
|
+
immediately. This is the entire reason for the bounded 30s post-hangup grace polling, and
|
|
130
|
+
why the share-summary send **re-reads Vapi at send time** rather than trusting what the
|
|
131
|
+
screen had.
|
|
132
|
+
- **Never `JSON.parse` a worker dispatcher reply directly.** The worker wraps **every** reply
|
|
133
|
+
as `[<requestId>] Successfully Executed (<n> seconds): <json>`, so `response.json()` always
|
|
134
|
+
throws. BDR previously logged "immediate dial not placed" on every **successful** dial and
|
|
135
|
+
silently discarded the response body (which is where the Vapi call id lives). Strip the
|
|
136
|
+
prefix with a regex before parsing — see
|
|
137
|
+
[creating-worker-actions.md](../../worker2/features/creating-worker-actions.md).
|
|
138
|
+
- **`vapiCallId` may be the literal string `'unknown'`.** Treat it as "no handle" and fall
|
|
139
|
+
back to the simulation rather than polling for a call that cannot be read.
|
|
140
|
+
- **Do not convert Vapi timestamps to Central.** They are already UTC `Z`. The Central
|
|
141
|
+
conversion belongs only on ContactAttempts/api2 datetimes.
|
|
142
|
+
- **Without `TOGA_VAPI_TOKEN` the whole feature silently degrades to the simulation.** That is
|
|
143
|
+
deliberate (the funnel must never fail on a missing credential) — but it means "the timer
|
|
144
|
+
looks fake in this environment" is an **env** symptom, not a code bug.
|
|
145
|
+
|
|
146
|
+
## Change history
|
|
147
|
+
- 2026-07-29 — Created. BUILT the live call-status pipeline: `src/server/vapiCall.ts` reads
|
|
148
|
+
`GET https://api.vapi.ai/call/{id}` with the worker's Vapi bearer credential and drives a real
|
|
149
|
+
dialing / active / ended / not-answered lifecycle on the Success screen (replacing the ~5s
|
|
150
|
+
scripted simulation, which is now only the fallback). DECIDED Vapi is the live-status source
|
|
151
|
+
instead of Toga `ContactAttempts` — re-verified 2026-07-29 that ContactAttempts is still
|
|
152
|
+
`EZ-1` record-level blocked on every route variant (grant still pending), **and** that
|
|
153
|
+
`dtStarted` is dial time so Vapi's `startedAt` (= ANSWER time) is strictly better for live UI
|
|
154
|
+
regardless. Recorded the timestamp semantics (`createdAt` placed → `startedAt` answered →
|
|
155
|
+
`endedAt` hangup; the gap is ring time, 10s observed), the UTC-`Z` no-conversion rule, and the
|
|
156
|
+
asynchronous `summary` write that forces the bounded 30s post-hangup grace poll. (tcox)
|