A brokerage lives or dies on one thing a generic CRM can't do: matching the right buyer to the right listing. Not by a keyword filter on beds and price, but by meaning — "a family wanting a suburban house with a garden" should surface the family home over the downtown loft, even when neither description says "family". This overlay makes the Vector pillar the product: two embedding tables and a cosine match, both directions, over the same bucket.
The problem — and the money
The buyer is the brokerage's ops lead who needs buyer-to-listing matching and a commission split that always balances — inside the CRM, not in a separate MLS tool and a spreadsheet. A vanilla CRM can filter listings; it can't rank them by fit to what a buyer actually wants. The payoff is two hard numbers: the family buyer's nearest listing comes back at cosine 0.272 (the "14 Maple Grove" family home) ahead of the downtown loft at 0.530 — and a close computes a commission split of 12,960 + 8,640 = 21,600, exactly the gross. That's the why — meaning-based matching and a balanced split are the brokerage's core loop.
This is the CRM suite + the real-estate overlay. The seven base skills — core, lead-to-opportunity, account-360, pipeline-forecast and the rest — are unchanged. The overlay only adds listings + buyer prefs, two VECTOR(2048) tables, and a commissions table. Same bucket, one copy of the rows.
What real-estate adds
A small, additive diff on top of the suite (skill: crm/real-estate → the
crm/overlays/real-estate contract):
- Inventory + intent —
listings(keylisting_id: price, beds, sqft, adescription) andbuyer_prefs(keycontact_email: budget, beds, areas, must-haves, aprefs_text). - Two VECTOR(2048) tables — the product —
listing_vectorsandbuyer_vectors, onejina-embeddings-v4(2048-dim, cosine) embedding row per listing and per buyer. Match ranks by cosine distance, both directions. - Commissions —
commissions(keydeal_id):agent_amount+brokerage_amountmust sum to gross; opportunities are repurposed as deals-on-a-listing by convention (no new columns). - Two gate changes — lead-to-opportunity scores buyer readiness (financing, timeline, motivation) rather than firmographic fit; account-360's next-best-action suggests the top-matched listings for a buyer, fed by the buyer-to-listing KNN.
Apply the diff — the inventory, the two vector tables, and one embedded listing + buyer:
In the crm bucket, apply the real-estate overlay: create listings, buyer_prefs, listing_vectors + buyer_vectors (VECTOR(2048)), and commissions. Embed two listings (a suburban family home '14 Maple Grove' and a downtown loft) and one buyer (wants a 3-bed suburban house with a garden) with jina-embeddings-v4, one vector row per upsert.
data_table_create→ignite_models_embed→data_table_upsertCreated listings, buyer_prefs, commissions, and listing_vectors + buyer_vectors as VECTOR(2048). Embedded 2 listings + 1 buyer with jina-embeddings-v4 (2048-dim confirmed), one vector row per upsert (2048-dim frame limit); all writes wal_written:true.
export BUCKET=crm # the SAME bucket the CRM suite built — the overlay only ADDs
# 1) inventory + intent + the two VECTOR(2048) tables (the product) + commissions
dodil data table create listings -b "$BUCKET" --merge-key listing_id \
--columns-json '[{"name":"listing_id","type":"string","nullable":false},{"name":"address","type":"string"},{"name":"type","type":"string"},{"name":"price","type":"double"},{"name":"beds","type":"int"},{"name":"sqft","type":"int"},{"name":"status","type":"string"},{"name":"agent","type":"string"},{"name":"description","type":"string"}]'
dodil data table create listing_vectors -b "$BUCKET" --merge-key listing_id \
--columns-json '[{"name":"listing_id","type":"string","nullable":false},{"name":"address","type":"string"},{"name":"text","type":"string"},{"name":"embedding","type":"VECTOR(2048)"}]'
dodil data table create buyer_vectors -b "$BUCKET" --merge-key contact_email \
--columns-json '[{"name":"contact_email","type":"string","nullable":false},{"name":"text","type":"string"},{"name":"embedding","type":"VECTOR(2048)"}]'
dodil data table create commissions -b "$BUCKET" --merge-key deal_id \
--columns-json '[{"name":"deal_id","type":"string","nullable":false},{"name":"opportunity_id","type":"string"},{"name":"gross","type":"double"},{"name":"split_json","type":"string"},{"name":"agent_amount","type":"double"},{"name":"brokerage_amount","type":"double"}]'
# 2) embed a listing description, upsert ONE vector row per call (2048-dim frame limit)
DESC="Spacious 3-bed 2-bath family home at 14 Maple Grove, large garden, quiet suburban street."
VEC=$(dodil ignite models embed jina-embeddings-v4 --input "$DESC" -o json \
| python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin)['data']['data'][0]['embedding']))")
dodil data table upsert listing_vectors -b "$BUCKET" \
--row "{\"listing_id\":\"lst-1\",\"address\":\"14 Maple Grove\",\"text\":\"$DESC\",\"embedding\":$VEC}"
# 3) embed the buyer's preferences the same way -> buyer_vectors (repeat for lst-2 the downtown loft)
PREF="Wants a 3-bedroom suburban house with a garden for a young family; not a downtown apartment."
BVEC=$(dodil ignite models embed jina-embeddings-v4 --input "$PREF" -o json \
| python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin)['data']['data'][0]['embedding']))")
dodil data table upsert buyer_vectors -b "$BUCKET" \
--row "{\"contact_email\":\"[email protected]\",\"text\":\"$PREF\",\"embedding\":$BVEC}"Now the payoff — the buyer-to-listing match by meaning, and the commission split that balances:
In crm, rank listings for [email protected] by cosine distance to their stored preference vector, nearest first. Then compute a 60/40 commission split on a $21,600 gross and check it sums to gross.
data_pg→data_sqlMatch: lst-1 '14 Maple Grove' family home at cosine 0.272 (nearest) ahead of lst-2 downtown loft at 0.530 — the family buyer wanting a suburban house with a garden ranks the family home first. Commission: agent_amount 12960 + brokerage_amount 8640 = 21600 = gross (balances=true).
# buyer -> listing match: order listings by cosine distance to the buyer's stored vector (nearest first)
dodil data pg -b "$BUCKET" "
SELECT lv.listing_id, lv.address,
lv.embedding <=> (SELECT embedding FROM buyer_vectors WHERE contact_email='[email protected]')
AS distance
FROM listing_vectors lv ORDER BY distance ASC"
# lst-1 | 14 Maple Grove | 0.272 <- nearest: the suburban family home
# lst-2 | Downtown Loft | 0.530
# commission split — agent_amount + brokerage_amount MUST equal gross
dodil data sql -b "$BUCKET" "
SELECT deal_id, gross, agent_amount, brokerage_amount,
(agent_amount + brokerage_amount = gross) AS balances
FROM commissions"
# deal-1 | 21600 | 12960 | 8640 | true (12960 + 8640 = 21600)The next-best-action gate then feeds off that KNN: for a given buyer it suggests the top-matched listings instead of cross-sell whitespace. The gate mechanism and its JSON contract are unchanged from the base account-360 skill; only the prompt and its input change.
Scaffold it — the one-shot
With the DODIL MCP connected, one prompt composes the suite and this overlay:
Scaffold a CRM for my real-estate brokerage — the CRM suite plus the real-estate overlay.
Base: the full crm/core suite (accounts, contacts, leads, opportunities, activities; the
account-hierarchy graph; lead-to-opportunity + account-360 + pipeline-forecast).
Then apply the real-estate overlay on the SAME bucket:
1. Create listings + buyer_prefs, and listing_vectors + buyer_vectors as VECTOR(2048)
(jina-embeddings-v4, cosine) — one vector row per upsert.
2. Create commissions (key deal_id); agent_amount + brokerage_amount MUST sum to gross.
3. Repurpose opportunities as deals-on-a-listing; set the pipeline stages new_buyer -> showing ->
offer -> under_contract -> closed_won/lost.
4. Point lead-to-opportunity at BUYER readiness and account-360's NBA at the buyer-to-listing KNN.
Seed the demo above and show the buyer-to-listing match + the balanced commission split.Verify
Every number below was live-validated on 2026-09-02 (org IHDIASH, throwaway bucket, torn down after):
# 5 tables incl. listing_vectors + buyer_vectors as VECTOR(2048); all writes wal_written:true
dodil data table list -b "$BUCKET" | grep -E "listing_vectors|buyer_vectors"
# buyer -> listing match: lst-1 (family home) 0.272 nearest, ahead of lst-2 (loft) 0.530
dodil data pg -b "$BUCKET" "SELECT lv.listing_id,
lv.embedding <=> (SELECT embedding FROM buyer_vectors WHERE contact_email='[email protected]')
FROM listing_vectors lv ORDER BY 2 ASC"
# commission split balances: 12960 + 8640 = 21600 = gross
dodil data sql -b "$BUCKET" "SELECT (agent_amount + brokerage_amount = gross) FROM commissions" # trueThe five tables, the two 2048-dim embeddings, the buyer-to-listing cosine match, and the balanced commission arithmetic are proven live. Wrapping the matcher / commission-calc as a deployed Ignite app reuses the crm-lead-scorer image/deploy pattern already proven in the base suite.
Connect your tools
Everything the overlay wrote lives in the one DataK3 bucket, reachable by your own stack — a listing
portal reads listings over the Postgres wire; a pgvector driver (or a Qdrant/Pinecone client) runs the
buyer-to-listing cosine match against the same listing_vectors rows. data connect crm prints the
endpoints. Full, live-validated walkthrough: Connect your tools.
Composes
This page is not a fork of the CRM — it is a composition:
- Base: the seven CRM suite skills (
crm/core,crm/lead-to-opportunity,crm/account-360,crm/pipeline-forecast, …) — the system of record, unchanged. - Overlay:
crm/overlays/real-estate— the additive diff above (listings + buyer prefs, the two VECTOR(2048) matching tables, the commissions table, the buyer-readiness + listing-NBA gates).
Read the base to learn the mechanics; this overlay is the small, industry-specific diff on top.