"Priya Raghavan and Engage" reads as two colleagues. Every other entry in the
trail already answers this — the agent's disc carries a sparkle, its name
carries an AI tag, a person's disc carries their initials — and the exchange
entry was the one place printing bare names, on the screen whose whole claim is
that the work was done autonomously.
So the participants are chips rather than a sentence, and so is the label on
every message, in the preview and in the thread: Priya Raghavan wears her
initials, Engage wears the sparkle and the AI tag, and ops replying by hand
wears their initials too — a person answering in the thread must never be
dressed as the machine. The agent chip opens its card, as it does everywhere
else in the trail; the label inside the preview card stays a span, because the
card is already a button.
The disc's tint and the bubble's tint are the same token, so a chip dropped
into a bubble lost its disc; it is white inside one. `.bub span` narrowed to
direct children other than the attribution row, so the chip's own spans are
not restyled and CallTranscript's plain labels still work.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
Implements the design in zurich-kotak-leaddesk across every screen.
Foundation: Source Sans 3 bundled from src/assets/fonts (no Google Fonts
request), 15px root, navy/blue/amber/teal/red palette where blue is the AI,
amber a person, teal the customer and red is reserved for risk; flat white
10px cards on a grey canvas, no shadows.
Shell: 56px navy masthead (Z wordmark, Lead Desk, user), 232px white nav
grouped Needs you / Scheduled / History with coloured count pills and the
closed stages folded.
Overview: the hero says who holds each open lead (stacked bar + legend)
beside the renewals-at-risk card; five KPIs incl. Closed to date; Waiting
on a person queue with owner dots and "N more with the AI"; Pipeline by
stage with owner-coloured bars; Renewal exposure with tabs, dropdown
filters and a Holding it column.
Lead: back button, id line, holder pill; one attention banner (amber your
turn / blocked, red stalled, blue with the AI, teal with the customer) that
carries its button; four facts with a confidence bar; navy steps rail
whose current step takes its holder's colour; timeline as an avatar
gutter with title, stage pill, by-line + AI tag and text-link actions
(See reasoning · N steps, Hear the call, Open thread); side column flows.
Lists: card with search + hide-closed toggle, columns Customer / Stage /
Renewal due / Holding it / Premium / Last activity, stalled rows get a red
inset, closed rows dim, person-gated queues split into Your turn / With the
AI group rows.
src/api/holder.js is the single rule for holder tone + label so the table
cells, the overview and the lead header cannot disagree.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A contiguous run of WhatsApp turns collapsed into one timeline entry that kept
the FIRST turn's speaker and previewed the LAST turn's message. When a run held
the customer's question and Engage's answer, the entry carried Engage's chip
over a quote the customer had typed — the trail claimed the machine said
something a person did, which on an accountability demo is the worst thing it
could get wrong.
Runs now group per speaker. That still collapses the case grouping exists for
(the same sentence resent several times) and can no longer misattribute,
because every row in a run is one voice. Customer runs read "the customer /
THE CUSTOMER SAID"; Engage runs read as Engage / WE REPLIED.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The chain of thought reads the employee's "why" out of the workflow data
fields, matched by base id. Three real reasoning fields were written by the
employees but not in the map, so those steps showed as a bare heading even
though the reasoning was captured:
- uw_referral_reason — the underwriting screen and prepare steps write their
decision reasoning here; the map only knew uw_decision_notes (the
clear/decline note), so the substantive UW decision showed no chain.
- kyc_ref — a clean KYC leaves the mismatch note empty, so the step had no
rung; kyc_ref is the document package the employee matched identity against
and now stands in as the finding.
- nudge_notes — the payment-follow-up employee's rationale.
Traced against instance 57632: underwriting (2.3k-char reasoning), KYC and the
nudge all had their reasoning captured but unmapped. This is frontend-only, no
backend or workflow change.
Known residual: Accept, Payment, Realise and Onboard write only a reference,
no "why" field, so their reasoning lives in aiemployee.tbl_ai_decisions /
the ai_reasoning column, which the audit endpoint does not return and the
org-scoped monitor endpoint a partner_agent cannot call. Forcing a chain onto
those mechanical acks needs a workflow field + charter change (future
instances only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Visual and information-architecture pass across every screen. No API call,
payload, poll, permission rule or workflow mirror changes; forms still render
from the live schema and the timeline's merge/grouping is unchanged (only the
display order is reversible).
Design language
- Ground + white cards with one hairline and one radius; sentence-case
headings; shared primitives in index.css (.card .pill .btn .avatar .live).
- One inline icon set (components/Icon.jsx); no more filled navy/red tiles.
- Read-side labels in api/display.js: roles and enum tokens humanized,
customer initials/tones, money and date formatters. Nothing sent changes.
Shell
- 56px header with brand, global lead search (portfolio rows, "/" to focus)
and identity chip with a human role label.
- Sidebar rows carry icons and a clear active bar; AI roster at the foot,
each opening its remit card.
Login
- Operator copy instead of retail copy; centred sign-in card with show/hide
password; demo persona picker that fills the EMAIL only.
Overview
- "Who holds the work" as four quiet cards that warm only when owed.
- Pipeline flow bar with every stage named beneath it; KPI strip in a card.
- Action rows with chevron / view-only eye; expiry bands filter the table.
Queues
- Filter bar (search, product, channel, expiry band, stalled, hide closed)
and sortable columns, all client-side on the fetched page.
- Avatars, product/channel chips, stage chips for closed leads, tinted
stalled rows, count inline with the title.
Lead
- Avatar + chips in the header; metrics card with the stage rail inside,
and a branch node for Referred / Parked / Lost / Declined.
- Timeline newest-first by default (toggle), the worker's face as the node,
one footer row per entry, AI markdown rendered as bold.
- "AI team on this lead" panel with per-agent step counts; PDF tags on docs.
Forms & dialogs
- Required marked with an asterisk + legend; "Select…" placeholder; dd Mon
yyyy hint under date inputs; open activity form in a titled panel;
one-line dialog footnotes.
Fixes: horizontal overflow at 1024px, boxed "in N days" figure, raw tokens
(sme_underwriter, verbal_call, Rsa), findings cut at "1.".
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Follows the previous commit but drops everything that would have needed a
backend change. The chain is built entirely from the audit trail the console
already fetches: the REASONED rung is the employee's own reasoning fields
(attribution_reason, ai_recommendation_rationale, and the rest — already in the
data), and the DECIDED rung is the activity it committed, the state it moved the
lead to, and its confidence (ai_recommendation_confidence, also a real field on
the step). The finding stays visible above the chain.
No dependency on any endpoint change: the tool-call rung and the tbl_ai_decisions
join are gone. What remains is real, self-contained, and ships with a push.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An agentic console has to answer one question before anyone will rely on it:
why did the AI do that. The reasoning was always recorded — the employees write
it, the platform stores the tools they called — but the trail showed only the
conclusion. An operator either trusted it blind or opened the raw record.
Each AI step now carries its chain of thought, assembled from real data, never
narrated after the fact: what it CHECKED (the tools it called, each marked for
whether it came back with anything), what it REASONED (the employee's own
words), and what it DECIDED (the activity it committed, the state it moved the
lead to, its confidence). The finding — the first sentence — stays visible; the
chain opens under it.
A tool that returned nothing is drawn hollow. That is the Manju Malik failure
made visible: a licence check that came back empty and was reasoned over as if
it were a finding is exactly what wrongly dropped a real lead, and an operator
scanning the chain should see the empty result the machine reasoned from.
The reasoning ships now — it is already in the audit data. The tool rung lights
up once view-service joins tbl_ai_decisions (next commit); until then the chain
shows two rungs and degrades cleanly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Recording is now on for Meera (93) and the transcript is kept on the lead
(94), so the call stops being a thing only one AI ever saw.
The wake that ends a call already carried far more than Engage used — a
summary, a sentiment analysis, and the FULL TRANSCRIPT, turn by turn —
and all of it was dropped once the three-sentence write-up was done. So a
customer said "I think it's 12,000 rupees" and "lower premium", and the
file held a paraphrase.
That matters here more than it would elsewhere: consent_artefact =
verbal_call is written from what the agent HEARD, and it is the artefact
justifying every later contact. The evidence for it should be the
customer's own words.
"Hear the call" now sits on the Log Contact entry and opens the
conversation, laid out like the WhatsApp thread — agent right, caller left
— because it is the same kind of thing and should not need learning
twice. It also files under Contact in the lead record.
Turns are split on the speaker labels rather than on newlines: a single
turn wraps, and splitting per line shattered one sentence into four
bubbles. A transcript that will not parse is shown raw — it is still
evidence, and "nothing to display" would be a lie.
NOT INCLUDED: the audio. Recording is enabled and the WAV is uploaded,
but recording_url is delivered to the agent's post-call WEBHOOK
(voice_internal_handler.go:429), not in the wake payload, so nothing puts
it on the lead. Wiring that webhook to an external-API activity is a
separate change, worth doing when someone asks to listen rather than to
read.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The trail rendered twelve fields as prose and four as money. The other
hundred-odd were invisible — so Capture Motor Risk, which writes engine
capacity, fuel, year, NCB, previous insurer, policy number, IDV and the
add-ons, appeared as a heading and a timestamp. The only way to see what
an agent had decided was to open the Lead file and guess which values
came from which step.
Each entry now carries a folded "N fields written" list: every field that
step committed, by label, with the values already shown above it
(narrative, money, documents) excluded so nothing repeats. Folded because
most steps write a dozen and the trail has to stay readable as a story.
WHAT THIS IS NOT, and it matters: it is not the model's internal
reasoning, and it is not its tool calls. Both ARE recorded — ai_reasoning,
ai_confidence, ai_model and ai_tool_calls sit on the same audit row — but
GetAuditLogsForApp selects eight columns and none of them are those, so
an app JWT cannot read them at all. Surfacing them needs four columns
added to that query and to ActivityLogEntry, which is a view-service
change and an image build.
This is the next best thing and arguably the more useful half: not what
the agent thought, but what it committed, itemised against the step that
committed it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TOOLS ARE VISIBLE NOW. Clicking any agent — in the trail or the "Worked
by" strip — opens a card saying what it is, when it runs, what it can
reach and what it reads. The answer is short and it is the reassuring
kind: four of the five hold no tools at all and reason from the policy
wordings, and the only tool in the roster reads a partner registry.
That answer stopped being true once, silently. Intake held a tool that
could telephone customers, its charter never mentioned it, and it used it
on every lead believing it was a duplicate check (89). Nobody could see
that from any screen. This is the screen that would have shown it — which
is the actual argument for building it, beyond a demo looking better.
Where an agent has no tools the card says so in words rather than showing
an empty section. "No tools" is the single most reassuring fact about
something that writes into an insurance file; a blank reads as missing
data.
The roster is a hand-maintained mirror of the employee config, like
STAGES and ACTIONS, and carries the same hazard: a tool added there and
not here is described wrongly, silently. Named in the file.
THE CONVERSATION WAS EATING THE TRAIL. Every WhatsApp turn was its own
entry — heading, actor, quote box — so a four-message exchange occupied
more of the trail than the entire underwriting chain, and a resend
printed the same sentence four times because the customer sent it four
times. A contiguous run now collapses into ONE entry: a WhatsApp mark, a
count, the latest line, and a link that opens the thread. A second
exchange later in the lead stays separate, because that is a different
episode in the story.
And the actor is right. The channel performs these as the system, so the
trail read "System · Customer Reply" — the opposite of what happened.
It says "the customer" now, in the channel's own green, and the rail dot
takes that colour so a scan shows where the conversation was without
reading a word.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five of the six asks. The sixth — the whole row as the click target —
shipped in 704afe1 and is already live on both grids.
THE AUDIT TRAIL WAS FROZEN. Timeline fetched the rows once on mount and
never again, so a lead being worked by five agents in three minutes
showed the trail as it was when the page opened — on the one screen whose
job is watching work happen. The rows move to the lead page, which
already polls every 12s, and Timeline becomes presentational. One fetch,
one poll, and the conversation view reads the same rows so the two cannot
disagree.
THE ACCEPTANCE BUTTON OUTLIVED THE ACCEPTANCE. Recording an acceptance
twice is not a loop, a recovery or an alternative — it is meaningless,
and offering it invites someone to overwrite a customer's WhatsApp
acceptance with a worse record of the same event. Gone once
acceptance_ref is set, replaced by a line saying who accepted and when.
THE AGENTS HAD NO FACE. Five of them carry this workflow and the console
named them three different ways — "ai_engage" here, "Engage" there,
"Engage AI" elsewhere — so nobody could see that the thing which called
the customer and the thing which wrote the quote were one worker. One
roster now (api/agents.js): a short name, a colour and two initials each,
used wherever an agent is named. People get a disc too, in grey — a trail
where the machines are decorated and the humans are plain text reads as
though the machines are the important ones, which is backwards on a
screen built for oversight. A "Worked by" strip above the trail shows the
team at a glance.
THE CONVERSATION WAS A LOG, NOT A THREAD. Each turn sat as its own entry
among twenty others. Now one button opens it as a thread — theirs left,
ours right, oldest first — and the footer says plainly which parts it
holds: the quote, the read receipt and the acceptance confirmation are
sent by trigger nodes and never written to a field, so they are not
there. A thread that quietly omitted them would be worse than one that
admits what it is.
CONFIRMING A PREMIUM IS A SIGN-OFF, NOT A TASK. It is the only step
locked to one role and the only one that touches money — an employee that
could mark a premium received could put a customer on risk for a policy
nobody paid for. It sat fourth in a list of six queues. It gets its own
band now, shown only to the role that owns it, amber only when something
is actually waiting: a permanent alert colour on an empty queue teaches
people to stop seeing it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Engage can now answer a customer's question from the policy wordings
(82), so the lead file has a conversation in it rather than a single
recorded reply.
The audit trail renders both halves — "The customer said", then "We
replied" — so the exchange reads as one, which is what an operator
reviewing an acceptance nobody in the office made actually needs.
The lead file gets a Conversation group beside Proposal: the question,
the answer, and the count. Acceptance stays in Proposal, one tab away
from the words that produced it.
"Reply to the customer" is offered to ops_admin only. Engage declines
anything it cannot answer from the wordings, anything that is a
negotiation, and everything past three answers — so a person needs a way
to take over, and it has to be through the same thread or the reply
lands somewhere the lead file never sees.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Recording the acceptance was the third human touchpoint, and it was a
person in the office typing what a customer had said somewhere else.
The customer's WhatsApp reply now drives it (81), so this stage has no
step of its own.
"Record the acceptance" drops from a step to a folded recovery lever —
kept, because a customer who says yes on a CALL still needs somebody to
record it, and because a reply Engage judged ambiguous has to be
actionable by a person.
That left Quote Presented with an empty screen at exactly the stage an
operator is most likely to wonder whether something has broken, so the
waiting state now says so. Grey, and no pulse: nothing is happening, and
that is the correct state — unlike the AI strip, where a still dot would
mean something is wrong.
The reply itself surfaces in two places: the audit trail carries it as
"The customer said", and the lead file files it under Proposal beside the
acceptance it produced. The evidence and the decision it justifies are
one line apart, which matters when nobody in the office made it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
THE SIDEBAR NOW CARRIES COUNTS. It deliberately did not, on the argument
that a tally could only come from a second full list call that would then
disagree with the queue's own total. Right about the cost, wrong about
the conclusion: with no numbers the only way to learn whether anything
was waiting on you was to open all nine queues in turn, which is the
question navigation exists to answer.
There is no second call. The overview's existing fetch moved into a
PortfolioProvider that both surfaces read, so this is one call fewer than
before and the two agree by construction. Bounded at 200 rows, as the
overview always was; past that the honest answer is an aggregate
endpoint, not a bigger limit.
What the badge counts is what needs a PERSON — phaseOf again, so a lead
whose documents are in and whose AI chain is running is reported beside
the badge rather than inside it. An amber dot marks a queue holding a
renewal inside a week, which is the only reason to open one queue before
another and was previously invisible. Zero is shown rather than hidden:
"nothing here" is an answer, and a queue that disappears when it empties
makes the sidebar move under the cursor.
Closed is folded into a summary. Three of the twelve queues, opened about
once a week, and at equal weight they made the live ones harder to find.
REASONING OPENS IN A DIALOG, NOT INLINE. The audit rail is 320px wide
with one entry per step, and a 1,500-character rationale expanding in
place pushed a lead's whole history off screen to read one sentence of
it. Nobody reads a paragraph in a sidebar. The finding — the AI's own
first sentence, which is already the conclusion — stays on the line; the
working is one click away and one Escape back, headed by what is being
read ("Call — Log Contact") rather than by nothing. Escape closes, the
page behind does not scroll, and focus returns to the button that opened
it so a keyboard reader keeps their place.
Two headings that described the container rather than the contents:
Record → "Lead file", and the audit trail's subtitle now says what a
reader gets from it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Traced the console against the workflow it renders. Five gaps, all of
which made a working chain look like a broken one.
THE AUDIT TRAIL WAS READING THE WRONG KEYS. An audit row's data is keyed
by the ACTIVITY's field ids, which carry a per-form suffix — the call
notes arrive as contact_notes_2, the document request as
documents_notes_3 — and the timeline matched the unsuffixed global ids.
Almost nothing ever matched, so the trail was a list of activity names
with the agents' reasoning invisible behind it. The one narrative line
that did appear was an accident. It now reads the platform's own typed
`fields[]` (label, data_type, value) and matches on the base id.
That is also what lets it answer the question that was asked: an upload
now renders as "3 documents received" with each one named and openable,
instead of a bare "Collect Documents".
DATA_UPDATE ROWS WERE CLASSED AS AGENT WORK. A bookkeeping row inherits
the roles of whoever caused it, and the AI check ran first — so an AI's
field write appeared in the trail as an entry titled "Data updated",
while the toggle underneath still offered to reveal the others. The
activity id says a row is bookkeeping; the roles say who triggered it.
DOCUMENT PENDING IS TWO SITUATIONS. Before the upload a person has to
act; after it the lead stays in the same state while three AI steps run.
Both rendered as "waiting on the partner agent", so a lead that had just
been served showed an Upload documents button under a panel saying the
documents had been received, and counted against the Action-required
queue. phaseOf() derives the difference once, from documents_status, and
the header, the queue count, the row status and the action list all read
it. The upload demotes to recovery — "Replace or add a document", folded
away with the other levers.
A STOPPED CHAIN LOOKED IDENTICAL TO A RUNNING ONE. The rating engine
refuses to price without an IDV and writes so into quoted_breakup;
Engage then declines to raise a quote it would have to fabricate. Both
are right, and nobody was told: the refusal sat in a field on a tab and
the lead never moved again. blockedOn() surfaces it as an amber strip
that names the missing value and opens the form that carries it.
THE STALL MEASURE WAS DATED FROM THE WRONG COLUMN. progress() fell back
to created_at when updated_at was absent — which it always was, because
neither view returned it — so every lead older than half an hour would
have reported stalled. It reads updated_at only; the view supplies it as
of 76.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things on the lead page.
THE AUDIT TRAIL REPEATED ITSELF. "Qualify Lead" appeared three times in
a row and "Partner Agent Lead" twice, which reads as the AI having done
the same thing three times. It had not. One submission writes several
rows, told apart by execution_state:
zk-act-qualify TRIGGER_PERFORMED the trigger's commit
zk-act-qualify TRIGGER_PERFORMED again, on the settle path
zk-act-qualify zk-state-qualified the one that moved the lead
Only the last means anything to somebody reading the file. They are now
collapsed into one entry carrying the earliest timestamp — when the
operator acted — and whichever state and payload is populated.
Matched on (same activity, within fifteen seconds) rather than on
execution_state, so a genuine repeat survives: Collect Documents really
is performed twice on a lead whose first upload was short, and those are
minutes apart. And matched by looking BACK through recent entries rather
than at the previous one, because the platform interleaves a DATA_UPDATE
row between the two halves of a submission — the rows to merge are near
each other in time but not adjacent in the list.
THE AI PARAGRAPHS HAD NO SUMMARY. attribution_reason and
eligibility_reason run to a paragraph each and opened folded, so the
finding could not be read without expanding. ClampText now lifts the
first sentence out as a headline and puts the rest behind "Show the
reasoning" — no new field, and nothing invented: the employees already
write a conclusion and then its evidence.
It falls back to plain folding when the split would be useless — no
sentence terminator, a first sentence that is the whole paragraph, or
one short enough to be a fragment rather than a finding.
The other half of that is in the charter (70_verdict_first.sql). The
headline is only as good as the sentence, and Intake was opening with
"Tool 29601 confirmed..." — an internal id — because it had been told to
"name the evidence, not the conclusion". Right about content, wrong about
order: it now leads with what it decided and gives the evidence second.
Three problems, and the first one is the reason the other two were hard
to see.
THERE WAS NO DASHBOARD. "/" redirected to the underwriting queue — one
stage, usually empty, and belonging to somebody else. Every screen in
the app answered "show me this queue"; none answered "how is the book
doing, and is anything waiting on me?", which is the only question a
person has before they have picked a queue.
So: a Today page. Three blocks, in the order somebody cares about them.
What is waiting on a person, as three cards that colour only when they
have something in them. Renewals running out, worst first, capped at 30
days because a renewal six months away is not a thing to look at today.
Then every stage with its count, zeroes included — a stage quietly
receiving nothing is only visible if its zero is on screen.
It is one list call, counted in the browser. That is honest at this size
and it is the same call the queues already make. If the book outgrows it
the answer is a counts endpoint, not a bigger page.
THE WORDS WERE WRITTEN FOR WHOEVER BUILT THE WORKFLOW. "What happens
next" was followed by "these are the only activities this state allows
— but the workflow decides, server-side, whether you may". A failed list
said "this queue has no record view yet". Panels were called "Flow
details". A closed lead read "nothing runs from here". None of that is
wrong; all of it is addressed to the wrong reader.
Now: "What you can do", "Lead details", "History", "This queue could not
be loaded". Queue names say what they want — "Documents needed",
"Underwriter to review", "Payment to confirm". The sidebar group called
"Not yet" says "Not due yet", and "Running by itself" says "The agents
have it", which is the actual claim being made.
THE QUEUE HAD NINE COLUMNS AND LED WITH THE WRONG ONE. Two were internal
vocabulary: attribution status renders "clear" or "contested" and means
nothing to an operator, and the channel is background rather than
something anyone scans a queue for. Both fold into a subtitle under the
customer. That leaves six columns and puts the renewal countdown — the
number that decides whether to act today — second instead of fifth.
Also: the timeline printed a raw slug when a step came back without a
name. It is the screen this product is demonstrated on, so a
"zk-act-doc-reminder" in the middle of an otherwise readable story is
expensive. It now reads as English.
The lead file listed the current field values and nothing about how they
got there. On an app whose whole claim is that agents do the work, that
is the wrong thing to show: you could see the outcome and not whether a
machine or a person produced it.
Adds a timeline from the audit trail — every activity in order, badged by
actor, carrying the reasoning the agent actually wrote (attribution,
eligibility, call notes, recommendation rationale, the premium breakup)
rather than the raw field dump that is already below it.
Colour encodes something real: machine work is blue, people amber, the
platform grey, so a file reads at a glance for how much of it was done by
hand.
Also fixes the audit path — the bare /view/audit is not an API route and
falls through to the SPA, returning HTML with a 200 that parses as a JSON
error rather than an HTTP one. It has to be app-scoped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>