Leave a trace — and don't create four of them
Upsert the contact so one student is one record — and see how an unguarded upsert quietly makes the record worse.
Your desk answered the question. Now what does the business have to show for it? If the answer is a chat transcript nobody will ever open, you have built a cost centre.
This lesson leaves a trace. And it leaves one — because the failure that quietly ruins a CRM is not the missing record, it is the fourth copy of one that already existed.
The one idea. Dedup is an ops problem, not an AI problem. Four contacts for one student and every number downstream lies — conversion, pipeline value, who has been contacted. No better model fixes it, because no model is being asked.
The build
Desk — leave one trace, not four
The model reads prose and emits JSON. A deterministic node does the writing. Those are two different jobs and they belong to two different nodes.
Scroll for all 4 steps →
Read the split. The llm_integration node reads free text and produces structured fields — that is a language job. The crm_manager node writes them — that is a database job. The model never touches the CRM, so it cannot invent a column, skip a match, or write twice.
The line that makes it an upsert
| Field | What it holds |
|---|---|
entity_type | Type of CRM entity or relationship to manage One of: contact · company · deal · note · task · product · activity · deal_contact · deal_company · contact_company · deal_product · deal_payment · deal_payment_schedule Defaults to contact. |
operation | Operation to perform. Use batch_* for multiple items at once. One of: create · update · get · search · delete · link · unlink · update_relationship · batch_create · batch_update · batch_delete Defaults to get. |
create_if_not_found | For contact updates: if contact not found by email, create it (upsert behavior) Defaults to true. |
email | Email address (contacts/companies) |
entity_type
- What it holds
- Type of CRM entity or relationship to manage One of:
contact · company · deal · note · task · product · activity · deal_contact · deal_company · contact_company · deal_product · deal_payment · deal_payment_scheduleDefaults tocontact.
operation
- What it holds
- Operation to perform. Use batch_* for multiple items at once. One of:
create · update · get · search · delete · link · unlink · update_relationship · batch_create · batch_update · batch_deleteDefaults toget.
create_if_not_found
- What it holds
- For contact updates: if contact not found by email, create it (upsert behavior) Defaults to
true.
email
- What it holds
- Email address (contacts/companies)
create_if_not_found: true turns an update into an upsert. Matched on email, an existing contact is updated in place; with no match, one is created. Remove that single line and every repeat enquiry mints a duplicate.
Proven, not asserted. The same person wrote in twice, eight seconds apart, in different words:
| Run | What they wrote | What the CRM did |
|---|---|---|
| First | "Hi, I'm Kavya Raghunathan (kavya.raghunathan@northwind.co.in…)" | Created the contact |
| Second | "Me again — Kavya here, kavya.raghunathan@northwind.co.in…" | Updated the same row. Still one contact. |
First
- What they wrote
- "Hi, I'm Kavya Raghunathan (kavya.raghunathan@northwind.co.in…)"
- What the CRM did
- Created the contact
Second
- What they wrote
- "Me again — Kavya here, kavya.raghunathan@northwind.co.in…"
- What the CRM did
- Updated the same row. Still one contact.
And the thing the run exposed that the design did not expect
Look at what the surname actually is after those two runs:
| Field | After run 1 | After run 2 |
|---|---|---|
| first_name | Kavya | Kavya |
| last_name | Raghunathan | raghunathan |
| phone | +91 98867 41120 | +91 98867 41120 |
first_name
- After run 1
- Kavya
- After run 2
- Kavya
last_name
- After run 1
- Raghunathan
- After run 2
- raghunathan
phone
- After run 1
- +91 98867 41120
- After run 2
- +91 98867 41120
The first message spelled the name out. The second only carried the email address, so the extraction derived a surname from kavya.raghunathan@northwind.co.in — lowercase — and the upsert wrote it straight over the good value. Nothing errored. Nothing warned.
An upsert is not automatically an improvement. It stops duplicates and, by default, lets the LAST observation win regardless of whether it is the best one. A follow-up message almost always carries less identifying detail than the first — so the natural drift of an unguarded upsert is toward worse data, arriving quietly.
Three ways to hold that line, in increasing order of effort:
- Write only what this message actually stated. Have the extractor emit an empty string for anything absent, and skip empty fields on write instead of overwriting with them.
- Never overwrite a non-empty field with a worse-quality source. A name typed by the human beats a name derived from their email address.
- Append rather than replace for anything historical. Notes and activities accumulate; identity fields do not.
Two gotchas that cost real time here
Both of these came out of building this exact workflow, and neither is guessable:
| What looks right | What is actually true |
|---|---|
Passing the record as a data object | Writes go in json_data, as a JSON string. A data object is silently dropped — it fails as a no-op, not an error. |
A contact has a name field | It does not. It has first_name and last_name. It also has no status — the field is lead_status. |
Passing the record as a data object
- What is actually true
- Writes go in
json_data, as a JSON string. Adataobject is silently dropped — it fails as a no-op, not an error.
A contact has a name field
- What is actually true
- It does not. It has
first_nameandlast_name. It also has nostatus— the field islead_status.
The habit worth stealing. Do not guess a column name — read the entity's fields before you write to it. Two failed runs here were both the same mistake: assuming a schema instead of checking one.
Next: the contact exists. Now the deal has to say something true about where they are — which is lesson 9.
Related lessons
Base rates — what a piece of evidence is actually worth
A face-recognition system that is 99.9% accurate and almost entirely wrong, and a number that sent an innocent woman to prison. Both are the same arithmetic, and it is the arithmetic that decides what any piece of evidence is worth.
ReadConfirmation and survivorship — what you never looked for
Two questions about evidence you did not go looking for. One is a rule you have to discover, and one is a pattern in five famous people — and in both, the thing that would have told you the truth is the thing nobody checks.
ReadLoss aversion, sunk cost and regression — what it costs you
Four questions you answer about yourself rather than about a scenario, and your own answers are the finding. Then the pattern that makes praise look useless and criticism look like it works, whatever you actually do.
Read
