The context builder
Before the AI writes a single word, one step assembles everything known about the customer. Until August 2026 that step asked the same database three separate times and quietly invented facts when an answer was missing. It is now a single briefing โ the dossier โ built once and passed to everyone. This page is the shape it is in today, verified against the running system.
flowchart LR
MSG["๐ฑ Customer message"] --> CB["THE CONTEXT BUILDER
10 sources, all at once"]
CB --> D["๐๏ธ The dossier
everything about the customer"]
CB --> H["๐ฌ Recent conversation"]
CB --> M["๐ง Rolling memory + behaviour notes"]
CB --> K["๐ 3 knowledge searches"]
CB --> E["๐ Recent calls ยท ๐ผ๏ธ photo check ยท โฑ๏ธ rate limit"]
D --> BRAIN["The brain that answers"]
H --> BRAIN
M --> BRAIN
K --> BRAIN
E --> BRAIN
classDef hi fill:#e7f2ec,stroke:#166b4e,color:#182420
class CB,D hi
One turn. Ten loaders run in parallel; the dossier is the one that knows the customer.
What changed, in one table
The dossier already existed. The problem was that the two older loaders it was meant to replace were still running beside it, so the work had been added rather than consolidated.
| Per customer message | Before | After |
|---|---|---|
| Things loaded in parallel | 12 | 10 |
| Database reads for customer context | 10 | 5 |
| Times the engagement record was read | 4 | 1 |
| How the customer is looked up | mixed โ phone number and profile ID | phone number only |
| Places that could disagree about you | 3 | 1 |
What each of the ten loads
Only the first is about who the customer is. The other nine are separate concerns that happen to be needed at the same moment, so they run together rather than one after another.
| Loader | What it is | Source | Live |
|---|---|---|---|
| The dossier | Who the customer is โ the single briefing described on this page | 5 collections | 118 records |
| Recent conversation | The last 30 messages. Deliberately excludes one-time passcodes, staff-only notes and inbox activity rows โ and deliberately includes the bot's own replies, which a bad filter used to hide from it | message history | 1,390 |
| Rate limit | Reads a flood-protection counter from Redis. This particular read does nothing โ see the note below; the real flood protection sits earlier in the pipeline | Redis | dead read |
| Agent memory | The running note the agent keeps about this customer between conversations | agent memory | 0 rows |
| Behaviour notes | What we learned from how they actually behave โ "replies fast", "goes quiet after price talk". Produced nightly from scored episodes | user insights | 18 |
| Recent calls | Last 5 phone calls in 30 days, inbound and outbound, with outcome and the AI call summary โ so the bot doesn't greet someone who spoke to a human an hour ago | IVR calls | 38 |
| Approved answers | Staff-approved canned answers matched on meaning | question bank | 30 |
| Company policy | Hand-authored pricing, privacy and service pages. Highest authority โ the agent must never contradict these | company knowledge | 208 chunks |
| Past corrections | Previous staff rewrites of bad replies, so the same mistake isn't repeated | corrections | 0 rows |
| Photo check | One cheap vision call classifying an attached image four ways โ payment receipt, profile photo, document, other | vision model | per message |
The three knowledge searches share one embedding of the customer's message: it is computed once and reused for all three, so three searches cost one embedding. The photo check is capped at 12 seconds and returns nothing on timeout, so a slow vision call can never hold a reply past the response target.
Inside the dossier
Five reads, always starting from the WhatsApp number โ the one identifier every collection agrees on.
flowchart TD
WA["๐ WhatsApp number
the only starting point"] --> T["engagementTracker"]
WA --> U["users"]
U -->|"userID"| P["profiles"]
T --> PAY["paymentRecords
matched on ANY known id"]
U --> PAY
P --> PAY
WA --> N["private staff notes"]
T --> BUILD["Assemble the briefing"]
U --> BUILD
P --> BUILD
PAY --> BUILD
N --> BUILD
BUILD --> OUT["๐๏ธ 13 sections"]
classDef key fill:#e7f6f1,stroke:#0e8a72,color:#182420
classDef out fill:#e7f2ec,stroke:#166b4e,color:#182420
class WA key
class OUT,BUILD out
The profile hangs off the user record, never off a pointer stored on the tracker โ that pointer is missing for most customers.
| Section | What it answers |
|---|---|
identity | Who is this โ registered or a lead, name, contact, when they joined |
profile | Their profile: name, gender, status, matchmaking state, onboarding progress |
funnel | Where they are in the journey, how long they've been stuck there, how they found us |
money | Plan, expiry, contact reveals used and remaining, last payment |
matches | Proposals delivered, per-candidate reactions, open feedback batch, pending nudge |
channel | Messaging windows, opt-out state, message counts, welcome pack |
handover | Whether a human has taken over, and who |
engagement | Engagement tier, response rate, how many nudges they've ignored |
onboardingJourney | When they started, device, the step they struggled on, current form step |
outreach | Registration drip progress โ so "ok" is read as a reply to that nudge |
paymentNudge | Nudges already sent, cooldown, and whether they opted out of them |
payments | Last 3 attempts, with an age flag so an abandoned checkout isn't called "under review" |
privateNotes | Last 3 internal staff notes, so the bot continues the story humans know |
engagementTracker.userStage, which outreachAI's own code calls
the canonical lifecycle stage, and outreachAI is one of its writers: its
nightly dormancy scan demotes inactive customers to dormant and
stamps the change time. The dossier reads that same stamp to work out how
long someone has been stuck. So the two agents cannot tell different stories
โ when the outreach agent moves someone overnight, the conversation agent's
very next reply already knows. Live spread across 118 records: 91 lead,
18 dormant, 4 active, 2 registered, 3 mid-onboarding.The rule that matters most: no invented facts
Every value is written only when the database actually held one. If we don't know, the field is absent โ and the prompt simply omits that line rather than guessing.
flowchart TD
Q{"Does the collection
hold a value?"} -->|"yes"| W["Write it into the dossier"]
Q -->|"no"| A["Leave the field OUT"]
W --> R["Prompt shows the real value"]
A --> S["Prompt omits the line entirely"]
A -.->|"NEVER"| BAD["โ invent a default
'free' ยท 0 ยท 'unknown'"]
classDef go fill:#e7f2ec,stroke:#166b4e,color:#182420
classDef bad fill:#f6e3de,stroke:#a63a2b,color:#182420
class W,R,A,S go
class BAD bad
The only literals kept are ones where absence and the value mean exactly the same thing โ a counter that is zero because nothing has happened, or a flag whose absence is the "no".
Built once, passed everywhere
The briefing is assembled a single time per message and handed to every part of the turn that needs it. Nothing loads customer data a second time.
flowchart LR
D["๐๏ธ The dossier
built ONCE per message"] --> P["The prompt
what the AI reads"]
D --> RT["Routing
which brain, which tools"]
D --> NA["Next best action
the conversion compass"]
D --> ESC["Handover summary
for staff + Slack"]
D --> EP["๐ผ The episode
frozen snapshot for learning"]
D --> XR["๐ Dashboard x-ray
what the agent saw"]
classDef hi fill:#e7f2ec,stroke:#166b4e,color:#182420
class D hi
Six consumers, one source. Previously three of these read from two other loaders that could disagree with each other.
Dead reads this uncovered
Consolidating exposed four places that had been reading fields which never existed. Each looked correct, ran every day, and did nothing.
| What it was meant to do | What actually happened | Now |
|---|---|---|
| Nudge a customer whose match acceptance is pending | Read a field the context object never carried โ the flag could never be true, so the nudge never fired once | FIXED |
| Reward a customer advancing an onboarding step | Compared the wrong collection: the field is filled on 10,021 of 10,021 profiles but only 3 of 118 engagement records, always at the final step โ an advance was unobservable | FIXED |
| Show the customer's name in the Dashboard x-ray | Read a key that was never written โ showed blank for every user, ever | FIXED |
| Show their plan in the Dashboard x-ray | Read the subscription off the wrong object โ showed "none" for everyone | FIXED |
What happens when something fails
Now that one briefing feeds everything, losing it would be far worse than before โ a paying customer would look like a stranger mid-conversation. So the optional parts fail on their own:
| If this failsโฆ | Result |
|---|---|
| Payment history lookup | That section is empty. Everything else survives. |
| Private notes lookup | That section is empty. Everything else survives. |
| The whole briefing | The agent still replies โ it just replies without background. A reply is never blocked. |
| No record of the customer at all | Treated as a brand-new lead, which is what they are. |
How this was verified
The risk in a change like this is silent: the code runs, the prompt still looks plausible, and a fact quietly changes. So the check was the prompt itself.
Golden master. The complete prompt was rendered for seven
real customers โ premium, free, mid-handover, mid-onboarding, active,
one with no engagement record, and an unknown number โ before the change and
after. Five lines differed out of 1,259. Four were a
cosmetic capitalisation fix; the fifth was the invented "free" disappearing
for the one customer whose database says nothing about money.
Test suite. Identical pass and fail counts on both sides โ
nothing newly broken, nothing newly passing by accident.
Live proof after deploying. Services were restarted and
confirmed to have started after the new files landed, then the real
write path was exercised against the live database and cleaned up.