A four-message exchange about a premium took seven entries in the trail —
"WhatsApp", "WhatsApp", "WhatsApp" — each with its own heading, actor, quote
box and Open thread link, so the conversation occupied more of the story than
the entire underwriting chain. The turns were being grouped per speaker, which
collapses nothing when the customer and Engage are alternating.
An exchange is now one entry: who spoke, how many messages, the last two of
them, and the whole thread behind a click on the card. It carries no single
actor — that was the trap the per-speaker rule was there to avoid, because
collapsing turns while keeping the first speaker's name printed the customer's
words under Engage's chip. Here both sides are named on their own message and
neither owns the entry.
ONE ENTRY PER BURST, not one for the whole thread. The quote objection and the
payment exchange are the same conversation resumed, but they are twenty minutes
and four workflow steps apart; folding them together would date the payment
messages to the moment of the objection and put them above the underwriting
that actually preceded them. Saying what happened in what order is the trail's
one job. So each burst sits where it happened, the later ones marked
"continued" and saying what ran in between, and every one of them opens the
same complete thread.
The thread itself is now built once, in api/thread.js, and shared:
- It picks up the two sends that are NOT chat activities. Request Premium
and the issuance step compose their message inside a trigger and write it
to customer_answer beside their own work (98/99/100), so those messages
belonged to the conversation and appeared nowhere in the trail. The step
now says "Sent 1 message on WhatsApp" and links into the thread rather
than quoting text the exchange below it already shows.
- The dialog groups the bursts with a divider that says how long the thread
was quiet and which steps ran meanwhile. Read flat, a reply about the
premium appeared to answer the payment link that followed it.
- Every bubble is named, both directions. It stamped only the time, so a
thread read back months later could not say which of our replies a person
wrote and which Engage did.
- Opened from an exchange it opens AT that exchange, marked briefly; opened
from the header it opens at the newest message, which is where a chat is
read from.
- The count on the lead header comes off the thread. Scanning the audit rows
counted the platform's own repeats — one submission is recorded up to three
times — so the badge claimed nine messages over a six-message conversation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|---|---|---|
| public/brand | ||
| src | ||
| .env.example | ||
| .gitignore | ||
| CLAUDE.md | ||
| eslint.config.js | ||
| index.html | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| vite.config.js | ||
Zurich Kotak — Lead Desk
Operator console for the Zurich Kotak "Lead to Policy" demo. Leads arrive through three channels — direct, bancassurance and agency — and run one state machine to policy issuance. The agents do the work; a person appears at one queue.
Workflow config is seeded from sm2/custom-apps/zurich-kotak/ (org 84,
app 536, workflow zk_wf_lead). Design doc:
sm2/app-designs/zurich-kotak/design.html. Workflow spec PDF:
sm2/custom-apps/zurich-kotak/zurich-kotak-workflow.pdf.
Run it
npm install
npm run dev # http://localhost:5175
npm run build # → dist/
.env carries the API host for local dev only:
VITE_ZINO_API_URL=https://dev.getzino.in
Logins (all password ZurichKotak@2026, org 84):
| Role | Can do | |
|---|---|---|
sanjay.uw@zurichkotak.example |
SME Underwriter | Clear / Decline Referral — the only human decision |
meera.rm@zurichkotak.example |
Bancassurance RM | Submit a Partner Bank Lead |
arjun.posp@zurichkotak.example |
POSP / Broker | Submit a Partner Agent Lead |
kavya.csr@zurichkotak.example |
Direct / Call centre | Submit a Self-Serve Lead |
deepa.ops@zurichkotak.example |
Operations | Cross-channel visibility |
Sign in as Sanjay to land in Referred to Underwriting, which is where the demo happens.
How it talks to the platform
Everything goes through src/api/client.js:
POST /usr/login— auth.org_idmust be a string; a number returnscannot unmarshal number into Go struct field LoginRequest.org_id.POST /app/536/view/recordview— every queue is one record view (zk-rv-leads) filtered server-side oncurrent_state_name.POST /app/536/view/form-screensthenPOST /app/536/activity— forms are read from the live activity schema and submitted straight back.
Forms are not defined in this repo
Whatever /view/form-screens returns is what renders — labels, types, select
options, which fields are mandatory. Add a field to an activity in Studio,
redeploy, and it appears here with no frontend change. That is deliberate: the
workflow is the source of truth, and a hardcoded form would quietly diverge
from it.
Permissions are never enforced here
The console does not hide a button to stop someone using it. Every permission
decision is the workflow's, made server-side on each submission — the platform
refuses and this app reports what it said. Two refusals worth knowing:
/activity answers 403, /start answers 400, and both carry
permission denied.
The runtime-config contract
VITE_ZINO_API_URL is read at runtime from a config.js the server writes
when it places the build — never compiled in. One artifact is promoted between
environments unchanged, so a build-time URL would point every environment at
whichever backend happened to build it. requireConfigValue throws if it is
missing, so a production build with no config.js fails loudly rather than
calling the wrong backend.
vite.config.js uses base: './' and the router takes its basename from the
<base href> the server writes, so one build serves any mount path. Do not
reintroduce a build-time base.
This is also why the fonts and logo live under src/assets/ and not
public/. Vite rewrites bundled asset URLs to be relative; a public/ file
referenced as /fonts/… stays absolute and 404s under the /zurich-kotak/
mount path. The favicon is the one exception — it stays in public/brand/ and
is referenced relatively from index.html.
Deployment
Registered with frontgen by sm2/custom-apps/zurich-kotak/05_frontgen_project.sql
(slug zurich-kotak, repo_name zurich_kotak, org 84). A push to main builds
and serves at https://preview-dev.getzino.in/zurich-kotak/.
The webhook only fires on a new push — a push made before the frontgen project row existed matched no project and did nothing.
State of the build
| Piece | Status |
|---|---|
| Brand — Zurich Sans, palette, logo | done (kept from the original scaffold) |
| Sign-in against the real gateway | done |
| Runtime config, relative base, router | done |
Pipeline sidebar (mirrors tbl_wf_states) |
done |
| Queue lists | waiting on the zk-rv-leads record view |
| Lead file, activity forms, attribution panel | waiting on zk-dv-lead + form screens |