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.
@@ -6,7 +6,7 @@ project: Tools
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-24
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-28
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/shareMailto.ts, bdr/src/flow/screens/CallNow.tsx, bdr/src/flow/screens/Schedule.tsx, bdr/src/flow/screens/CallSummary.tsx, bdr/src/content/schema.ts, bdr/src/content/default.ts, bdr/test/togaEnrichment.test.ts, bdr/test/fastDial.test.ts, bdr/test/shareMailto.test.ts, bdr/eslint.config.mjs, bdr/amplify.yml, bdr/src/app, bdr/src/app/layout.tsx, bdr/src/app/page.tsx, bdr/src/app/studio.css, bdr/src/lib/resolveCampaignId.ts, bdr/src/lib/scheduleSlots.ts, bdr/src/lib/formatPhone.ts, bdr/src/lib/analytics.ts, bdr/src/lib/rateLimit.ts, bdr/src/lib/clientIp.ts, bdr/src/proxy.ts, bdr/.env.example, bdr/src/components/GoogleAnalytics.tsx, bdr/src/components/RichText.tsx, bdr/src/content, bdr/src/components, bdr/src/flow, bdr/src/server, bdr/src/server/toga.ts, bdr/src/app/api, bdr/test, bdr/test/togaPrimaryPhone.test.ts, bdr/public, bdr/mockup/styles.css |
10
+ | [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)