What you'll build: a real go-to-market CRM — the one we use to sell DODIL — on one DataK3 bucket. Contacts, deals, and activities are merge-keyed tables; the account hierarchy is a graph you traverse to roll up pipeline by corporate family; activity notes are vector-searchable by meaning; email sequences are data (adding an email is an upsert); a serverless engine walks the steps; and a cost-gated pipeline classifies leads on Ignite Models. One bucket answers by content (SQL), by relationship (graph), and by meaning (vector) over one copy of the rows — no ETL, no Postgres + Neo4j + Pinecone + a warehouse to stitch together. We stood the core up in about an hour.
What you'll learn:
- Model a CRM as merge-keyed DataK3 tables — idempotent upserts, one store for contacts and leads.
- Project accounts + contacts into a graph and traverse it — roll up a whole corporate family's pipeline.
- Make activity notes semantically searchable — a
VECTOR(2048)column,jina-embeddings-v4. - Run email sequences as data — a
flow_stepsrow is an email; edit a campaign, don't redeploy. - Deploy a scale-to-zero tick engine and a public/private tracking split on Ignite.
- Gate a lead pipeline so you only pay to qualify (Ignite Models,
kimi-k2.6) and enrich the batch you choose. - Run every step two ways — by prompting an agent over the MCP, or the
dodil dataCLI.
The problem — and why it matters
A go-to-market team's "CRM" is never one product. It is Salesforce or HubSpot for the records, a separate tool for sequences, another for enrichment and scoring, a warehouse so analytics can see any of it, and a reverse-ETL job to push the answers back. Five bills, and four of them exist only because the first system cannot answer the question you actually have.
Those questions are not exotic. Roll this account's pipeline up to its parent company — that is
a graph, and your CRM stores a text field. Find the accounts that look like our best customers —
that is similarity, and your CRM offers LIKE '%…%'. So the relationship lives in a spreadsheet,
the similarity lives in someone's head, and the sync jobs between the five systems become the
thing the team actually maintains.
Here, all of it is one DataK3 bucket. The same rows answer three ways — SQL, graph, and vector — so there's no second system to sync. Two Ignite apps do the moving parts; the rest is a query.
| Piece | Lands in | Pillar / runs on |
|---|---|---|
| Contacts / deals / activities | tables (contacts, deals, activities) | SQL |
| Accounts + hierarchy | tables (accounts) + graph (crm_node / crm_edge) | Graph |
| Semantic search over notes | table activity_vectors (VECTOR(2048)) | Vector |
| Sequences as data | tables (flows, flow_steps, flow_enrollments, flow_actions) | SQL |
| The tick engine | writes sends → activities | Ignite app (private, scale-to-zero) |
| Lead pipeline | organizations → contacts / deals | Ignite Models (the cost gate) |
| Open / click / unsubscribe | table (email_events) | Ignite app (public, minimal secrets) |
NOTE
Connect the DODIL MCP once — see the two-minute setup. Every step shows an Ask your agent tab and the CLI. We built the real thing by prompting an agent over this MCP.
Prerequisites
- A DODIL organization, and the
dodilCLI authenticated (dodil auth login) or the DODIL MCP connected to your agent (Claude Code, Cursor, VS Code, Codex). export BUCKET=crm— one bucket is the whole system's data plane.- An email sender you can call (SES, Postmark, Resend, …) and — for the pipeline — a lead source.
Step 1 — Stand up the CRM tables (the transactional core)
The CRM is the schema: merge-keyed tables in one bucket. email is the identity across contacts; a
deal's deal_id is signup:<user_id> so re-imports upsert instead of duplicating; activities is an
append-only event log. A --merge-key (PRIMARY KEY) is required — writes are keyed, so re-runs and
shard retries upsert idempotently, and reads are read-your-writes (fresh rows are visible immediately,
JOINs included — no compaction step).
Create a DataK3 bucket crm, then merge-keyed tables: contacts (key email) with user_id, full_name, org_domain, lifecycle_stage, source, subscribed(boolean), created_at, updated_at; deals (key deal_id) with title, contact_email, org_domain, pipeline, stage, status, value_num(double), owner, source, created_at; and activities (key activity_id) with deal_id, contact_email, kind, subject, body, direction, status, ts.
data_bucket_create→data_table_createCreated bucket crm with contacts (key email), deals (key deal_id, value_num as double), and activities (key activity_id). Upserts are idempotent; the same bucket also holds the accounts graph and the leads warehouse for domain joins.
export BUCKET=crm
dodil data bucket create "$BUCKET" --description "GTM CRM: contacts, deals, activities, sequences, account graph"
dodil data table create contacts -b "$BUCKET" --merge-key email \
--columns-json '[
{"name":"email","type":"string","nullable":false},
{"name":"user_id","type":"string","nullable":true},
{"name":"full_name","type":"string","nullable":true},
{"name":"org_domain","type":"string","nullable":true},
{"name":"lifecycle_stage","type":"string","nullable":true},
{"name":"source","type":"string","nullable":true},
{"name":"subscribed","type":"boolean","nullable":true},
{"name":"created_at","type":"string","nullable":true},
{"name":"updated_at","type":"string","nullable":true}
]'
dodil data table create deals -b "$BUCKET" --merge-key deal_id \
--columns-json '[
{"name":"deal_id","type":"string","nullable":false},
{"name":"title","type":"string","nullable":true},
{"name":"contact_email","type":"string","nullable":true},
{"name":"org_domain","type":"string","nullable":true},
{"name":"pipeline","type":"string","nullable":true},
{"name":"stage","type":"string","nullable":true},
{"name":"status","type":"string","nullable":true},
{"name":"value_num","type":"double","nullable":true},
{"name":"owner","type":"string","nullable":true},
{"name":"source","type":"string","nullable":true},
{"name":"created_at","type":"string","nullable":true}
]'
dodil data table create activities -b "$BUCKET" --merge-key activity_id \
--columns-json '[
{"name":"activity_id","type":"string","nullable":false},
{"name":"deal_id","type":"string","nullable":true},
{"name":"contact_email","type":"string","nullable":true},
{"name":"kind","type":"string","nullable":true},
{"name":"subject","type":"string","nullable":true},
{"name":"body","type":"string","nullable":true},
{"name":"direction","type":"string","nullable":true},
{"name":"status","type":"string","nullable":true},
{"name":"ts","type":"string","nullable":true}
]'TIP
Keyed upserts drop null/empty keys. A JSON null — or an empty string "" — in a merge-key
column reads back as null and the row silently disappears on the next read. Use a non-empty sentinel
("none", "n/a") for a "no parent" value, never "".
Step 2 — Model the accounts, and build the account-hierarchy graph
Companies aren't flat. Acme Corp owns Acme Labs and Acme EU; a rep working the family wants the
pipeline for the whole corporate tree, not one legal entity. So we keep an accounts table (the
record), then project accounts and contacts into a graph and traverse the org structure.
A DataK3 graph is table-backed: one node table with an integer KEY, one edge table, then
CREATE GRAPH. Multiple relationship kinds go on a single rel column of the one edge table
(subsidiary_of for child→parent companies, works_at for contact→account). data pg owns the DDL.
In crm, create an accounts table (key org_domain: name, parent_domain, tier, country) and upsert Acme Corp (acme.io, parent none), Acme Labs (labs.acme.io, parent acme.io), Acme EU (acme.eu, parent acme.io), and Greyparrot (greyparrot.ai, parent none).
data_table_create→data_table_upsertCreated accounts (key org_domain) and upserted 4 orgs: Acme Corp with two subsidiaries (Acme Labs, Acme EU) and independent Greyparrot. parent_domain='none' marks a top-level account (never empty-string — that would drop the key).
dodil data table create crm_accounts -b "$BUCKET" --merge-key org_domain \
--columns-json '[
{"name":"org_domain","type":"string","nullable":false},
{"name":"name","type":"string","nullable":true},
{"name":"parent_domain","type":"string","nullable":true},
{"name":"tier","type":"string","nullable":true},
{"name":"country","type":"string","nullable":true}
]'
dodil data table upsert accounts -b "$BUCKET" --row '{"org_domain":"acme.io","name":"Acme Corp","parent_domain":"none","tier":"enterprise","country":"US"}'
dodil data table upsert accounts -b "$BUCKET" --row '{"org_domain":"labs.acme.io","name":"Acme Labs","parent_domain":"acme.io","tier":"enterprise","country":"US"}'
dodil data table upsert accounts -b "$BUCKET" --row '{"org_domain":"acme.eu","name":"Acme EU","parent_domain":"acme.io","tier":"enterprise","country":"DE"}'
dodil data table upsert accounts -b "$BUCKET" --row '{"org_domain":"greyparrot.ai","name":"Greyparrot","parent_domain":"none","tier":"mid","country":"UK"}'Now the graph. A graph node KEY must be an integer — business keys (a domain, an email) get
projected to integer node ids in a dedicated crm_node table. The single crm_edge table carries every
relationship, tagged by rel. Populate both tables fully, then CREATE GRAPH — the graph
snapshots its edges at creation time, so edges inserted afterwards aren't traversable until you re-create it.
In crm, build a graph crm_graph. Create crm_node (id bigint KEY, kind, biz_key, name) projecting the 4 accounts (ids 1-4) and 4 contacts (ids 101-104), and crm_edge (src, dst, rel) with subsidiary_of edges Labs->Corp and EU->Corp plus works_at edges each contact->its account. Then CREATE GRAPH crm_graph over them.
data_pgcrm_node has 8 rows (4 accounts, 4 contacts); crm_edge has 6 rows (2 subsidiary_of, 4 works_at). CREATE GRAPH crm_graph NODES (crm_node KEY id) EDGES (crm_edge SRC src DST dst) succeeded — edges were snapshotted at creation.
# integer-keyed node table: accounts 1-4, contacts 101-104
dodil data pg -b "$BUCKET" "CREATE TABLE crm_node (id BIGINT PRIMARY KEY, kind VARCHAR, biz_key VARCHAR, name VARCHAR)"
dodil data pg -b "$BUCKET" "INSERT INTO crm_node VALUES
(1,'account','acme.io','Acme Corp'),
(2,'account','labs.acme.io','Acme Labs'),
(3,'account','acme.eu','Acme EU'),
(4,'account','greyparrot.ai','Greyparrot'),
(101,'contact','[email protected]','Jane Doe'),
(102,'contact','[email protected]','Bob Lin'),
(103,'contact','[email protected]','Carol Ng'),
(104,'contact','[email protected]','Dan Roe')"
# ONE edge table; the rel column tags each relationship (child->parent, contact->account)
dodil data pg -b "$BUCKET" "CREATE TABLE crm_edge (src BIGINT, dst BIGINT, rel VARCHAR, PRIMARY KEY (src,dst))"
dodil data pg -b "$BUCKET" "INSERT INTO crm_edge VALUES
(2,1,'subsidiary_of'), (3,1,'subsidiary_of'),
(101,1,'works_at'), (102,2,'works_at'), (103,3,'works_at'), (104,4,'works_at')"
# populate FIRST, then snapshot the graph
dodil data pg -b "$BUCKET" "CREATE GRAPH crm_graph NODES (crm_node KEY id) EDGES (crm_edge SRC src DST dst)"Step 3 — Roll up pipeline by corporate family (a real traversal)
Here's the payoff. Every subsidiary and every contact points up toward Acme Corp (node 1), so a
reverse k-hop from node 1 collects the entire corporate family — subsidiaries at hop 1, their
contacts at hop 2 — in one traversal. Join that to deals, and you have the family's total open
pipeline no flat query could give you (Greyparrot and its deal are correctly excluded — they're not in
the tree).
In crm, from Acme Corp (graph node 1) traverse the crm_graph inward up to 2 hops to get the whole corporate family, then roll up open pipeline per account and list every contact under the family.
data_bolt→data_pgFamily = Acme Labs + Acme EU (hop 1) and their contacts Bob, Carol (hop 2), plus Jane at Acme Corp (hop 1). Pipeline rollup: Acme Labs 1 deal $24,000; Acme EU 1 deal $12,000 → $36,000 across the family. Greyparrot ($30k) is excluded — not in Acme's tree.
# who is in Acme Corp's tree? reverse traversal (anchor on the left, arrow flipped)
dodil data bolt -b "$BUCKET" -g crm_graph \
"MATCH (a)<-[:crm_edge*1..2]-(b) WHERE id(a)=1 RETURN b"
# node hop_distance
# 3 1 (Acme EU)
# 2 1 (Acme Labs)
# 101 1 (Jane @ Acme Corp)
# 103 2 (Carol @ Acme EU)
# 102 2 (Bob @ Acme Labs)
# roll up open pipeline across the family — graph traversal JOINed to the deals table in one statement
dodil data pg -b "$BUCKET" "
SELECT n.name AS account, count(d.deal_id) AS open_deals, coalesce(sum(d.value_num),0) AS pipeline
FROM graph_khop('crm_graph', 1, 2, 'in') g
JOIN crm_node n ON n.id = g.node AND n.kind='account'
LEFT JOIN deals d ON d.org_domain = n.biz_key AND d.status='open'
GROUP BY n.name ORDER BY pipeline DESC"
# account open_deals pipeline
# Acme Labs 1 24000
# Acme EU 1 12000 -> family pipeline = $36,000
# every contact under the family, hydrated from the contacts table
dodil data pg -b "$BUCKET" "
SELECT c.full_name, c.email, c.org_domain, g.hop_distance AS hops
FROM graph_khop('crm_graph', 1, 2, 'in') g
JOIN crm_node n ON n.id = g.node AND n.kind='contact'
JOIN contacts c ON c.email = n.biz_key
ORDER BY hops"NOTE
graph_khop('crm_graph', 1, 2, 'in') walks incoming edges (direction in), so it climbs down
the ownership tree from the parent. The literal start key (1) lets the traversal JOIN straight to
your relational tables in a single SQL statement — graph and SQL over one copy of the rows.
Step 4 — Make activity notes searchable by meaning (Vector)
Reps ask "which open deals care about data residency?" — a keyword LIKE misses "GDPR", "EU
hosting", "sovereignty". So we embed each activity note into a VECTOR(2048) column with
jina-embeddings-v4 and search by meaning. Same bucket, same rows — a vector is just a column, not a
separate store.
In crm, create activity_vectors (key activity_id: deal_id, contact_email, note, embedding VECTOR(2048)). Embed each activity note with jina-embeddings-v4 and upsert one row per note.
data_table_create→ignite_models_embed→data_table_upsertCreated activity_vectors with a VECTOR(2048) column. Embedded 4 notes with jina-embeddings-v4 (2048-dim) and upserted them one row per call — batching many 2048-dim vectors in one upsert can hit a gRPC frame limit.
dodil data table create activity_vectors -b "$BUCKET" --merge-key activity_id \
--columns-json '[
{"name":"activity_id","type":"string","nullable":false},
{"name":"deal_id","type":"string","nullable":true},
{"name":"contact_email","type":"string","nullable":true},
{"name":"note","type":"string","nullable":true},
{"name":"embedding","type":"VECTOR(2048)"}
]'
# embed a note, then upsert it as a [f1,f2,…] literal — ONE vector row per upsert call
NOTE="Carol needs GDPR data residency in the EU and is evaluating us against a Snowflake plus Pinecone stack."
VEC=$(dodil ignite models embed jina-embeddings-v4 --input "$NOTE" -o json \
| python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin)['data']['data'][0]['embedding']))")
dodil data table upsert activity_vectors -b "$BUCKET" \
--row "{\"activity_id\":\"act-3\",\"deal_id\":\"deal-eu\",\"contact_email\":\"[email protected]\",\"note\":\"$NOTE\",\"embedding\":$VEC}"
# … repeat for each activity note (act-1, act-2, act-4)Now search by meaning — and JOIN the nearest note straight to its deal:
In crm, which open deals mention data-residency / regulatory-compliance concerns? Vector-search the activity notes and hydrate the top hits with their deal title, stage, and value.
data_vsearch→data_pgNearest note (cosine distance 0.36) is Carol's on deal-eu: 'GDPR data residency in the EU…' — Acme EU platform deal, qualified, $12,000. Then Bob's failover note, then Dan's graph note. The compliance-sensitive deal surfaces first, no keyword match needed.
# quick ranked hits (client-side embeds the query with the same model)
dodil data vsearch -b "$BUCKET" -t activity_vectors --column embedding \
--text "data residency and regulatory compliance requirements" \
--model jina-embeddings-v4 --metric cosine --top-k 3
# id score
# act-3 0.3629 (Carol / GDPR) <- nearest
# act-4 0.5649
# act-2 0.5701
# hydrate: KNN JOINed to deals. Pass the query embedding as a LITERAL in ORDER BY … <=> '[…]'
QV=$(dodil ignite models embed jina-embeddings-v4 --input "data residency and regulatory compliance requirements" -o json \
| python3 -c "import sys,json; print('['+','.join(map(str,json.load(sys.stdin)['data']['data'][0]['embedding']))+']')")
dodil data pg -b "$BUCKET" "
SELECT v.contact_email, d.title, d.stage, d.value_num, v.note
FROM activity_vectors v JOIN deals d ON d.deal_id = v.deal_id
ORDER BY v.embedding <=> '$QV' LIMIT 3"
# [email protected] | Acme EU — platform | qualified | 12000 | Carol needs GDPR data residency…Step 5 — Model the sequence engine as data
A drip campaign is four tables. flows is the campaign, flow_steps are the touches,
flow_enrollments track each contact's position, and flow_actions is the audit log. The point: a
campaign is rows you edit, not code you ship.
In crm, create flows (key flow_id: name, trigger_event, status, created_at), flow_steps (key step_id: flow_id, position(int), kind, subject, body_html, delay_seconds(int), stage), flow_enrollments (key enrollment_id: flow_id, contact_email, deal_id, status, current_position(int), next_run_at, enrolled_at), and flow_actions (key action_id: enrollment_id, flow_id, step_id, contact_email, kind, status, detail, ts).
data_table_createCreated flows, flow_steps, flow_enrollments, and flow_actions. Adding an email to a campaign is now an upsert into flow_steps; the engine reads these to know what to send and when.
dodil data table create flows -b "$BUCKET" --merge-key flow_id \
--columns-json '[
{"name":"flow_id","type":"string","nullable":false},
{"name":"name","type":"string","nullable":true},
{"name":"trigger_event","type":"string","nullable":true},
{"name":"status","type":"string","nullable":true},
{"name":"created_at","type":"string","nullable":true}
]'
dodil data table create flow_steps -b "$BUCKET" --merge-key step_id \
--columns-json '[
{"name":"step_id","type":"string","nullable":false},
{"name":"flow_id","type":"string","nullable":false},
{"name":"position","type":"int","nullable":false},
{"name":"kind","type":"string","nullable":true},
{"name":"subject","type":"string","nullable":true},
{"name":"body_html","type":"string","nullable":true},
{"name":"delay_seconds","type":"int","nullable":true},
{"name":"stage","type":"string","nullable":true}
]'
dodil data table create flow_enrollments -b "$BUCKET" --merge-key enrollment_id \
--columns-json '[
{"name":"enrollment_id","type":"string","nullable":false},
{"name":"flow_id","type":"string","nullable":true},
{"name":"contact_email","type":"string","nullable":true},
{"name":"deal_id","type":"string","nullable":true},
{"name":"status","type":"string","nullable":true},
{"name":"current_position","type":"int","nullable":true},
{"name":"next_run_at","type":"string","nullable":true},
{"name":"enrolled_at","type":"string","nullable":true}
]'
dodil data table create flow_actions -b "$BUCKET" --merge-key action_id \
--columns-json '[
{"name":"action_id","type":"string","nullable":false},
{"name":"enrollment_id","type":"string","nullable":true},
{"name":"flow_id","type":"string","nullable":true},
{"name":"step_id","type":"string","nullable":true},
{"name":"contact_email","type":"string","nullable":true},
{"name":"kind","type":"string","nullable":true},
{"name":"status","type":"string","nullable":true},
{"name":"detail","type":"string","nullable":true},
{"name":"ts","type":"string","nullable":true}
]'Author a campaign — each email is one row; delay_seconds is when it fires after the previous step.
Reorder by editing position; pause by flipping flows.status — no deploy either way.
In crm, create an onboarding flow triggered by signup (status active), then add three flow_steps: position 1 a welcome email now; position 2 a 'first query in DataK3' email after 3 days; position 3 a 'connect your agent' email after 4 more days.
data_table_upsertInserted the onboarding flow (trigger_event=signup, active) and three email steps at positions 1–3 with delays 0 / 259200 / 345600 seconds. Editing the copy or timing is just upserting these rows.
dodil data table upsert flows -b "$BUCKET" \
--row '{"flow_id":"onboarding","name":"Onboarding","trigger_event":"signup","status":"active","created_at":"2026-07-06T10:00:00Z"}'
dodil data table upsert flow_steps -b "$BUCKET" --row '{"step_id":"onb-1","flow_id":"onboarding","position":1,"kind":"email","subject":"Welcome to DODIL","body_html":"<p>Your bucket is live…</p>","delay_seconds":0}'
dodil data table upsert flow_steps -b "$BUCKET" --row '{"step_id":"onb-2","flow_id":"onboarding","position":2,"kind":"email","subject":"Your first query in DataK3","body_html":"<p>Run a SELECT over your bucket…</p>","delay_seconds":259200}'
dodil data table upsert flow_steps -b "$BUCKET" --row '{"step_id":"onb-3","flow_id":"onboarding","position":3,"kind":"email","subject":"Connect your agent","body_html":"<p>Point Claude Code at DODIL over MCP…</p>","delay_seconds":345600}'Step 6 — Deploy the tick engine (Ignite)
A small Ignite app runs the loop. An Ignite app is a separate workload, so it needs its own
service account to call DataK3 (client-credentials → bearer token), injected as runtime env. Each tick
it grabs the enrollments that are due, runs the current step, logs it, and schedules the next one.
It gates itself on contacts.subscribed, so an unsubscribe is honored the instant it's written.
# engine/handler.py (essence) — an Ignite app; the sequence tick loop (polls, or POST /engine/tick).
def tick(limit=200):
due = sql("""SELECT enrollment_id, flow_id, contact_email, deal_id, current_position
FROM flow_enrollments
WHERE status='active' AND next_run_at <= now()
ORDER BY next_run_at LIMIT :n""", n=limit)
for e in due:
step = sql_one("SELECT * FROM flow_steps WHERE flow_id=:f AND position=:p",
f=e.flow_id, p=e.current_position)
if step is None: # walked off the end -> done
upsert("flow_enrollments", {"enrollment_id": e.enrollment_id, "status": "done"})
continue
status = execute(step, e) # send / change stage / add note
upsert("flow_actions", {"action_id": uuid(), "enrollment_id": e.enrollment_id,
"step_id": step.step_id, "contact_email": e.contact_email,
"kind": step.kind, "status": status, "ts": now()})
upsert("flow_enrollments", {"enrollment_id": e.enrollment_id,
"current_position": e.current_position + 1,
"next_run_at": iso(now() + step.delay_seconds)})
def execute(step, e):
if step.kind == "email":
if not subscribed(e.contact_email): # the gate — honor unsubscribe instantly
return "skipped:unsubscribed"
send_email(e.contact_email, step.subject, render(step.body_html, e)) # your email provider
upsert("activities", {"activity_id": uuid(), "deal_id": e.deal_id,
"contact_email": e.contact_email, "kind": "email",
"subject": step.subject, "direction": "outbound",
"status": "sent", "ts": now()})
return "sent"
# ... stage_change / note / task handled the same way ...Give the engine its own least-privilege identity, then deploy it:
Create a service account for crm-engine, grant it k3.editor, then deploy my ./engine app to Ignite as crm-engine with the service-account creds as runtime env so it runs the sequence tick loop.
auth_service-account_create→auth_service-account_grant-role→ignite_app_deployCreated service account crm-engine-sa, granted k3-authorization-service k3.editor, and deployed crm-engine — request-invoked, scale-to-zero. It polls due enrollments (or on POST /engine/tick), sends the next email, logs to activities/flow_actions, and advances each enrollment. Nothing runs between ticks.
dodil auth service-account create crm-engine-sa
SA_UUID=$(dodil auth service-account list -o json | python3 -c "import sys,json;print([s['id'] for s in json.load(sys.stdin) if s['name']=='crm-engine-sa'][0])")
dodil auth service-account grant-role "$SA_UUID" k3-authorization-service k3.editor
dodil ignite app deploy crm-engine --code ./engine --runtime python --tier small \
--env DODIL_SERVICE_ACCOUNT_ID=$SA_UUID --env DODIL_SERVICE_ACCOUNT_SECRET=$SA_SECRETStep 7 — Wire up signups (a source-agnostic connector)
New users come from an upstream user system — your identity provider, product database, or auth
service. A small connector pulls {user_id, email, name} and hands them to the CRM, which upserts a
contact, a deal keyed signup:<user_id>, and an enrollment in the signup-triggered flow. Anything
that can emit a user_id + email plugs in.
# connector (essence) — read new users from your upstream system, push to the CRM. Source-agnostic.
for u in upstream.list_new_users(): # IdP / product DB / auth service -> {user_id, email, name}
crm.post("/signups", {"user_id": u.user_id, "email": u.email.lower(), "name": u.name})
# on the CRM side: upsert contact + deal, then enroll into the signup flow
def on_signup(u):
upsert("contacts", {"email": u["email"], "user_id": u["user_id"], "full_name": u["name"],
"org_domain": u["email"].split("@")[-1], "lifecycle_stage": "signup",
"source": "signup", "subscribed": True, "created_at": now()})
upsert("deals", {"deal_id": f"signup:{u['user_id']}", "contact_email": u["email"],
"pipeline": "onboarding", "stage": "new", "status": "open",
"value_num": 0.0, "created_at": now()})
enroll_by_trigger("signup", u["email"], f"signup:{u['user_id']}") # walk the onboarding flowAsk your agent to ingest one and watch the CRM fill:
In crm, ingest a signup for user_id u_8f21, [email protected], Jane Doe: upsert the contact (lifecycle_stage signup, subscribed true, org_domain acme.io), upsert a deal signup:u_8f21 (pipeline onboarding, stage new, status open, value_num 0), and enroll her in the active signup-triggered flow starting now.
data_table_upsertUpserted contact [email protected] (signup, acme.io) and deal signup:u_8f21 (onboarding/new/open). Enrolled her in 'onboarding' at position 1, next_run_at now — the engine sends the welcome email on its next tick and schedules step 2 for +3 days.
# contact + deal (idempotent on email / deal_id)
dodil data table upsert contacts -b "$BUCKET" \
--row '{"email":"[email protected]","user_id":"u_8f21","full_name":"Jane Doe","org_domain":"acme.io","lifecycle_stage":"signup","source":"signup","subscribed":true,"created_at":"2026-07-06T10:00:00Z"}'
dodil data table upsert deals -b "$BUCKET" \
--row '{"deal_id":"signup:u_8f21","title":"Signup — [email protected]","contact_email":"[email protected]","org_domain":"acme.io","pipeline":"onboarding","stage":"new","status":"open","value_num":0.0,"source":"signup","created_at":"2026-07-06T10:00:00Z"}'
# enroll into the onboarding flow at position 1, due now
dodil data table upsert flow_enrollments -b "$BUCKET" \
--row '{"enrollment_id":"enr-1","flow_id":"onboarding","contact_email":"[email protected]","deal_id":"signup:u_8f21","status":"active","current_position":1,"next_run_at":"2026-07-06T10:00:00Z","enrolled_at":"2026-07-06T10:00:00Z"}'Step 8 — Fill the pipeline: discover → qualify → approve → enrich → load
Outbound is the same store, run as a cost-gated pipeline. Discover companies cheaply, classify
them on Ignite Models — but only the bounded batch you ask for — review the tiers, and only then
spend credits to enrich decision-maker contacts. Approved leads land as contacts + deals and
auto-enroll in a sales sequence. (The classification mechanics are the whole
Leads Data Warehouse post — here it's the CRM's front door.)
discover → qualify (Ignite Models — GATE 1) → approve (strong only — GATE 2) → enrich → load
free classify just this batch a human tier check paid contacts+dealsThe classifier is the gate you can afford: qualify a batch of, say, 25 on kimi-k2.6, keep the strong
buyers, and only those cost credits to enrich.
Qualify this discovered company for our unified data backend on kimi-k2.6 — org: Greyparrot, greyparrot.ai, 'AI waste analytics; computer vision on sorting facilities.' Return JSON: tier (strong|possible|weak), score (0-1), reason.
ignite_models_chat{"tier":"possible","score":0.6,"reason":"AI vision needs vectors; waste taxonomy and tracking need graph/SQL."}
# GATE 1 — qualify a bounded batch on kimi-k2.6 (confirm availability with: dodil ignite models list)
dodil ignite models chat kimi-k2.6 \
--system 'Qualify a company as a sales lead for a unified data backend (SQL+vector+graph in one bucket). Return ONLY JSON: tier (strong|possible|weak), score (0-1), reason (<=15 words).' \
--message 'org: Greyparrot | domain: greyparrot.ai | AI waste analytics; computer vision on sorting facilities.'
# -> {"tier":"possible","score":0.6,"reason":"AI vision needs vectors; waste taxonomy and tracking need graph/SQL."}
# GATE 2 — approve only the strong tier, then enrich + load (this is where credits get spent)
dodil data sql -b "$BUCKET" "SELECT domain FROM organizations WHERE buyer_tier='strong'"Step 9 — Track opens, clicks & unsubscribes (public/private split)
Recipients hit three routes — an open pixel, a click redirect, and unsubscribe — and none
should sit next to your secrets. So they run as a separate, minimal Ignite app: no auth, the fewest
secrets possible, writing an email_events row and honoring opt-outs. The admin engine stays behind auth.
In crm, create an email_events table keyed on event_id with action_id, enrollment_id, contact_email, type, detail, ts.
data_table_createCreated email_events (key event_id) — the public tracking app writes an 'open' or 'click' row per hit; unsubscribe flips contacts.subscribed and logs an activity.
dodil data table create email_events -b "$BUCKET" --merge-key event_id \
--columns-json '[
{"name":"event_id","type":"string","nullable":false},
{"name":"action_id","type":"string","nullable":true},
{"name":"enrollment_id","type":"string","nullable":true},
{"name":"contact_email","type":"string","nullable":true},
{"name":"type","type":"string","nullable":true},
{"name":"detail","type":"string","nullable":true},
{"name":"ts","type":"string","nullable":true}
]'# public/handler.py (essence) — a tiny, auth-less tracking app. Its only secret is the unsubscribe signer.
@app.get("/track/open/{action_id}")
def open_pixel(action_id):
upsert("email_events", {"event_id": uuid(), "action_id": action_id, "type": "open", "ts": now()})
return PIXEL_GIF # 1x1 transparent gif
@app.get("/track/click/{action_id}")
def click(action_id, url):
upsert("email_events", {"event_id": uuid(), "action_id": action_id, "type": "click",
"detail": url, "ts": now()})
return redirect(url)
@app.get("/unsubscribe")
def unsubscribe(email, token):
verify_signed(email, token) # the ONLY secret this app carries
upsert("contacts", {"email": email, "subscribed": False, "updated_at": now()})
upsert("activities", {"activity_id": uuid(), "contact_email": email, "kind": "unsubscribe",
"direction": "inbound", "status": "done", "ts": now()})
return "You're unsubscribed."Deploy my public tracking app to Ignite as crm-public, internet-facing, with the minimum secrets.
ignite_app_deployDeployed crm-public (public) — it serves the open pixel, the click redirect, and unsubscribe, writing email_events and flipping contacts.subscribed. It carries only DataK3 access + the unsubscribe signing key; every other secret stays in crm-engine.
dodil ignite app deploy crm-public --code ./public --runtime python --tier small --public \
--env DODIL_SERVICE_ACCOUNT_ID=$SA_UUID --env DODIL_SERVICE_ACCOUNT_SECRET=$SA_SECRETQuery it — one bucket, three pillars, drop-in clients
Now it's a CRM you query — by content (SQL), by relationship (graph), and by meaning (vector) — over
one bucket. And any Postgres/pgvector or Bolt/Neo4j client points straight at the bucket:
data connect prints the endpoints (DB name = bucket, credential = your login token), so psql,
cypher-shell, a BI tool, or a pgvector library reads the same rows with no export.
In crm, show me the signup funnel by lifecycle_stage, the open-deals pipeline board by pipeline and stage, and print the endpoints to point psql / cypher-shell at the crm bucket.
data_sql→data_connectFunnel: lead 3, signup 1. Pipeline board: onboarding/new 1 deal $0; sales/demo 2 deals $54,000; sales/qualified 1 deal $12,000. data connect printed pg pg.uk-lon-1.dodil.io:5432/crm and bolt bolt+s://bolt.uk-lon-1.dodil.io:7687 — same bucket, drop-in clients.
# signup funnel by lifecycle stage
dodil data sql -b "$BUCKET" \
"SELECT lifecycle_stage, count(*) AS n FROM contacts GROUP BY lifecycle_stage ORDER BY n DESC"
# lead 3 | signup 1
# pipeline board — open deals by pipeline & stage
dodil data sql -b "$BUCKET" \
"SELECT pipeline, stage, count(*) AS deals, round(sum(value_num),2) AS value
FROM deals WHERE status='open' GROUP BY pipeline, stage ORDER BY pipeline, stage"
# onboarding/new 1 $0 | sales/demo 2 $54000 | sales/qualified 1 $12000
# drop-in clients: same bucket, your own psql / cypher-shell / pgvector driver
dodil data connect "$BUCKET" -o psql
# postgresql://token:[email protected]:5432/crm?sslmode=require -> paste into psql
# bolt: bolt+s://bolt.uk-lon-1.dodil.io:7687 (cypher-shell -a … over the crm_graph)DataK3 vs. the multi-system stack
This CRM would normally be four systems and the glue between them. On DataK3 it's one bucket, one bill, one auth context — and the pillars share one copy of the rows.
| Job | The usual stack | On DataK3 |
|---|---|---|
| Contacts / deals / campaigns | Postgres (or a SaaS CRM seat) | SQL tables in the bucket |
| Roll up a corporate family's pipeline | Neo4j + a sync job | crm_graph — graph_khop JOINed to deals |
| "Which deals mention data residency?" | Pinecone + an embedding pipeline | VECTOR(2048) column + data vsearch |
| Analytics / BI | A warehouse + nightly ETL | data sql over the live rows |
| Point your own tools at it | Per-system drivers & creds | data connect — psql / bolt / pgvector, DB = bucket |
No ETL, no second copy, no drift between the record and the search index — a signup joins to its account
graph and its lead data on org_domain for free, because it's all one bucket.
The components teach; the suite ships
This post is one build, told end to end. The CRM is also published as seven component packages — each
its own tutorial, each a standalone FastAPI + SQLAlchemy package you can read and run:
crm/core · crm/account-360 ·
crm/lead-to-opportunity ·
crm/qualification-scoring ·
crm/campaign-to-lead ·
crm/pipeline-forecast · crm/quote-cpq.
Read those for the why — the modelling decision each part encodes, and the reasoning you need in order
to derive a different customer's version of it.
But the deployable artifact is the suite. code/crm-suite-app is the whole
CRM as one app: a single FastAPI with a router per component, one canonical models.py, plain
imports (no importlib loader), a web/ front-end, over one bucket and one dodil-appid pool — so seven
components mean one sign-in and one bill. That is what you fetch to stamp a customer system; the component
posts are what you read to know how to change it. It ships by the ordinary git cycle (repo → CI → registry →
CD): Ship a DODIL app. The tar also carries PLATFORM.md — the
platform invariants, so whoever downloads it gets the rules along with the code.
Test
Every command below ran live against DataK3 on 2026-09-01 (bucket blog-crm, org IHDIASH); the
Ignite deploys in Steps 6, 7, and 9 are shown-as-code. Real results are inline.
# core rows land
dodil data sql -b "$BUCKET" "SELECT count(*) AS n FROM contacts" # n = 4
dodil data sql -b "$BUCKET" "SELECT count(*) AS n FROM deals" # n = 4
# graph traversal rolls up the Acme corporate family
dodil data pg -b "$BUCKET" "
SELECT count(DISTINCT d.deal_id) AS deals, coalesce(sum(d.value_num),0) AS pipeline
FROM graph_khop('crm_graph', 1, 2, 'in') g
JOIN crm_node n ON n.id = g.node AND n.kind='account'
JOIN deals d ON d.org_domain = n.biz_key AND d.status='open'" # deals = 2, pipeline = 36000
# vector search surfaces the compliance-sensitive deal first
dodil data vsearch -b "$BUCKET" -t activity_vectors --column embedding \
--text "data residency and regulatory compliance requirements" \
--model jina-embeddings-v4 --metric cosine --top-k 1 # act-3 (Carol / GDPR), 0.36
# the Models gate returns a JSON verdict
dodil ignite models chat kimi-k2.6 \
--system 'Return ONLY JSON: tier, score, reason.' \
--message 'org: Greyparrot | greyparrot.ai | AI waste analytics.' # {"tier":"possible","score":0.6,…}One-shot: build it by prompting your agent
With the DODIL MCP connected, paste this to scaffold the whole CRM at once:
Build a CRM on DataK3 (one bucket = SQL + graph + vector). Confirm each step as you go.
1. Create a DataK3 bucket `crm`, then merge-keyed tables:
- contacts (key email): user_id, full_name, org_domain, lifecycle_stage, source, subscribed(boolean),
created_at, updated_at.
- deals (key deal_id): title, contact_email, org_domain, pipeline, stage, status, value_num(double),
owner, source, created_at.
- activities (key activity_id): deal_id, contact_email, kind, subject, body, direction, status, ts.
- accounts (key org_domain): name, parent_domain, tier, country.
- flows, flow_steps, flow_enrollments, flow_actions, email_events (as in the post).
2. Project accounts + contacts into crm_node (BIGINT KEY) + crm_edge (src, dst, rel), populate
subsidiary_of + works_at edges, then CREATE GRAPH crm_graph. Roll up open pipeline for Acme Corp's
whole family with graph_khop(...,'in') JOINed to deals.
3. Add activity_vectors (VECTOR(2048)); embed a few notes with jina-embeddings-v4 (one row per upsert);
vsearch "data residency" and JOIN the nearest note to its deal.
4. Author an `onboarding` flow (trigger=signup, active) with three email steps (delays 0 / 3d / 7d).
5. Deploy an Ignite app `crm-engine` (own service account, k3.editor): a tick loop over due enrollments
that sends the current flow_step (skip if contacts.subscribed is false) and advances position.
6. Deploy a SECOND public Ignite app `crm-public` (only /track/open, /track/click, /unsubscribe).
7. Ingest a signup {u_8f21, [email protected], Jane Doe}; show the funnel + open-deals board.Ship it — crm-public as a public service (git → CI → registry → deploy)
The crm-public tracking app above is the code; here it becomes the public endpoint that actually serves
open/click/unsubscribe links — shipped through the real DODIL supply chain, no external CI or PaaS. (The
whole lifecycle, including versioning and rollback, is validated end to end in
Ship a DODIL App; this is that pipeline for crm-public.)
1 — Source in DODIL git. Create the repo, mint a git.editor key, push the app (handler.py +
Dockerfile + requirements.txt):
Create a DODIL git repo crm-public, mint a git push key, and push the crm-public app to it.
git_repo_create→auth_apikey_issue→git_clone-urlRepo crm-public created at git.dodil.io/$ORG/crm-public.git; pushed main.
dodil git repo create crm-public
DK=$(dodil auth apikey issue --service git --role git.editor --name crm-public-push -o json | jq -r '.secret // .key')
git init && git add . && git commit -m "ship crm-public"
git push "https://x:$DK@git.dodil.io/$ORG/crm-public.git" main2 — CI → a scanned image in the DODIL registry.
Build the crm-public repo into the DODIL registry and scan it.
ignite_build_create→registry_vulnBuilt registry.dodil.io/$ORG/crm-public:v1; scan queued — re-check registry vuln for the CVE totals.
dodil ignite build create crm-public --git-url "https://git.dodil.io/$ORG/crm-public.git" \
--tag "registry.dodil.io/$ORG/crm-public:v1"
dodil registry vuln crm-public v13 — Deploy public and curl it. Build-on-deploy needs no registry pull secret:
Deploy crm-public from the repo as a public app and curl its health path with no token.
ignite_app_deploy→ignite_app_getDeployed crm-public; public FQDN on ignite.dodil.cloud. curl /healthz returns 200 with no token — open/click/unsubscribe links now resolve for real recipients.
dodil ignite app deploy crm-public --git-url "https://git.dodil.io/$ORG/crm-public.git" \
--dockerfile-path Dockerfile --allow-unauthenticated --port 8080 --health-path /healthz
BASE=$(dodil ignite app get crm-public --output json | jq -r '.public_urls[0]')
curl -s "https://$BASE/healthz" # 200, no tokenRoll out a v2 and roll back with dodil ignite version rollback crm-public 1 — the full versioning
walk-through is in Ship a DODIL App.
Connect your tools
Everything this build wrote lives in one DataK3 bucket, reachable by your own stack — not just the CLI. data connect crm prints the endpoints; point your tools straight at the same rows:
- SQL over Postgres wire —
psql,sqlx/diesel(Rust),psycopg/asyncpg(Python),node-postgres(TS). - Graph over Bolt — a Neo4j driver or
cypher-shellagainstcrm_graph. - Vector — pgvector (
<=>) over the same wire, or a Qdrant/Pinecone client against the sameactivity_vectorsrows.
Full, live-validated walkthrough: Connect your tools.
Conclusion
A go-to-market CRM that's just one DataK3 bucket + two Ignite apps — contacts and deals you upsert, an account graph that rolls up pipeline by corporate family, notes you search by meaning, campaigns you edit as rows, a scale-to-zero engine that walks them, and a cost-gated pipeline on Ignite Models. One bucket, one bill, three pillars over one copy of the rows. We built ours in a session with an agent; you just built the core.
You can do this today: Create a free account, install the CLI, connect your agent over MCP in under two minutes, and ship a real workload this afternoon.
Next steps:
- Leads Data Warehouse — the pipeline behind the pipeline.
- Inside the DODIL AI Cloud — one bucket, three planes.