What you'll build: a Zendesk-style helpdesk on one DataK3 bucket — the ticket/KB/macro/agent/
customer tables (SQL) with the full lifecycle and CSAT, a KB + similar-ticket vector search that
deflects a new ticket to the right article and surfaces the prior solved tickets that already answer it,
a customer/account graph that rolls every ticket in an account family into one traversal (and finds
the noisiest account), and an Ignite auto-triage engine that drafts a suggested reply and writes
suggested_kb_id + draft_reply back to the ticket — with a model as the category/priority gate.
The problem — and why it matters
A helpdesk suite is priced per agent seat — Zendesk's Suite plans run roughly $55–$115 per agent per month, so a 200-agent support org is paying $1.3M–$2.8M a year before a single automation. And that seat buys a system that is really four systems bolted together: the ticket database (Postgres — the lifecycle of record), a KB + ticket semantic search index (Pinecone — "which article answers this?"), an account-relationship store (who belongs to which account, which accounts are parent/child), and a reporting warehouse the whole thing is ETL'd into overnight for CSAT and deflection dashboards. Four engines, four bills, four copies of the same rows, and glue code to keep them agreeing.
The metric that pays for all of this is deflection — the fraction of tickets an article or a prior answer resolves before an agent touches them — and its sibling, agent hours saved on the tickets that do land. Both depend on the same question: "have we answered this before, and where?" Answering it means a vector search over KB and ticket history that the ticket DB can't do, correlated with an account graph the warehouse only has at yesterday's freshness. So the vectors live in Pinecone, the tickets in Postgres, the account tree in a third store, and correlating them is a nightly job.
What collapses onto one bucket: the tickets, the KB/macro/ticket embeddings, and the customer/account
graph are the same rows under three query pillars — SQL, Vector (pgvector), and Graph (Cypher/Bolt +
graph_*()). No ETL, no second copy, no nightly sync, one auth context. The payoff for the team: an
incoming ticket instantly shows the KB article that answers it, the two past tickets that look identical
(and how they were solved), a model-graded account / high verdict, and a drafted reply the agent
can send in one click — so first-response time collapses and the queue self-deflects.
What you'll build: a one-bucket blog-helpdesk system — the transactional tables (SQL), KB +
similar-ticket search (Vector), the customer/account graph (Graph), 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
dodilCLI (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-helpdesk— one bucket is the whole system's data plane.
Step 1 — Stand up the ticket 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 the retries of a dozen agents touching the same queue
upsert idempotently instead of duplicating.
Create a DataK3 bucket called blog-helpdesk, then merge-keyed tables: customers, agents, tickets, kb_articles, macros. tickets and the KB/macros carry a VECTOR(2048) embedding column.
data_bucket_create→data_table_createCreated bucket blog-helpdesk. Tables customers, agents, tickets, kb_articles, macros created, each with PRIMARY KEY (id).
dodil data bucket create "$BUCKET" --description "Helpdesk system of record — tickets, KB, accounts"
# customers point at an account_id (the graph's account nodes in Step 3)
dodil data table create customers -b "$BUCKET" --merge-key id \
--columns-json '[{"name":"id","type":"bigint"},{"name":"name","type":"varchar"},{"name":"email","type":"varchar"},{"name":"account_id","type":"bigint"},{"name":"tier","type":"varchar"},{"name":"created_at","type":"timestamp"}]'
dodil data table create agents -b "$BUCKET" --merge-key id \
--columns-json '[{"name":"id","type":"bigint"},{"name":"name","type":"varchar"},{"name":"email","type":"varchar"},{"name":"team","type":"varchar"},{"name":"role","type":"varchar"},{"name":"active","type":"boolean"}]'
# tickets — the lifecycle core. embedding = VECTOR(2048) for KB + dedup search (Step 2);
# suggested_kb_id + draft_reply are written by the triage engine (Step 4)
dodil data table create tickets -b "$BUCKET" --merge-key id \
--columns-json '[{"name":"id","type":"bigint"},{"name":"subject","type":"varchar"},{"name":"body","type":"varchar"},{"name":"customer_id","type":"bigint"},{"name":"assignee_agent_id","type":"bigint"},{"name":"status","type":"varchar"},{"name":"priority","type":"varchar"},{"name":"category","type":"varchar"},{"name":"channel","type":"varchar"},{"name":"csat","type":"int"},{"name":"created_at","type":"timestamp"},{"name":"solved_at","type":"timestamp"},{"name":"suggested_kb_id","type":"bigint"},{"name":"draft_reply","type":"varchar"},{"name":"embedding","type":"VECTOR(2048)"}]'
dodil data table create kb_articles -b "$BUCKET" --merge-key id \
--columns-json '[{"name":"id","type":"bigint"},{"name":"title","type":"varchar"},{"name":"body","type":"varchar"},{"name":"category","type":"varchar"},{"name":"embedding","type":"VECTOR(2048)"}]'
dodil data table create macros -b "$BUCKET" --merge-key id \
--columns-json '[{"name":"id","type":"bigint"},{"name":"title","type":"varchar"},{"name":"body","type":"varchar"},{"name":"category","type":"varchar"},{"name":"embedding","type":"VECTOR(2048)"}]'Load the people first — five customers across two account families (Acme, with an EU child, and Globex)
and three support agents. The customers.account_id is the foreign key the account graph (Step 3) is
built from, and the ids here are the same integers the graph traverses.
Upsert 5 customers (Dana/Sam on Acme account 100, Priya on Acme-EU account 101, Leo/Mira on Globex account 200) and 3 agents (tier1, billing, tier2 lead).
data_table_upsertUpserted 5 customers and 3 agents (wal_written: true).
dodil data table upsert customers -b "$BUCKET" \
--row '{"id":1,"name":"Dana Reed","email":"[email protected]","account_id":100,"tier":"enterprise","created_at":"2026-01-10 09:00:00"}' \
--row '{"id":2,"name":"Sam Ortiz","email":"[email protected]","account_id":100,"tier":"enterprise","created_at":"2026-02-14 11:30:00"}' \
--row '{"id":3,"name":"Priya Nair","email":"[email protected]","account_id":101,"tier":"enterprise","created_at":"2026-03-02 08:15:00"}' \
--row '{"id":4,"name":"Leo Fisher","email":"[email protected]","account_id":200,"tier":"pro","created_at":"2026-03-20 14:45:00"}' \
--row '{"id":5,"name":"Mira Chen","email":"[email protected]","account_id":200,"tier":"pro","created_at":"2026-04-01 10:00:00"}'
dodil data table upsert agents -b "$BUCKET" \
--row '{"id":10,"name":"Alex Kim","email":"[email protected]","team":"tier1","role":"agent","active":true}' \
--row '{"id":11,"name":"Robin Vale","email":"[email protected]","team":"billing","role":"agent","active":true}' \
--row '{"id":12,"name":"Jordan Fox","email":"[email protected]","team":"tier2","role":"lead","active":true}'Now the KB articles and macros (canned replies) — the deflection library. Each carries a VECTOR(2048)
embedding of its text, produced with the jina-embeddings-v4 model; you write the [f1,f2,…] literal
into the embedding column.
TIP — never write an empty string into a keyed column. A
""(or JSONnull) for a declared column reads back as null and can silently drop the whole row on the next read. Use a real value or a sentinel ("none",0,1970-01-01 00:00:00for an unset timestamp). Every column below is non-empty for exactly this reason.
Embed each KB article and macro body with jina-embeddings-v4 and upsert the row (id, title, body, category, embedding). Four articles (password reset, refund, SAML SSO, API rate limits) and three macros.
ignite_models_embed→data_table_upsertUpserted 4 kb_articles (201-204) and 3 macros (301-303); each embedding is a 2048-dim vector literal.
# embed helper: text → a pgvector '[…]' literal
emb() { dodil ignite models embed jina-embeddings-v4 --input "$1" --output json \
| jq -c '.data.data[0].embedding'; }
E=$(emb "Reset your password. Use the Forgot Password link; the reset link is valid for 60 minutes. Check spam.")
dodil data table upsert kb_articles -b "$BUCKET" \
--row "{\"id\":201,\"title\":\"Reset your password\",\"body\":\"Use the Forgot Password link on the login page. The reset link is valid for 60 minutes; check spam and whitelist [email protected].\",\"category\":\"account\",\"embedding\":$E}"
# …repeat for 202 (refund), 203 (SAML SSO), 204 (API rate limits), and macros 301-303.Finally the tickets — five solved history rows (each with its CSAT score) plus one fresh new
ticket. Each carries a VECTOR(2048) embedding of subject + body. Because embeddings are large, upsert
one row per call (a batched frame carrying six 2048-dim vectors exceeds the gateway's first-frame
size). The new ticket 1006 is a password-reset complaint from Dana (customer 1, Acme) — the star input
for Steps 2 and 4.
For each ticket, embed subject+body with jina-embeddings-v4, then upsert the full row (id, subject, body, customer_id, assignee_agent_id, status, priority, category, channel, csat, created_at, solved_at, suggested_kb_id, draft_reply, embedding). One row per call. Use 'none' / 0 / 1970-01-01 sentinels for the new ticket's unset fields — never an empty string.
ignite_models_embed→data_table_upsertUpserted 6 tickets (1001-1005 solved with CSAT, 1006 new); each embedding is a 2048-dim vector literal.
E=$(emb "Cannot log in, need password reset. I forgot my password and the login page keeps rejecting me.")
dodil data table upsert tickets -b "$BUCKET" \
--row "{\"id\":1001,\"subject\":\"Cannot log in, need password reset\",\"body\":\"I forgot my password and the login page keeps rejecting me. Can you help me reset it?\",\"customer_id\":1,\"assignee_agent_id\":10,\"status\":\"solved\",\"priority\":\"normal\",\"category\":\"account\",\"channel\":\"email\",\"csat\":5,\"created_at\":\"2026-06-01 09:12:00\",\"solved_at\":\"2026-06-01 09:40:00\",\"suggested_kb_id\":0,\"draft_reply\":\"none\",\"embedding\":$E}"
# The fresh ticket — status=new, unassigned (0), no CSAT yet (0), unset timestamp = 1970 sentinel
E=$(emb "Password reset email never arrives. I clicked forgot password several times but the reset email never shows up and I still cannot log in.")
dodil data table upsert tickets -b "$BUCKET" \
--row "{\"id\":1006,\"subject\":\"Password reset email never arrives\",\"body\":\"I clicked forgot password several times but the reset email never shows up in my inbox and I still cannot log in.\",\"customer_id\":1,\"assignee_agent_id\":0,\"status\":\"new\",\"priority\":\"none\",\"category\":\"none\",\"channel\":\"email\",\"csat\":0,\"created_at\":\"2026-08-31 20:15:00\",\"solved_at\":\"1970-01-01 00:00:00\",\"suggested_kb_id\":0,\"draft_reply\":\"none\",\"embedding\":$E}"
# …plus 1002 (refund), 1003 (SAML SSO), 1004 (API 429), 1005 (login link expired) — each solved, with CSAT.Step 2 — KB search + similar-ticket dedup (Vector)
This is the pillar deflection lives on. The embedding columns you populated are the semantic index —
no Pinecone, no second copy. A new ticket asks two questions at once: "which KB article answers this?"
and "have we solved this exact thing before?" 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.
KB search — the nearest article to the new password-reset ticket is, unambiguously, article 201:
In blog-helpdesk, find the KB article most similar to 'Password reset email never arrives, forgot password, cannot log in' — vector search kb_articles.embedding with jina-embeddings-v4, cosine, top 3.
data_vsearchNearest KB: 201 Reset your password (0.2509), then 203 SAML SSO (0.5128), 202 refund (0.5308).
dodil data vsearch -b "$BUCKET" -t kb_articles --column embedding \
--text "Password reset email never arrives, forgot password, cannot log in" \
--model jina-embeddings-v4 --metric cosine --top-k 3Real output — article 201 wins by a wide margin (0.25 vs 0.51), the clean deflection signal:
id score
201 0.2509 ← Reset your password
203 0.5128
202 0.5308
Similar-ticket dedup — the same search over tickets answers "have we seen this?". The new ticket
1006 is its own nearest neighbour (0.032, the self-match), and the next two are the prior password/login
tickets — exactly the precedents an agent wants:
Now vector-search the tickets themselves for the same query, top 4, so I can see the near-duplicate prior tickets.
data_vsearchNearest: 1006 (self, 0.032), 1001 (0.181, password reset), 1005 (0.254, login link expired), 1004 (0.509, API 429).
dodil data vsearch -b "$BUCKET" -t tickets --column embedding \
--text "Password reset email never arrives, forgot password, cannot log in" \
--model jina-embeddings-v4 --metric cosine --top-k 4The engine (Step 4) needs to filter to status='solved' and pull the actual resolution, so it runs the
same KNN in SQL with pgvector's <=> and a query-vector literal — the two nearest solved tickets
are the other login issues, both CSAT 5:
Show the 3 nearest SOLVED tickets to the new ticket's embedding, with subject, CSAT, and cosine distance — SQL pgvector KNN.
data_sql1001 (0.181, csat 5), 1005 (0.254, csat 5), 1004 (0.509, csat 3).
# $QVEC = the query embedding as a '[f1,f2,…]' literal (from `ignite models embed`)
dodil data sql -b "$BUCKET" \
"SELECT id, subject, csat, ROUND((embedding <=> '$QVEC')::numeric, 4) AS dist
FROM tickets WHERE status = 'solved'
ORDER BY embedding <=> '$QVEC' LIMIT 3"Real output:
id subject csat dist
1001 Cannot log in, need password reset 5 0.181
1005 Login link expired, locked out 5 0.254
1004 Getting 429 errors from the API 3 0.509
The
<=>operator needs a vector literal on the right-hand side — embed the query text and interpolate the[…]string. (A correlated sub-SELECTof another row's vector does not drive the KNN; pass the literal.)
Step 3 — The customer / account graph (family rollups)
Support questions are rarely about one contact — they're about an account: "show me every ticket
across the whole Acme family" or "which account is generating the most noise?" That's a graph
traversal (accounts have parent/child structure; customers belong to accounts), and it's table-backed: a
node table with an integer KEY, a single edge table, and CREATE GRAPH. data pg owns the DDL.
Node keys must be an integer type, so acct_node projects both accounts and customers into one integer
id space (accounts 100/101/200, customers 1–5). One acct_edge table carries both relationship
types via a rel column (v1 graphs allow one node + one edge table): parent_of links Acme Corp → Acme
EU, and belongs_to links each account to its customers — oriented account→customer so a forward
traversal from the parent reaches the whole family.
In blog-helpdesk, build acct_node (id BIGINT KEY, kind, biz_key, name) for 3 accounts + 5 customers, and acct_edge (src, dst, rel) with parent_of (100->101) and belongs_to (account->customer). Then CREATE GRAPH accts over acct_node / acct_edge.
data_pgGraph accts created over acct_node (8 nodes) / acct_edge (6 edges).
dodil data pg -b "$BUCKET" "CREATE TABLE acct_node (id BIGINT PRIMARY KEY, kind VARCHAR, biz_key VARCHAR, name VARCHAR)"
dodil data pg -b "$BUCKET" "INSERT INTO acct_node VALUES (100,'account','acme','Acme Corp'),(101,'account','acme-eu','Acme EU'),(200,'account','globex','Globex'),(1,'customer','c1','Dana Reed'),(2,'customer','c2','Sam Ortiz'),(3,'customer','c3','Priya Nair'),(4,'customer','c4','Leo Fisher'),(5,'customer','c5','Mira Chen')"
dodil data pg -b "$BUCKET" "CREATE TABLE acct_edge (src BIGINT, dst BIGINT, rel VARCHAR, PRIMARY KEY (src,dst))"
# parent_of: Acme Corp -> Acme EU ; belongs_to: account -> its customers
dodil data pg -b "$BUCKET" "INSERT INTO acct_edge VALUES (100,101,'parent_of'),(100,1,'belongs_to'),(100,2,'belongs_to'),(101,3,'belongs_to'),(200,4,'belongs_to'),(200,5,'belongs_to')"
dodil data pg -b "$BUCKET" "CREATE GRAPH accts NODES (acct_node KEY id) EDGES (acct_edge SRC src DST dst)"
CREATE GRAPHsnapshots its edges. Populateacct_nodeandacct_edgefully first — edges inserted afterCREATE GRAPHaren't traversable until youDROP GRAPH+ re-create.
graph_khop('accts', 100, 3) walks forward from the Acme parent — through the parent_of edge to Acme
EU and the belongs_to edges to every customer in the family — with hop distance, and you hydrate names
by joining acct_node in the same statement:
Give me the whole Acme account family from the parent account (100): every account and customer under it, hop-ranked and hydrated with names.
data_pghop 1: Dana Reed, Sam Ortiz, Acme EU; hop 2: Priya Nair.
dodil data pg -b "$BUCKET" \
"SELECT k.hop_distance, n.kind, n.name
FROM graph_khop('accts', 100, 3) k
JOIN acct_node n ON n.id = k.node
ORDER BY k.hop_distance, n.id"Real output — the Acme EU child and its customer come along for free:
hop_distance kind name
1 customer Dana Reed
1 customer Sam Ortiz
1 account Acme EU
2 customer Priya Nair
Now the flagship support question — every ticket in the account family — is that traversal joined to
tickets on customer_id, in one statement:
List every ticket across the whole Acme family (account 100): traverse the graph to its customers, join tickets, show id/subject/status/customer.
data_pg4 tickets — 1001, 1002, 1003 (solved) and 1006 (new) — across Dana, Sam, Priya.
dodil data pg -b "$BUCKET" \
"SELECT t.id, t.subject, t.status, c.name AS customer
FROM graph_khop('accts', 100, 3) k
JOIN customers c ON c.id = k.node
JOIN tickets t ON t.customer_id = c.id
ORDER BY t.id"And the noisiest account is that same count per family. graph_khop needs a literal start key
(it can't be correlated to a column), so run one traversal per account — Acme's family generates twice
Globex's:
Which account family is noisiest? Count tickets under Acme (100) and under Globex (200) via the graph.
data_pgAcme = 4 tickets, Globex = 2. Acme is the noisiest account.
dodil data pg -b "$BUCKET" "SELECT count(t.id) AS tickets FROM graph_khop('accts', 100, 3) k JOIN tickets t ON t.customer_id = k.node" # Acme → 4
dodil data pg -b "$BUCKET" "SELECT count(t.id) AS tickets FROM graph_khop('accts', 200, 3) k JOIN tickets t ON t.customer_id = k.node" # Globex → 2The same traversal in Cypher over Bolt — return the node variable (the graph plane hands back node keys;
join acct_node for properties):
Same Acme family membership in Cypher over Bolt: from account 100 follow up to 3 hops and return the reachable nodes.
data_boltReturns nodes 101, 2, 1 (hop 1) and 3 (hop 2).
dodil data bolt -b "$BUCKET" -g accts "MATCH (a)-[*1..3]->(m) WHERE id(a)=100 RETURN m"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. On a new ticket the handler fuses all three pillars over one Postgres-wire connection to
the bucket (SQL + pgvector <=> + graph_khop() are all just SQL there): it finds the nearest KB
article and macro (vector), drafts a suggested reply from that macro, classifies the ticket (model), and
writes suggested_kb_id + draft_reply + category + priority back to the ticket.
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 ticket 1006.
auth_service-account_create→auth_service-account_grant-role→ignite_app_deploy→ignite_invokeDeployed triage-engine (deployment_state: deployed); invoke returned category=account, priority=high, suggested_kb_id=201, draft_reply set.
dodil auth service-account create triage-engine-sa # → prints $SA_ID / $SA_SECRET
dodil auth service-account grant-role triage-engine-sa ignite-authorization-service ignite.model-user
dodil auth service-account grant-role triage-engine-sa k3-authorization-service k3.editor
dodil ignite app deploy triage-engine --code ./triage-engine --runtime python \
--env DODIL_BUCKET="$BUCKET" \
--env DODIL_SERVICE_ACCOUNT_ID="$SA_ID" \
--env DODIL_SERVICE_ACCOUNT_SECRET="$SA_SECRET"
dodil ignite invoke triage-engine --payload '{"ticket_id":1006}'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-helpdesk")
EMBED_MODEL = "jina-embeddings-v4" # matches the VECTOR(2048) columns
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, subject, body):
system = ('You are a helpdesk triage assistant. Reply with ONLY compact JSON: '
'{"category": one of [account, billing, api, other], '
'"priority": one of [low, normal, high, urgent], '
'"sentiment": one of [angry, frustrated, neutral, happy]}. No prose.')
user = f'Ticket subject: "{subject}". Body: "{body}".'
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()
tid = int(payload["ticket_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 subject, body FROM tickets WHERE id = %s", (tid,))
subject, body = cur.fetchone()
# 1. VECTOR — nearest KB article and nearest macro (the draft-reply source)
qvec = _embed(tok, f"{subject}. {body}")
cur.execute(f"SELECT id FROM kb_articles ORDER BY embedding <=> '{qvec}' LIMIT 1")
kb_id = cur.fetchone()[0]
cur.execute(f"SELECT body FROM macros ORDER BY embedding <=> '{qvec}' LIMIT 1")
draft_reply = cur.fetchone()[0]
# 2. VECTOR — the prior solved tickets that look identical (agent context)
cur.execute(f"""SELECT id FROM tickets WHERE status='solved'
ORDER BY embedding <=> '{qvec}' LIMIT 3""")
similar = [r[0] for r in cur.fetchall()]
# 3. MODEL — category / priority / sentiment gate
verdict = _classify(tok, subject, body)
# 4. WRITE BACK — same bucket, same connection
cur.execute("""UPDATE tickets SET status='open', category=%s, priority=%s,
suggested_kb_id=%s, draft_reply=%s WHERE id=%s""",
(verdict["category"], verdict["priority"], kb_id, draft_reply, tid))
return {"ticket_id": tid, **verdict, "suggested_kb_id": kb_id,
"similar_tickets": similar, "draft_reply": draft_reply}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 (if you also want the
account family), and the write-back — no second driver, no second store.
Step 5 — The classification gate (Models)
The gate is why this is cheap at helpdesk scale: a low-cost model reads the free-text ticket and returns
a structured category + priority + sentiment, so the queue self-sorts, angry customers jump the
line, and agents spend their hours where it matters. Here is the real call the handler makes, and the
real reply:
Classify this ticket with kimi-k2.6 and return only JSON {category, priority, sentiment}: subject 'Password reset email never arrives', body 'I clicked forgot password several times but the reset email never shows up in my inbox and I still cannot log in.'
ignite_models_chat{"category":"account","priority":"high","sentiment":"frustrated"}
dodil ignite models chat kimi-k2.6 \
--system 'You are a helpdesk triage assistant. Reply with ONLY compact JSON: {"category": one of [account, billing, api, other], "priority": one of [low, normal, high, urgent], "sentiment": one of [angry, frustrated, neutral, happy]}. No prose.' \
--message 'Ticket subject: "Password reset email never arrives". Body: "I clicked forgot password several times but the reset email never shows up in my inbox and I still cannot log in."'The verdict lands back in the ticket's SQL columns — category='account', priority='high',
suggested_kb_id=201, and a draft_reply lifted from the nearest macro — with status advanced
new → open and the ticket routed to the tier-1 agent:
Write the triage verdict back to ticket 1006: status=open, category=account, priority=high, assignee=10, suggested_kb_id=201, draft_reply from macro 301. Then show the row.
data_table_update→data_sqlrows_affected: 1. Row 1006 → open / account / high / kb 201 / draft set.
dodil data table update tickets -b "$BUCKET" --predicate "id=1006" \
--updates-json '{"status":"open","category":"account","priority":"high","assignee_agent_id":10,"suggested_kb_id":201,"draft_reply":"Hi! I have triggered a fresh reset link to your account email — it is valid for 60 minutes. If it lands in spam, please whitelist [email protected] and try again."}'
dodil data sql -b "$BUCKET" "SELECT id, status, category, priority, suggested_kb_id, substr(draft_reply,1,40) AS draft FROM tickets WHERE id=1006"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. The CSAT + deflection report every support lead wants is a plain GROUP BY:
Give me the solved-ticket count and average CSAT by category, and the current lifecycle rollup.
data_sqlaccount: 3 solved, avg CSAT 5.0; billing: 1 / 4.0; api: 1 / 3.0. Lifecycle: solved 5, open 1.
dodil data sql -b "$BUCKET" \
"SELECT category, count(*) AS solved, ROUND(AVG(csat),2) AS avg_csat
FROM tickets WHERE status='solved' GROUP BY category ORDER BY solved DESC"
dodil data sql -b "$BUCKET" "SELECT status, count(*) AS n FROM tickets GROUP BY status ORDER BY n DESC"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:
Print the drop-in pg / bolt / grpc endpoints for blog-helpdesk so I can point psql and cypher-shell at it.
data_connectpg postgresql://token:…@pg.uk-lon-1.dodil.io:5432/blog-helpdesk · bolt bolt+s://bolt.uk-lon-1.dodil.io:7687 (database=blog-helpdesk) · grpc table-rpc.uk-lon-1.dodil.io:443
dodil data connect "$BUCKET" # pg / bolt / grpc endpoints
dodil data connect "$BUCKET" -o psql # a ready-to-paste postgresql://… URLDataK3 vs. the multi-system helpdesk stack
| Helpdesk concern | Classic stack | On DataK3 |
|---|---|---|
| Ticket / KB / macro / agent / customer tables | Postgres (ticket DB) | SQL pillar — merge-keyed tables, CSAT + lifecycle |
| KB search + similar-ticket dedup | Pinecone / Elasticsearch | Vector pillar — VECTOR(2048) columns, pgvector <=> |
| Customer / account relationships | A relationship store / Neo4j | Graph pillar — graph_khop('accts', …), Cypher over Bolt |
| CSAT / deflection reporting | Nightly ETL → warehouse | One GROUP BY — same rows, read-your-writes, no ETL |
| Auto-triage + suggested reply | Workflow builder + connectors | Ignite handler.py, one bucket, one auth context |
One bucket, one bill, one auth context — and the KB search that deflects a ticket and the account graph that rolls it up are the same live rows, not last night's snapshot in a separate vector DB.
Test
Run against live DataK3, then the assertions below hold. What ran live for this post
(tested_at: 2026-09-01, bucket blog-helpdesk, org IHDIASH): the bucket + all five SQL tables; the
customers/agents/KB/macro/ticket rows; all 6 ticket embeddings and 4 KB + 3 macro embeddings
(jina-embeddings-v4, dim 2048); the vsearch and pgvector <=> KNN for KB search and ticket dedup;
the accts graph with graph_khop, the family/noisiest joins, and the Bolt traversal; 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.
# KB search — expect article 201 (Reset your password) nearest, ~0.25
dodil data vsearch -b "$BUCKET" -t kb_articles --column embedding \
--text "Password reset email never arrives, forgot password, cannot log in" \
--model jina-embeddings-v4 --metric cosine --top-k 3 # 201 (0.2509), 203, 202
# Similar-ticket dedup — expect the prior password/login tickets 1001, 1005 as nearest solved
dodil data vsearch -b "$BUCKET" -t tickets --column embedding \
--text "Password reset email never arrives, forgot password, cannot log in" \
--model jina-embeddings-v4 --metric cosine --top-k 4 # 1006(self), 1001, 1005, 1004
# Account family rollup — expect Acme = 4 tickets, Globex = 2 (Acme noisiest)
dodil data pg -b "$BUCKET" "SELECT count(t.id) FROM graph_khop('accts', 100, 3) k JOIN tickets t ON t.customer_id = k.node"
# The triaged ticket — expect: open | account | high | 201
dodil data sql -b "$BUCKET" "SELECT status, category, priority, suggested_kb_id FROM tickets WHERE id=1006"
# CSAT rollup — expect account: 3 solved / avg 5.0
dodil data sql -b "$BUCKET" "SELECT category, count(*) AS solved, ROUND(AVG(csat),2) AS avg_csat FROM tickets WHERE status='solved' GROUP BY category"One-shot
Build a Zendesk-style helpdesk on one DataK3 bucket named blog-helpdesk.
1. Create the bucket and merge-keyed tables: customers(id,name,email,account_id,tier,created_at);
agents(id,name,email,team,role,active); tickets(id,subject,body,customer_id,assignee_agent_id,status,
priority,category,channel,csat,created_at,solved_at,suggested_kb_id,draft_reply,embedding VECTOR(2048));
kb_articles(id,title,body,category,embedding VECTOR(2048)); macros(id,title,body,category,embedding VECTOR(2048)).
2. Upsert 5 customers (Acme 100 + child 101, Globex 200), 3 agents, 4 KB articles, 3 macros, and 6 tickets
(5 solved with CSAT, 1 new = 1006). Embed KB/macro bodies and ticket subject+body with
jina-embeddings-v4; store the [ … ] literal in embedding; one row per call. Use 'none'/0/1970-01-01
sentinels — never an empty string (empty string reads back null and drops the row).
3. Vector: KB search + ticket dedup via vsearch / pgvector `embedding <=> '<qvec>'`; filter dedup to
status='solved' and pull CSAT + resolution.
4. Graph: acct_node(id BIGINT KEY, kind, biz_key, name) + acct_edge(src,dst,rel) with parent_of(100->101)
and belongs_to(account->customer); CREATE GRAPH accts. Family tickets = graph_khop('accts',100,3)
joined to tickets; noisiest account = the per-account count (Acme 4 > Globex 2).
5. Deploy an Ignite triage-engine (own service account: ignite.model-user + k3.editor) whose handler.py
embeds the new ticket, finds the nearest KB article + macro, KNN-searches solved history, classifies
with kimi-k2.6, and writes status=open + category + priority + suggested_kb_id + draft_reply back.
6. Verify: KB nearest = 201; dedup nearest solved = 1001/1005; Acme family = 4 tickets; ticket 1006 →
open/account/high/kb201.Ship it — triage-engine as a public inbound API (git → CI → registry → deploy)
The triage-engine above drafts replies as code; as a public endpoint it becomes the webhook your
ticketing front-end (or an agent) calls on every new ticket — 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 triage-engine.)
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 helpdesk-triage, mint a git push key, and push the triage-engine app to it.
git_repo_create→auth_apikey_issue→git_clone-urlRepo helpdesk-triage created at git.dodil.io/$ORG/helpdesk-triage.git; pushed main.
dodil git repo create helpdesk-triage
DK=$(dodil auth apikey issue --service git --role git.editor --name helpdesk-triage-push -o json | jq -r '.secret // .key')
git init && git add . && git commit -m "ship triage-engine"
git push "https://x:$DK@git.dodil.io/$ORG/helpdesk-triage.git" main2 — CI → a scanned image in the DODIL registry.
Build the helpdesk-triage repo into the DODIL registry and scan it.
ignite_build_create→registry_vulnBuilt registry.dodil.io/$ORG/helpdesk-triage:v1; scan queued — re-check registry vuln for the CVE totals.
dodil ignite build create triage-engine --git-url "https://git.dodil.io/$ORG/helpdesk-triage.git" \
--tag "registry.dodil.io/$ORG/helpdesk-triage:v1"
dodil registry vuln helpdesk-triage v13 — Deploy public and curl it. Build-on-deploy needs no registry pull secret:
Deploy triage-engine from the repo as a public app and curl its health path with no token.
ignite_app_deploy→ignite_app_getDeployed triage-engine; public FQDN on ignite.dodil.cloud. curl /healthz returns 200 with no token — your ticketing webhook now posts to a real URL.
dodil ignite app deploy triage-engine --git-url "https://git.dodil.io/$ORG/helpdesk-triage.git" \
--dockerfile-path Dockerfile --allow-unauthenticated --port 8080 --health-path /healthz
BASE=$(dodil ignite app get triage-engine --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 triage-engine 1 — the full versioning
walk-through is in Ship a DODIL App.
Conclusion
You now have a one-bucket helpdesk — the ticket/KB/macro/agent/customer SQL core with CSAT and the full lifecycle, KB search + similar-ticket dedup that deflects a new ticket to the article and precedents that already answer it, a customer/account graph that rolls every ticket in an account family into one traversal, and an Ignite engine that auto-triages a new ticket and drafts a suggested reply — with a model as the category/priority 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 deflection-driven support system as one DataK3 bucket, where the vector pillar answers "have we solved this before?" over the same rows the SQL lifecycle and the account graph share. See also the sibling builds a ServiceNow-style ITSM with a CMDB graph and a cost-gated CRM lead pipeline — same one-bucket, three-pillar pattern, different domain.