Skip to content
Expedify
Building AI Agents for Sales, Onboarding and Support

Module · M3 · Make it count: the pipeline

Leave a trace — and don't create four of them

Lesson 8 of 16 · 9 min

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

Four settings. The third one is the lesson.

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_schedule Defaults to contact.

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_delete Defaults to get.

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:

created_at 10:51:21, updated_at 10:51:29. One record.

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:

The second run made the record worse.

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:

  1. 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.
  2. 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.
  3. 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:

This build hit both, in that order, before it wrote a single row.

Passing the record as a data object

What is actually true
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

What is actually true
It does not. It has first_name and last_name. It also has no status — the field is lead_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.