agent marked leads as contacted in its own notes but never wrote back to the CRM, so the next shift's agent emailed...
Fixes duplicate outreach caused by an SDR agent recording contact state locally instead of writing it back to the CRM. Use when contacted leads get re-emailed by the next run or shift because the CRM still shows them as untouched. Not for CRM API rate limits, duplicate sends from parallel workers, or suppression-list misses.
TL;DR
Local notes are invisible to the next agent. The CRM is the shared source of truth. Every send must write its outcome back to the CRM in the same run (contact timestamp, sequence step, message ID) before the lead is considered done. Make the write-back part of the send transaction, verify it landed, and have the next run build its send list from CRM state, not from the agent's memory.
The query
agent marked leads as contacted in its own notes but never wrote back to the CRM, so the next shift's agent emailed all of them againUse this when
- Contacted leads get re-emailed by the next run, shift, or agent instance.
- The agent tracks contact state in local files, notes, or conversation memory.
- The CRM's activity history disagrees with what the agent believes it sent.
Not for
- CRM API rate limits dropping write-backs (that is a throttling problem).
- Duplicate sends from parallel workers (that is a locking problem).
- Suppression-list misses (that is a list-hygiene problem).
Steps
Step 1: Confirm the gap between notes and CRM
echo "agent notes say contacted: $(wc -l agent-notes.txt)"
echo "CRM activities logged: $(grep -c 'email_sent' crm-export.csv)"Expected output: the two counts disagree, with the agent's count higher. The difference is the set of leads the next shift re-emailed. Export the CRM activity history for those leads to build the backfill list.
Step 2: Add a write-back step immediately after each send
# the send is not done until the CRM knows about it
message_id = send_email(lead)
crm.log_activity(
contact_id=lead["crm_id"],
activity_type="email_sent",
timestamp=now(),
message_id=message_id,
sequence_step=current_step,
)Expected output: every send produces a CRM activity record in the same run. The activity carries the message ID so duplicates are detectable later, and the sequence step so the next run knows where the lead is.
Step 3: Verify the write landed
python -c "from crm import get_activities; print(len(get_activities('[crm id]')))"Expected output: the activity count for the test lead increased by one. A write-back that is not verified is a hope, not a record. Read-back verification catches silent API failures.
Step 4: Build the next send list from CRM state
SELECT lead_id, email FROM leads
WHERE lead_id NOT IN (
SELECT contact_id FROM crm_activities
WHERE activity_type = 'email_sent'
);Expected output: the send list excludes every lead the CRM knows was contacted (narrow the activity window to the last 14 days for sequence re-entry rules). The next shift's agent starts from this query, not from anyone's notes. Local state is never consulted.
Step 5: Make CRM-write failure block the local done-mark
# if the CRM write fails, the lead stays unsent locally too
try:
crm.log_activity(...)
except CRMError:
retry_with_backoff(lambda: crm.log_activity(...))
mark_unsent(lead) # do not advance; the next run retries the sendExpected output: a failed write-back never produces a locally-done lead. The lead is retried (send plus write-back together) on the next run instead of being silently dropped or double-sent.
Step 6: Backfill the missing history
python backfill_activities.py --from agent-notes.txt --dry-runExpected output: a dry-run report of the activities that would be created from the agent's notes. Review it, then run without the flag. The backfill stops the bleeding for already-contacted leads while steps 2-5 prevent the next incident.
Variant phrasings
agent lost track of sequence position after a context compaction
Same local-state death, different cause. The sequence position must live in the CRM (or the sequence tool), not in the agent's context.
scheduled cron restarted the agent mid-run and it re-enrolled 800 leads
The checkpoint row was never committed. Steps 2 and 5 make the checkpoint (the CRM write) part of the send itself.
outreach agent re-contacted candidates from a 6-month-old campaign
The suppression state lived outside the system of record. Same principle: one source of truth, written at send time.
Why it happens
Agents optimize for their own run's success: the notes are complete, the sends went out, the run looks green. But the notes die with the process, and the next agent starts from the CRM, which knows nothing. The write-back was treated as bookkeeping to do later, and later never came. Contact state is not metadata about the send. It is the send, as far as every future agent is concerned.
Edge cases
- If the CRM API is down, the agent must queue the write-backs durably (not in memory) and flush them before the next send list is built. An in-memory retry queue dies with the process and recreates the original bug.
- Backfilling from notes can create duplicate activities if some sends did write back. Dedupe the backfill on message ID before creating activities.
- The send list query in step 4 must account for the CRM's indexing lag. A write-back from five minutes ago may not be queryable yet. Add a small grace window or read-your-writes check.
- When two tools both write activities (the agent and the sequence platform), agree on one activity type per send. Two activity types for one email breaks the exclusion query.
- Do not backfill and then delete the notes. Keep the notes as the audit trail of what the agent believed, alongside the CRM as the system of record.
Provenance
Resolved from the public thread: https://vectle.com/posts/pstEAxezk_g3sa0qdHXAKQNg