Skip to content

Dealflow Intelligence

Glowing deal cards flowing down a pipeline into a single calm briefing screen

A venture fund’s memory lives in its CRM. Miss one thing and the memory is now wrong. A meeting you took last week never made it into Affinity; a deal sits at “Term Sheet” in your head but “3rd Meeting” in the database. So the Monday dealflow review runs on fiction, and the fund makes decisions on a pipeline that quietly disagrees with reality.

Dealflow Intelligence keeps that disagreement from happening. It pulls the deal list out of Affinity, enriches each active deal with its real meeting history, hands Mundi — the financial-intelligence Jedi — a short brief of what actually needs a human, and pushes proven work contacts back into Affinity so the next sync finds them. In the end, it is a machine for making sure the most expensive meetings of the week left a trace.

The scripts live in sanctum-crm under intel/, reachable through symlinks in ~/.openclaw/scripts/, each owning one boundary:

Affinity ─▶ v_deal ─▶ prep ─▶ Mundi ─▶ Signal
v2 REST │ DuckDB v1 API 30 min Force Flow
└──────── push ────────▶ Affinity
v1 API, daily
StageScriptWhat it does
Syncaffinity-sync.pyPulls the deal lists from the Affinity v2 REST API into workspace.duckdb (the v_deal view). Daily, com.sanctum.affinity-sync. On success, re-seeds the work graph so partner sessions stay current. It fetches both lists into memory before it takes the write lock — an earlier version connected first and held the whole archive through two paginated API calls.
Enrichdealflow-meeting-prep.pyReads the active pipeline from v_deal, fetches each deal’s real meeting/email recency, and buckets it.
Briefmundi-watch.pyEvery 30 minutes under com.sanctum.mundi-briefing, event-driven: it fires only on a deal-related email or a due Affinity reminder, folds the enrichment into a terse brief, and pages it through Force Flow to Signal.
Pushaffinity-push.pyDaily under com.sanctum.crm.linkedin-sync, the one path that writes to the fund’s system of record — see below.

The active pipeline is roughly 60 deals in a decision status: Meeting Pending, To be Reviewed, 2nd/3rd Meeting, Due Diligence, Term Sheet, Refer to LP. It is not the nine thousand discarded first-screens. And it is not the standing “Weekly Review” list that fills with deals nobody has touched since 2022.

Every healthy active deal lands in one of four buckets. A fifth — OUTAGE — comes just below. The brief leads with the only one that matters at 8 a.m.:

  • NEEDS YOU — you met or emailed them and nothing is booked next. The ball is in your court.
  • SCHEDULED — a future meeting is on the calendar. (A meeting whose date has passed does not count as scheduled; that was a real bug, and it hid deals that needed action.)
  • COLD — an active deal with no contact in three weeks.
  • UNRESOLVED — the deal carries a stale CRM id that no longer resolves. Cleanup, not crisis.

Most of the engineering here is at the seams between layers, where the obvious thing is wrong.

Meeting intel comes from the v1 API, not v2. Affinity’s v2 relationship-intelligence field returns zero fields for most companies. One well-connected deal happened to populate, which masked the gap until a wider sweep exposed it. The reliable source is the v1 /organizations/{id}?with_interaction_dates=true endpoint. The lesson is the standing one: a contract that passes on one happy row is not tested.

The sync is atomic. affinity-sync.py writes both lists inside a single transaction. A partial failure rolls back rather than leaving v_deal half-applied yet stamped fresh — which would blind the freshness guard into reporting health over a broken pipeline.

The freshness guard exists because the last sync died silently for two months. The previous Rube/Composio integration failed quietly, and nobody noticed until the pipeline was a quarter stale. Mundi now checks how long it has been since v_deal was refreshed and warns past thirty hours. It also treats a failed freshness read itself as a symptom, not as “fresh.”

The knobs, for when you need them:

# Cadence (launchd)
com.sanctum.affinity-sync: daily 06:30 # Affinity -> v_deal
com.sanctum.mundi-briefing: every 30 min # mundi-watch.py, event-driven
com.sanctum.crm.linkedin-sync: daily 08:40 # harvest, then affinity-push
com.sanctum.crm-freshness: hourly # every channel vs its SLA
# Tunables (in-script constants)
ACTIVE_STATUSES: decision-stage statuses only
cold-days: 21 # quiet-deal threshold
STALE_HOURS: 30 # sync-freshness alert (daily sync + grace)
cache: ~20h # meeting-intel cache, so the sweep runs once/day
MAX_CREATES_PER_RUN: 25 # affinity-push cap; remainder is logged
MAX_CONSECUTIVE_ENRICH_FAILURES: 3 # affinity-push session breaker
Terminal window
# See the active pipeline on demand
~/Projects/sanctum-crm/intel/dealflow-meeting-prep.py # human-readable
~/Projects/sanctum-crm/intel/dealflow-meeting-prep.py --json # for the briefing

Everything above reads. One path writes, and it writes into the fund’s system of record, so it is built to refuse rather than guess.

affinity-push.py walks recent LinkedIn conversations, and for each unknown counterparty it fetches the person’s real profile, checks the companies on it against the engaged deals in v_deal, and creates the contact only if a company matches a deal. Not a domain resemblance, not a name that looks familiar — a company-to-deal match. Everything else is logged for review and left alone. No enrichment means not provably work, which means not created.

Two guards matter more than the gate:

  • A session breaker. Enrichment rides a real logged-in browser session. When that session expires every fetch fails, so the run stops after a streak of failures rather than paying the timeout once per candidate. A streak, not a total: one unfetchable profile among healthy ones is normal.
  • The watermark does not move on an aborted run. It is a max(date_ts), so committing it after a partial pass would skip the unexamined candidates permanently. An abort pages, exits non-zero, and leaves them for the next run.

That second guard is the interesting one, because the failure it prevents is invisible. A run that examined nothing and advanced the watermark looks exactly like a quiet day.

The pipeline ships with 49 unit tests over the pure logic. They cover bucket classification, status dedup, Affinity field extraction, HTTP-error categorization, and the briefing’s outage-vs-quiet partitioning. Then there is the end-to-end harness, tests/e2e_dealflow.py.

The harness crosses every real boundary in order: Keychain, Affinity v2, the DuckDB transaction, the v_deal read, the v1 interactions call, Mundi’s consumer contract, the freshness guard, and a live Force Flow page. Structural assertions prove a field exists. The harness proves a real artifact crossed all of them before it ever reached your phone. That is the only proof a Monday brief is worth trusting.