What you'll build: a ServiceNow-style ITSM on one DataK3 bucket — the incident/change/problem tables (SQL), the CMDB dependency graph that turns a failing database into its full blast radius in one hop-ranked query, a similar-incident vector search ("have we seen this before?"), and an Ignite auto-triage engine that fuses all three and writes category + priority + assignment_group back to the incident — with a model as the classification gate.

The problem — and why it matters

An ITSM suite is priced per agent seat — a mid-size IT org runs 300–2,000 fulfillers at roughly $100–$150 each per month, so the platform bill lands in the seven figures a year before a single custom workflow. And that price buys a system that is really four systems stitched together: the ticket database (Postgres), a KB / semantic search index (Pinecone), the CMDB relationship store (Neo4j — every app→service→host→db dependency), and a reporting warehouse the whole thing is ETL'd into overnight. Four engines, four bills, four copies of the same rows, and glue code to keep them agreeing.

The whole point of a CMDB is the question "if orders-db falls over, what breaks?" — and answering it means a graph traversal the ticket DB can't do, over data the warehouse only has at yesterday's freshness. So the graph lives in Neo4j, the tickets in Postgres, and correlating them is a nightly job.

What collapses onto one bucket: the incidents/changes/problems tables, the CMDB graph, and the KB + similar-incident vectors are the same rows under three query pillars — SQL, Graph (Cypher/Bolt + graph_*()), and Vector (pgvector). No ETL, no second copy, no nightly sync, one auth context. The payoff for the team is MTTR and deflection: the on-call opens a new incident and instantly sees the fleet-wide blast radius, the three past incidents that look identical (and how they were fixed), and a model-graded P1/database verdict routed to the right group — instead of hand-triaging a queue.

What you'll build: a one-bucket blog-itsm system — the transactional tables (SQL), the CMDB dependency graph (Graph), similar-incident search (Vector), an Ignite triage-engine app, and a kimi-k2.6 classification gate — queried by content, by meaning, and by relationship over one copy of the rows.

Prerequisites

  • The dodil CLI (dodil auth login) and an agent connected over MCP (Claude Code, Cursor, VS Code, Codex). Every step shows an Ask your agent tab and a CLI tab.
  • export BUCKET=blog-itsm — one bucket is the whole system's data plane.

Step 1 — Stand up the incident core (SQL)

Create the bucket, then the merge-keyed transactional tables. A PRIMARY KEY (merge-key) is required — writes are keyed, so re-runs and shard retries upsert idempotently, exactly what you want for a ticket system that a dozen workers touch at once.

You

Create a DataK3 bucket called blog-itsm, then merge-keyed tables: cis (configuration items), incidents, changes, problems.

DODIL MCP tools called
data_bucket_createdata_table_create
Agent

Created bucket blog-itsm (status ACTIVE). Tables cis, incidents, changes, problems created, each with PRIMARY KEY (id).

Load the CMDB inventory — ten CIs spanning the four tiers app → service → host → database. These rows are the graph's nodes in Step 2 and the ci_id foreign key every incident points at.

You

Upsert 10 configuration items into cis: web-storefront (app), checkout-service/orders-api/payments-gateway/auth-service/redis-cache (services), app-host-01/02 (hosts), orders-db/payments-db (databases).

DODIL MCP tools called
data_table_upsert
Agent

Upserted 10 CIs (wal_written: true).

Now the incidents themselves — five resolved history rows (with their fixes) plus one fresh new incident. Each carries a VECTOR(2048) embedding of its description; you produce that with the jina-embeddings-v4 model and write the [f1,f2,…] literal. Because embeddings are large, upsert one row per call (a batched frame carrying six 2048-dim vectors exceeds the gateway's first-frame size).

You

For each incident, embed its description with jina-embeddings-v4, then upsert the row (id, short_description, description, ci_id, state, priority, category, assignment_group, opened_at, resolved_at, resolution, embedding). Include all columns — a full-row upsert that omits a declared column will not persist.

DODIL MCP tools called
ignite_models_embeddata_table_upsert
Agent

Upserted 6 incidents (1001–1005 resolved, 1006 new); each embedding is a 2048-dim vector literal.

Step 2 — The CMDB dependency graph (blast radius)

This is the pillar an ITSM lives or dies on. A graph is table-backed: the cis table is the node set (KEY id), a depends_on edge table holds src → dst (a CI depends on another CI), and CREATE GRAPH binds them. data pg owns the DDL.

You

In blog-itsm, create edge table depends_on(src,dst), insert the app→service→host→db dependencies, and CREATE GRAPH cmdb over node table cis (KEY id) and edges depends_on (SRC src DST dst).

DODIL MCP tools called
data_pg
Agent

Graph cmdb created over cis / depends_on (10 nodes, 10 edges).

graph_neighbors('cmdb', 1) walks the edges forward — the direct dependencies of a CI. From web-storefront (1) that's auth-service (5) and checkout-service (2):

You

What does web-storefront (CI 1) directly depend on? Use graph_neighbors on cmdb.

DODIL MCP tools called
data_pg
Agent

neighbor: 5 (auth-service), 2 (checkout-service).

But blast radius is the reverse questionif this CI fails, who is impacted? — i.e. every CI that transitively depends on the failing one. Build a reverse impacts edge table (just the depends_on rows flipped) and a cmdb_impact graph over it; now graph_khop(...) from the failing CI returns the whole impacted set with hop distance, and you hydrate names by joining cis in the same statement.

You

Build the reverse impact graph: create impacts(src,dst) as the flip of depends_on, then CREATE GRAPH cmdb_impact over cis / impacts. Then give me the full blast radius of orders-db (CI 8), hop-ranked and hydrated with CI names.

DODIL MCP tools called
data_pg
Agent

Blast radius of orders-db → hop 1: orders-api, hop 2: checkout-service, hop 3: web-storefront.

Real output — a failing orders-db takes down three tiers, and you know it before a customer does:

 hop_distance  name              ci_type   owner_group
 1             orders-api        service   orders-eng
 2             checkout-service  service   payments-eng
 3             web-storefront    app       retail-web

The same traversal in Cypher over Bolt — return the node variable (the graph plane hands back node keys; join cis for properties):

You

Same blast radius in Cypher over Bolt: from orders-db, follow impacts up to 5 hops and return the impacted nodes.

DODIL MCP tools called
data_bolt
Agent

Returns nodes 3, 2, 1 with hop_distance 1, 2, 3.

Step 3 — Similar-incident search (Vector)

The incidents.embedding column you populated in Step 1 is the semantic index — no Pinecone, no second copy. Ask the fleet-tested question "have we seen this before?": embed the new incident's symptoms and KNN-search the resolved history. data vsearch --text embeds the query client-side (pass the same model the column was built with) and returns the nearest rows by cosine distance.

You

In blog-itsm, find the incidents most similar to 'orders-db connections maxed out, connection timeouts under load' — vector search the incidents.embedding column with jina-embeddings-v4, cosine, top 3.

DODIL MCP tools called
data_vsearch
Agent

Nearest: 1006 (self, 0.078), 1001 (0.149, orders-db pool exhausted), 1005 (0.242, orders-db disk full).

The two nearest resolved incidents are the other two orders-db outages — exactly the precedents an on-call wants. The same KNN in SQL, which is what the engine runs so it can filter to state='resolved' and pull the historic fix and owning team in one shot (pgvector's <=> with a query-vector literal):

You

Show the 3 nearest RESOLVED incidents to the new incident's embedding, with their assignment_group and resolution — SQL pgvector KNN.

DODIL MCP tools called
data_sql
Agent

1001 (orders-eng, added PgBouncer), 1005 (dba, extended volume), 1003 (payments-eng).

The <=> operator needs a vector literal on the right-hand side — embed the query text and interpolate the […] string. (A correlated sub-SELECT of another row's vector does not drive the KNN; pass the literal.)

Step 4 — The auto-triage engine (Ignite + a real handler)

An Ignite app is a separate workload — it needs its own service account to call DataK3 and Models (client-credentials → bearer). Grant least-privilege roles, inject the creds as runtime env, deploy, then invoke. The handler fuses all three pillars over one Postgres-wire connection to the bucket (SQL + pgvector <=> + graph_khop() are all just SQL there) and writes the verdict back.

You

Create a service account triage-engine-sa, grant it ignite.model-user (invoke models) and k3.editor (read/write the bucket), deploy ./triage-engine as a python app with the SA creds as runtime env, then invoke it for incident 1006.

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

Deployed triage-engine (deployment_state: deployed); invoke returned category=database, priority=P1, assignment_group=orders-eng.

The handler — real, runnable code. ./triage-engine/handler.py:

 
 
TOKEN_URL   = "https://id.dodil.io/realms/dodil/protocol/openid-connect/token"
MODELS_BASE = "https://api.dodil.io/v1"          # OpenAI-compatible
PG_HOST, PG_PORT = "pg.uk-lon-1.dodil.io", 5432
BUCKET      = os.environ.get("DODIL_BUCKET", "blog-itsm")
EMBED_MODEL = "jina-embeddings-v4"               # matches the VECTOR(2048) column
CHAT_MODEL  = "kimi-k2.6"
 
def _token():
    r = requests.post(TOKEN_URL, timeout=30, data={
        "grant_type": "client_credentials",
        "client_id": os.environ["DODIL_SERVICE_ACCOUNT_ID"],
        "client_secret": os.environ["DODIL_SERVICE_ACCOUNT_SECRET"]})
    r.raise_for_status()
    return r.json()["access_token"]
 
def _embed(tok, text):
    r = requests.post(f"{MODELS_BASE}/embeddings", timeout=120,
        headers={"Authorization": f"Bearer {tok}"},
        json={"model": EMBED_MODEL, "input": text})
    r.raise_for_status()
    vec = r.json()["data"][0]["embedding"]
    return "[" + ",".join(repr(round(x, 6)) for x in vec) + "]"   # pgvector literal
 
def _classify(tok, desc, ci_name, blast):
    system = ('You are an ITSM triage assistant. Reply with ONLY compact JSON: '
              '{"category": one of [database, network, performance, availability, '
              'security], "priority": one of [P1, P2, P3]}. No prose.')
    user = f'Incident: "{desc}". Affected CI: {ci_name}. Blast radius: {", ".join(blast)}.'
    r = requests.post(f"{MODELS_BASE}/chat/completions", timeout=120,
        headers={"Authorization": f"Bearer {tok}"},
        json={"model": CHAT_MODEL, "messages": [
            {"role": "system", "content": system},
            {"role": "user", "content": user}]})
    r.raise_for_status()
    c = r.json()["choices"][0]["message"]["content"].strip().strip("`")
    if c.startswith("json"): c = c[4:].strip()
    return json.loads(c)
 
def handler(payload, ctx):
    tok = _token()
    inc_id = int(payload["incident_id"])
    conn = psycopg.connect(host=PG_HOST, port=PG_PORT, dbname=BUCKET,
                           user="token", password=tok, sslmode="require")
    with conn, conn.cursor() as cur:
        cur.execute("""SELECT i.description, i.ci_id, c.name
                         FROM incidents i JOIN cis c ON c.id = i.ci_id
                        WHERE i.id = %s""", (inc_id,))
        description, ci_id, ci_name = cur.fetchone()
 
        # 1. VECTOR — nearest resolved incidents
        qvec = _embed(tok, description)
        cur.execute(f"""SELECT id, assignment_group, resolution FROM incidents
                         WHERE state='resolved' ORDER BY embedding <=> '{qvec}' LIMIT 3""")
        neighbors = cur.fetchall()
 
        # 2. GRAPH — blast radius of the affected CI
        cur.execute("""SELECT c.name FROM graph_khop('cmdb_impact', %s, 5) k
                         JOIN cis c ON c.id = k.node ORDER BY k.hop_distance""", (ci_id,))
        blast = [r[0] for r in cur.fetchall()]
 
        # 3. MODEL — classify; route to the team that fixed the nearest precedent
        verdict = _classify(tok, description, ci_name, blast)
        assignment_group = neighbors[0][1] if neighbors else None
 
        # 4. WRITE BACK — same bucket, same connection
        cur.execute("""UPDATE incidents SET state='triaged', category=%s,
                          priority=%s, assignment_group=%s WHERE id=%s""",
                    (verdict["category"], verdict["priority"], assignment_group, inc_id))
    return {"incident_id": inc_id, **verdict,
            "assignment_group": assignment_group,
            "similar_incidents": [n[0] for n in neighbors], "blast_radius": blast}

requirements.txt is just requests and psycopg[binary]. Note the one connection, three pillars: the same psycopg cursor runs the pgvector KNN, the graph_khop() traversal, and the write-back — no second driver, no second store.

Step 5 — The classification gate (Models)

The gate is why this is cheap at fleet scale: a low-cost model reads the free-text symptom and returns a structured category + priority, so the queue self-sorts and analysts spend their hours on the P1s. Here is the real call the handler makes, and the real reply:

You

Classify this incident with kimi-k2.6 and return only JSON {category, priority, assignment_group, reason}: 'orders-db connections maxed out, orders-api throwing connection timeout errors under peak checkout load' — affected CI orders-db, blast radius orders-api/checkout-service/web-storefront.

DODIL MCP tools called
ignite_models_chat
Agent

{"category":"database","priority":"P1","assignment_group":"DBA","reason":"Prod DB connection pool exhausted under peak load, causing checkout timeouts"}

The verdict lands back in the incident's SQL columns — category='database', priority='P1', and assignment_group='orders-eng' (the team that resolved the nearest past incident, INC1001), with state advanced new → triaged:

You

Write the triage verdict back to incident 1006: state=triaged, category=database, priority=P1, assignment_group=orders-eng. Then show the row.

DODIL MCP tools called
data_table_updatedata_sql
Agent

rows_affected: 1. Row 1006 → triaged / database / P1 / orders-eng.

Query it — one bucket, three pillars, drop-in clients

The same rows answer by content (SQL), by meaning (vector), and by relationship (graph) — no ETL, no second copy. Join an incident to its affected CI, and the lifecycle report is a plain GROUP BY:

You

Join incidents to their affected CI (name + type), and give me the incident count by lifecycle state.

DODIL MCP tools called
data_sql
Agent

6 incidents (5 resolved, 1 triaged); each joined to its affected CI.

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). Point psql, cypher-shell, Grafana, or a BI tool at these with zero export:

You

Print the drop-in pg / bolt / grpc endpoints for blog-itsm so I can point psql and cypher-shell at it.

DODIL MCP tools called
data_connect
Agent

pg postgresql://token:…@pg.uk-lon-1.dodil.io:5432/blog-itsm · bolt bolt+s://bolt.uk-lon-1.dodil.io:7687 (database=blog-itsm) · grpc table-rpc.uk-lon-1.dodil.io:443

DataK3 vs. the multi-system ITSM stack

ITSM concernClassic stackOn DataK3
Incident / change / problem tablesPostgres (ticket DB)SQL pillar — incidents, changes, problems (merge-keyed)
CMDB dependency graph + blast radiusNeo4jGraph pillar — graph_khop('cmdb_impact', …), Cypher over Bolt
KB / similar-incident searchPinecone / ElasticsearchVector pillar — VECTOR(2048) column, pgvector <=>
Cross-pillar reportingNightly ETL → warehouseOne JOIN — same rows, read-your-writes, no ETL
Automation runtimeWorkflow engine + connectorsIgnite handler.py, one bucket, one auth context

One bucket, one bill, one auth context — and the blast-radius traversal and the tickets it correlates are the same live rows, not last night's snapshot in a separate graph DB.

Test

Run against live DataK3, then the assertions below hold. What ran live for this post (tested_at: 2026-09-01, bucket blog-itsm, org IHDIASH): the bucket + all four SQL tables; both CMDB graphs; all 6 incident embeddings (jina-embeddings-v4, dim 2048); the graph_neighbors / graph_khop / Bolt traversals; the vsearch and pgvector <=> KNN; the kimi-k2.6 classification call; and the data table update write-back. The handler.py is real and byte-for-byte the triage pipeline that was executed live query-by-query; the ignite app deploy / invoke wrapper is shown as code.

# CMDB blast radius of orders-db — expect 3 rows: orders-api(1), checkout-service(2), web-storefront(3)
dodil data pg -b "$BUCKET" \
  "SELECT k.hop_distance, c.name FROM graph_khop('cmdb_impact', 8, 5) k
     JOIN cis c ON c.id = k.node ORDER BY k.hop_distance"
 
# Similar-incident search — expect the two other orders-db incidents (1001, 1005) as nearest resolved
dodil data vsearch -b "$BUCKET" -t incidents --column embedding \
  --text "orders-db connections maxed out, connection timeouts under load" \
  --model jina-embeddings-v4 --metric cosine --top-k 3
 
# The triaged incident — expect: triaged | database | P1 | orders-eng
dodil data sql -b "$BUCKET" \
  "SELECT state, category, priority, assignment_group FROM incidents WHERE id=1006"
 
# Lifecycle rollup — expect resolved=5, triaged=1
dodil data sql -b "$BUCKET" "SELECT state, count(*) AS n FROM incidents GROUP BY state"

One-shot

Build a ServiceNow-style ITSM on one DataK3 bucket named blog-itsm.
1. Create the bucket and merge-keyed tables: cis(id,name,ci_type,environment,owner_group,status);
   incidents(id,short_description,description,ci_id,state,priority,category,assignment_group,
   opened_at,resolved_at,resolution,embedding VECTOR(2048)); changes(...); problems(...).
2. Upsert 10 CIs across app→service→host→db, and 6 incidents (5 resolved with resolutions, 1 new).
   Embed each incident description with jina-embeddings-v4 and store the [ … ] literal in embedding;
   upsert one row per call (large vectors).
3. Build the CMDB graph: depends_on(src,dst) edges (app→service→host→db) + CREATE GRAPH cmdb over cis;
   then the reverse impacts(src,dst) + CREATE GRAPH cmdb_impact. Blast radius of a failing CI =
   graph_khop('cmdb_impact', <ci_id>, 5) joined to cis, hop-ranked.
4. Similar-incident search: vsearch / pgvector `embedding <=> '<qvec>'` over state='resolved'.
5. Deploy an Ignite triage-engine (own service account: ignite.model-user + k3.editor) whose handler.py
   embeds the new incident, KNN-searches resolved history, computes the blast radius, classifies with
   kimi-k2.6, and writes state=triaged + category + priority + assignment_group back to the incident.
6. Verify: blast radius of orders-db = orders-api/checkout-service/web-storefront; incident 1006 →
   triaged/database/P1/orders-eng.

Ship it — make triage-engine a public endpoint

The triage-engine app above is shown as code. Ship it the last mile so users hit a real URL, not a snippet — the managed runtime compiles your handler.py and returns a public FQDN:

You

Deploy the triage-engine app publicly with no auth and give me its URL.

DODIL MCP tools called
ignite_app_deployignite_app_get
Agent

Deployed triage-engine (deployment_state: deployed). Its public FQDN on ignite.dodil.cloud is callable with no token — or call it with dodil ignite invoke triage-engine.

That's the quick path (managed compile). The full supply chain — DODIL git → CI checks → a scanned image in the DODIL registry → versioning and one-command rollback — is its own tutorial: Ship a DODIL App.

Conclusion

You now have a one-bucket ITSM — the incident/change/problem SQL core, the CMDB dependency graph that turns a failing orders-db into its blast radius in one hop-ranked query, similar-incident vector search over the same rows, and an Ignite engine that auto-triages a new incident and writes the verdict back — with a model as the classification gate. No Postgres + Pinecone + Neo4j + warehouse + the ETL between them: one copy of the rows, three query pillars, one bill.

The reusable skill: model a relationship-heavy enterprise system as one DataK3 bucket and traverse blast-radius over the graph pillar while the SQL and vector pillars share the same rows. See also the sibling builds a cost-gated CRM lead pipeline and an AI-powered SIEM for threat hunting — same one-bucket, three-pillar pattern, different domain.