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_steps row 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 data CLI.

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.

PieceLands inPillar / runs on
Contacts / deals / activitiestables (contacts, deals, activities)SQL
Accounts + hierarchytables (accounts) + graph (crm_node / crm_edge)Graph
Semantic search over notestable activity_vectors (VECTOR(2048))Vector
Sequences as datatables (flows, flow_steps, flow_enrollments, flow_actions)SQL
The tick enginewrites sends → activitiesIgnite app (private, scale-to-zero)
Lead pipelineorganizationscontacts / dealsIgnite Models (the cost gate)
Open / click / unsubscribetable (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 dodil CLI 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).

You

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.

DODIL MCP tools called
data_bucket_createdata_table_create
Agent

Created 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.

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.

You

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).

DODIL MCP tools called
data_table_createdata_table_upsert
Agent

Created 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).

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.

You

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.

DODIL MCP tools called
data_pg
Agent

crm_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.

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).

You

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.

DODIL MCP tools called
data_boltdata_pg
Agent

Family = 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.

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.

You

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.

DODIL MCP tools called
data_table_createignite_models_embeddata_table_upsert
Agent

Created 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.

Now search by meaning — and JOIN the nearest note straight to its deal:

You

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.

DODIL MCP tools called
data_vsearchdata_pg
Agent

Nearest 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.

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.

You

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).

DODIL MCP tools called
data_table_create
Agent

Created 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.

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.

You

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.

DODIL MCP tools called
data_table_upsert
Agent

Inserted 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.

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:

You

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.

DODIL MCP tools called
auth_service-account_createauth_service-account_grant-roleignite_app_deploy
Agent

Created 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.

Step 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 flow

Ask your agent to ingest one and watch the CRM fill:

You

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.

DODIL MCP tools called
data_table_upsert
Agent

Upserted 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.

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+deals

The 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.

You

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.

DODIL MCP tools called
ignite_models_chat
Agent

{"tier":"possible","score":0.6,"reason":"AI vision needs vectors; waste taxonomy and tracking need graph/SQL."}

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.

You

In crm, create an email_events table keyed on event_id with action_id, enrollment_id, contact_email, type, detail, ts.

DODIL MCP tools called
data_table_create
Agent

Created 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.

# 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."
You

Deploy my public tracking app to Ignite as crm-public, internet-facing, with the minimum secrets.

DODIL MCP tools called
ignite_app_deploy
Agent

Deployed 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.

Query 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.

You

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.

DODIL MCP tools called
data_sqldata_connect
Agent

Funnel: 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.

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.

JobThe usual stackOn DataK3
Contacts / deals / campaignsPostgres (or a SaaS CRM seat)SQL tables in the bucket
Roll up a corporate family's pipelineNeo4j + a sync jobcrm_graphgraph_khop JOINed to deals
"Which deals mention data residency?"Pinecone + an embedding pipelineVECTOR(2048) column + data vsearch
Analytics / BIA warehouse + nightly ETLdata sql over the live rows
Point your own tools at itPer-system drivers & credsdata 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):

You

Create a DODIL git repo crm-public, mint a git push key, and push the crm-public app to it.

DODIL MCP tools called
git_repo_createauth_apikey_issuegit_clone-url
Agent

Repo crm-public created at git.dodil.io/$ORG/crm-public.git; pushed main.

2 — CI → a scanned image in the DODIL registry.

You

Build the crm-public repo into the DODIL registry and scan it.

DODIL MCP tools called
ignite_build_createregistry_vuln
Agent

Built registry.dodil.io/$ORG/crm-public:v1; scan queued — re-check registry vuln for the CVE totals.

3 — Deploy public and curl it. Build-on-deploy needs no registry pull secret:

You

Deploy crm-public from the repo as a public app and curl its health path with no token.

DODIL MCP tools called
ignite_app_deployignite_app_get
Agent

Deployed crm-public; public FQDN on ignite.dodil.cloud. curl /healthz returns 200 with no token — open/click/unsubscribe links now resolve for real recipients.

Roll 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-shell against crm_graph.
  • Vector — pgvector (<=>) over the same wire, or a Qdrant/Pinecone client against the same activity_vectors rows.

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: