marriageAI ยท what the agent knows before it thinks

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 messageBeforeAfter
Things loaded in parallel1210
Database reads for customer context105
Times the engagement record was read41
How the customer is looked upmixed โ€” phone number and profile IDphone number only
Places that could disagree about you31

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.

LoaderWhat it isSourceLive
The dossier Who the customer is โ€” the single briefing described on this page 5 collections118 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 history1,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 Redisdead read
Agent memory The running note the agent keeps about this customer between conversations agent memory0 rows
Behaviour notes What we learned from how they actually behave โ€” "replies fast", "goes quiet after price talk". Produced nightly from scored episodes user insights18
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 calls38
Approved answers Staff-approved canned answers matched on meaning question bank30
Company policy Hand-authored pricing, privacy and service pages. Highest authority โ€” the agent must never contradict these company knowledge208 chunks
Past corrections Previous staff rewrites of bad replies, so the same mistake isn't repeated corrections0 rows
Photo check One cheap vision call classifying an attached image four ways โ€” payment receipt, profile photo, document, other vision modelper 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.

๐Ÿซ™
Two of the ten return nothing today โ€” both legitimately. Agent memory was deliberately emptied on 7 August so the redesigned note-taker could refill it from scratch; the writer is deployed and running, and it refills organically the first time a customer says something worth remembering. Since the purge there have been two real customer turns and the model judged both as "nothing to record" โ€” which is the designed answer, not a failure. Past corrections is empty because no staff member has ever submitted a correction: working plumbing, empty tank.
๐Ÿšฆ
The rate-limit read in this list is dead code, and you are still protected. It looks for a counter under one name; the only thing that ever writes a counter uses a different name and a different data structure, so this read has returned "plenty of headroom" for every customer since the service was first written โ€” 397 of 397 logged turns. Four places consume that answer, including the Dashboard trace, which therefore shows operators a rate-limit reading that was never real. Flooding is genuinely blocked, but by a different limiter that runs earlier in the pipeline, before any AI is called. Both layers let traffic through if Redis is down rather than blocking everyone.

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.

SectionWhat it answers
identityWho is this โ€” registered or a lead, name, contact, when they joined
profileTheir profile: name, gender, status, matchmaking state, onboarding progress
funnelWhere they are in the journey, how long they've been stuck there, how they found us
moneyPlan, expiry, contact reveals used and remaining, last payment
matchesProposals delivered, per-candidate reactions, open feedback batch, pending nudge
channelMessaging windows, opt-out state, message counts, welcome pack
handoverWhether a human has taken over, and who
engagementEngagement tier, response rate, how many nudges they've ignored
onboardingJourneyWhen they started, device, the step they struggled on, current form step
outreachRegistration drip progress โ€” so "ok" is read as a reply to that nudge
paymentNudgeNudges already sent, cooldown, and whether they opted out of them
paymentsLast 3 attempts, with an age flag so an abandoned checkout isn't called "under review"
privateNotesLast 3 internal staff notes, so the bot continues the story humans know
๐Ÿ”—
The funnel position is shared with the outreach agent โ€” the same field, not a copy. Both agents read 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.

๐Ÿ’ธ
What this fixed. The old loader looked your subscription up by a profile pointer stored on the engagement record โ€” a pointer 97 of 118 records don't have. When the lookup found nothing it didn't say "unknown", it said "free". The AI was then told, as fact, that a customer was on the free plan on the strength of nothing at all. That line is now printed only when the database says so.
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 doWhat actually happenedNow
Nudge a customer whose match acceptance is pendingRead a field the context object never carried โ€” the flag could never be true, so the nudge never fired onceFIXED
Reward a customer advancing an onboarding stepCompared 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 unobservableFIXED
Show the customer's name in the Dashboard x-rayRead a key that was never written โ€” showed blank for every user, everFIXED
Show their plan in the Dashboard x-rayRead the subscription off the wrong object โ€” showed "none" for everyoneFIXED
๐Ÿงฉ
Why these hid so well. The context object was a fixed list of allowed fields, rebuilt one field at a time. A field missing from that list is simply absent at runtime no matter what the database holds โ€” and reading an absent field returns empty rather than raising an error. The code reads fine; it just never sees anything. The dossier removes the list.

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 lookupThat section is empty. Everything else survives.
Private notes lookupThat section is empty. Everything else survives.
The whole briefingThe agent still replies โ€” it just replies without background. A reply is never blocked.
No record of the customer at allTreated 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.

โœ…
Live since 8 August 2026. Both legacy loaders are deleted โ€” verified not just in the code but on the running process. The engagement record is read once per message instead of four times.