DEV Community

Dmitry Lukashov
Dmitry Lukashov

Posted on Fully Autonomous

An empty CRM response is not evidence of no follow-up

Suppose your CRM agent returns this sentence:

Two deals have no follow-up. Ask their owners to fix them.

The JSON behind it contains two empty arrays. One belongs to a completed activity read. The other belongs to an incomplete read. The agent has turned a collection limit into an accusation.

You don't fix that with a more persuasive prompt. Keep the evidence needed to support the statement, and test the rule before asking a model to explain it.

Here's a small, reproducible example using Bitrix read kit, a free MIT-licensed Python CLI from my Lookatshow project. Every record below is synthetic. You need no CRM account, credentials or paid core.

Reproduce the mistake without connecting a CRM

Use Python 3.9 or later. The package uses the standard library:

git clone https://github.com/ilookatshow-gif/bitrix-read-kit.git
cd bitrix-read-kit
git checkout --detach e102c33d20ed5d74591092433e78fb9019e1cc0d
python3 -B bitrix_read_kit.py demo
Enter fullscreen mode Exit fullscreen mode

Use python instead of python3 if that's your interpreter name. Pinning the commit makes the example repeatable even if the default branch changes.

The fixture contains five fictional deals and three unique unfinished activities. Look at these two rows in the output; this is an excerpt, not the full report:

[
  {
    "deal_id": 101,
    "activity_read_complete": true,
    "visible_open_activity_ids": [],
    "flags": ["no_visible_open_activity"]
  },
  {
    "deal_id": 105,
    "activity_read_complete": false,
    "visible_open_activity_ids": [],
    "flags": ["activity_coverage_incomplete"]
  }
]
Enter fullscreen mode Exit fullscreen mode

The arrays match. The conclusions don't.

This tempting filter produces the wrong work queue:

missing = [
    row["deal_id"] for row in report["deals"]
    if not row["visible_open_activity_ids"]
]
# [101, 105]
Enter fullscreen mode Exit fullscreen mode

A narrower predicate preserves what the reader actually established:

missing = [
    row["deal_id"] for row in report["deals"]
    if row["activity_read_complete"]
    and not row["visible_open_activity_ids"]
]
unknown = [
    row["deal_id"] for row in report["deals"]
    if not row["activity_read_complete"]
]
# missing: [101]; unknown: [105]
Enter fullscreen mode Exit fullscreen mode

Even missing needs a careful label: no visible unfinished CRM activity, not “no customer agreement.” A promise could live in a conversation or another system. The useful next action is to check the deal card and correspondence, not reprimand an employee.

Make coverage part of the data contract

Bitrix24 documents that crm.activity.list respects the current user's permissions, paginates results, and returns next when another page exists. A successful HTTP response doesn't establish that your integration collected the whole selection, much less every record in the company.

This module keeps coverage per deal. It also reports the collection window and refuses to claim an atomic portal snapshot:

{
  "scope": "records_visible_to_webhook_user",
  "selected_deal_pages_complete": true,
  "all_selected_activity_pages_complete": false,
  "snapshot_is_atomic": false,
  "portal_wide_completeness_proven": false
}
Enter fullscreen mode Exit fullscreen mode

That object is the fixture's actual coverage output. true for deal pages and false for activity pages aren't contradictory: the selection of deals finished, but one deal's activity read didn't.

Don't discard an entire partial report either. An incomplete read that contains an overdue activity still proves that particular activity was visible. It doesn't prove there were no others. In a separate regression, adding synthetic unfinished activity 205 to deal 105 produces both activity_coverage_incomplete and overdue.

Positive evidence survives incomplete coverage. An absence claim doesn't.

A later clock is not a newer snapshot

Run the same saved input at two analysis times:

python3 -B bitrix_read_kit.py audit \
  --input examples/synthetic-snapshot.json \
  --as-of 2026-10-10T10:00:00+03:00

python3 -B bitrix_read_kit.py audit \
  --input examples/synthetic-snapshot.json \
  --as-of 2026-10-13T10:00:00+03:00
Enter fullscreen mode Exit fullscreen mode
Field First analysis Later analysis
Unique visible unfinished activities 3 3
Deals with a scheduled activity 1 0
Deals with an overdue activity 1 2
Deals with incomplete activity reads 1 1

Activity 201 on deal 102 has a deadline of 10 October at 12:00 MSK. The first analysis happens before it; the second happens after it. Nothing entered or left the input file.

Both reports retain captured_at: 2026-10-10T06:00:00Z. The later report doesn't tell you whether someone completed the activity after capture. It answers “how does this saved evidence classify at this time?”, not “what is happening in the portal now?”

Keep that difference in the UI. Show capture time beside analysis time. Before a consequential action, obtain an authorized fresh read rather than merely changing --as-of.

There is another date trap: Bitrix24's official activity example uses 9999-12-31 for an activity with no deadline. The kit classifies the fixture's activity 203 as undated. It doesn't celebrate an appointment scheduled thousands of years ahead.

Give the model a bounded job

Once the checks run, use the model to explain evidence, not manufacture certainty. This prompt works with the included fixture:

Run the two offline audits at the exact times in this article. Don't connect a portal or change the CRM. For each deal, give its ID, visible unfinished activity IDs, coverage, supported conclusion and manual verification step. Keep incomplete reads separate from complete empty selections. Explain why activity 201 changes classification without changing the unfinished count. Distinguish capture time from analysis time. Don't infer lost sales, employee failure or an agreed customer next step.

Make the same boundary executable in your integration tests:

for row in report["deals"]:
    flags = set(row["flags"])
    assert not {
        "no_visible_open_activity",
        "activity_coverage_incomplete",
    } <= flags
Enter fullscreen mode Exit fullscreen mode

That assertion catches one specific contradiction. It doesn't certify the underlying portal data. Keep provider errors, malformed records and permission checks out of the “empty success” path too.

For this article, autonomous AI agents reran the pinned source's 95 tests and its clean-install smoke test on macOS arm64 with Python 3.9.6. They passed. No real customer portal supplied these results. Real capture files still contain linkable internal CRM IDs even without names or phone numbers; don't publish them or hand them to an unrelated AI service.

Use the free module, then decide what needs integration

Start with the pinned source and fixture. You can keep the offline checks independent of any agent host. The CLI isn't an MCP server; adding a model doesn't change its evidence limits.

I'm Dmitry Lukashov, creator of Lookatshow, a marketing-management system for AI agents. If your team needs a CRM review workflow with explicit permissions, freshness rules and a separate read-before-write check, discuss that implementation with me. The module remains free and doesn't require that service. I share practical notes at @lookatsoul.

Disclosure: this is my project. Autonomous AI agents prepared the article and checked its code and outputs. The linked source, fixture and commands make the technical claims independently testable.

Top comments (0)