Write a Camunda 8 DMN decision table with @bpmnkit/core in one script: create, export, edit, validate. Run it with node --experimental-strip-types build.ts (Node 22.6+) or npx tsx build.ts. Never write DMN XML by hand.
Minimal DMN build script: create, export, edit, validate
import { readFileSync, writeFileSync } from "node:fs"
import { Dmn, exportDmnPreserving } from "@bpmnkit/core"
// 1. Create. "risk" is the decision id a BPMN businessRuleTask references as decisionId.
const definitions = Dmn.createDecisionTable("risk")
.name("Order risk")
.hitPolicy("FIRST")
.input({ label: "Amount", expression: "amount", typeRef: "number" })
.input({ label: "Country", expression: "country", typeRef: "string" })
.output({ label: "Risk", name: "risk", typeRef: "string" })
.rule({ inputs: ["> 1000", "-"], outputs: ['"high"'], description: "Large order" })
.rule({ inputs: ["-", 'not("DE", "AT")'], outputs: ['"medium"'] })
.rule({ inputs: ["-", "-"], outputs: ['"low"'] })
.build()
// 2. Export.
writeFileSync("risk.dmn", Dmn.export(definitions))
// 3. Edit an existing file: parse, change, write with exportDmnPreserving to keep the diagram.
const original = readFileSync("risk.dmn", "utf8")
const edited = Dmn.parse(original)
const decision = edited.decisions.find((d) => d.id === "risk")
if (decision) decision.name = "Order risk level"
writeFileSync("risk.dmn", exportDmnPreserving(original, edited))
// 4. Validate: it parses back and the decision id the process calls is there.
const check = Dmn.parse(readFileSync("risk.dmn", "utf8"))
if (!check.decisions.some((d) => d.id === "risk")) {
console.error("decision 'risk' missing")
process.exit(1)
}
npm i @bpmnkit/core. The script writes risk.dmn with its diagram, re-opens it, renames the decision without touching the layout, and checks the decision id.
Several decisions in one file: Dmn.drd()
import { writeFileSync } from "node:fs"
import { Dmn } from "@bpmnkit/core"
// Two decisions in one file: "approval" reads the result of "riskScore".
const drd = Dmn.drd("Order approval")
const riskScore = drd.decision("riskScore", (t) =>
t
.name("Risk score")
.hitPolicy("FIRST")
.input({ label: "Amount", expression: "amount", typeRef: "number" })
.output({ label: "Risk", name: "risk", typeRef: "string" })
.rule({ inputs: ["> 1000"], outputs: ['"high"'] })
.rule({ inputs: ["-"], outputs: ['"low"'] }),
)
drd.decision(
"approval",
(t) =>
t
.name("Approval")
.input({ label: "Risk", expression: "risk", typeRef: "string" })
.output({ label: "Approved", name: "approved", typeRef: "boolean" })
.rule({ inputs: ['"low"'], outputs: ["true"] })
.rule({ inputs: ["-"], outputs: ["false"] }),
{ requires: [riskScore] }, // pass the handle; a name or label is a type error
)
writeFileSync("approval.dmn", drd.toXml())
drd.decision(id, table, { requires }) returns a handle; requires takes handles (or drd.inputData(id, name) handles), never names. A requires that points at an unknown id throws at build()/toXml().
Top builder options
Dmn.createDecisionTable(decisionId)….build()returnsDmnDefinitions;Dmn.export(defs)gives the XML with DMNDI shapes..name(text)is the display name;.hitPolicy("UNIQUE" | "FIRST" | "ANY" | "COLLECT" | "RULE ORDER" | "OUTPUT ORDER" | "PRIORITY")..input({ label, expression, typeRef }):expressionis a FEEL expression over process variables (amount,order.total)..output({ label, name, typeRef }):nameis the key in the result..rule({ inputs, outputs, description }): one entry per column, in column order, orbuildthrows.typeRef:"string" | "number" | "boolean" | "date".- Read or change an existing file:
Dmn.parse(xml)→defs.decisions[i].decisionTable.
Traps
- The process calls the decision id. A BPMN
businessRuleTask(id, { decisionId: "risk", resultVariable: "risk" })must use the argument ofcreateDecisionTable("risk"), not the DMNdefinitionsid, the file name or.name(). Ids are unique across the cluster: a second file with decisionriskdeploys as a new version of the same decision. - Deploy the
.dmnbefore an instance reaches the task. The call binds to the latest deployed version:casen deploy deploy risk.dmnfirst. - Hit policy
UNIQUEis the default. Two matching rules is a runtime incident. UseFIRSTfor a catch-all last row, orCOLLECTfor a list. - Input entries are FEEL unary tests, outputs are FEEL expressions, and neither starts with
=.-matches anything;> 1000,[1..10],"DE",not("DE", "AT"). A string output needs quotes:'"high"', not"high". - Result shape. With one output column the result variable holds the value; with several it holds an object keyed by output
name;COLLECTgives a list. - Edit with the preserving writer. Write
exportDmnPreserving(original, edited);Dmn.exportalone reformats the file and its diagram.
Validate
casen check risk.dmn order.bpmn --deploy # DMN lint + every decisionId in the BPMN resolves
casen check <files...> takes .bpmn, .dmn and .form together and exits 1 on errors; add --json for an agent loop. The script above also re-parses the file and checks the decision id.
Related cards: casen agent-card bpmn, casen agent-card form. More: npx bpmnkit-docs ask "decision table hit policy".