Most BPMN diagrams are drawn by hand in a visual editor. That’s the right call when a human analyst is designing a process. It’s the wrong call when the process is generated by a template, assembled from configuration, or produced by an LLM — in those cases you want a fluent API that emits valid BPMN 2.0 XML, not a GUI to click through.
That’s what @bpmnkit/core is for.
A minimal process
import { Bpmn } from "@bpmnkit/core";
const xml = Bpmn.export(
Bpmn.createProcess("my-flow")
.startEvent("start")
.serviceTask("task", {
name: "Do Something",
taskType: "my-worker", // Zeebe job type
})
.endEvent("end")
.withAutoLayout()
.build()
);
// ✓ Valid BPMN 2.0 XML
// ✓ Auto-layout applied
// ✓ Zeebe extensions set
Every call in the chain returns a new builder state, so the whole diagram is one
type-checked expression. There’s no XML template string to get wrong, no namespace
to remember, and no manual coordinate math — .withAutoLayout()
runs a Sugiyama layout pass before export.
Branching with gateways
Real processes branch. @bpmnkit/core models exclusive and parallel gateways with a
branch() callback that scopes each path:
const defs = Bpmn.createProcess("approval-flow")
.startEvent("start", { name: "Request Submitted" })
.userTask("review", { name: "Review Request" })
.exclusiveGateway("gw", { name: "Approved?" })
.branch("yes", (b) =>
b.condition("= approved")
.serviceTask("notify", { taskType: "send-email" })
.endEvent("done")
)
.branch("no", (b) =>
b.defaultFlow().endEvent("rejected")
)
.withAutoLayout()
.build();
The cursor inside each branch() callback is scoped to that path — you don’t have to
manually track “which element connects to what” the way you would when hand-writing
XML.
Error paths with boundary events
Boundary events are one of the more awkward parts of BPMN to author by hand, because
after attaching an error path you have to remember to move the cursor back to the
main flow. withBoundary() does that for you:
const xml = Bpmn.export(
Bpmn.createProcess("payment-flow")
.startEvent("start")
.serviceTask("charge", { name: "Charge Card", taskType: "payment-charge" })
.withBoundary("on-fail", { errorCode: "PAYMENT_FAILED" }, (p) =>
p.serviceTask("notify", { taskType: "send-email" }).endEvent("end-failed"),
)
// main flow continues from "charge" — not from the boundary
.serviceTask("fulfill", { name: "Fulfill Order", taskType: "warehouse-pick" })
.endEvent("end-ok")
.withAutoLayout()
.build()
);
Type guards and typed errors
Every BPMN element is a discriminated union member, so narrowing is compile-time safe:
import { isBpmnServiceTask, isBpmnGateway, ParseError } from "@bpmnkit/core";
for (const el of process.flowElements) {
if (isBpmnServiceTask(el)) {
console.log(el.taskDefinition.type); // fully typed, no cast
}
}
Parsing malformed XML throws a typed ParseError (not a generic Error), so you can
instanceof-catch it and handle it distinctly from a ValidationError.
Where to go next
- Auto-layout BPMN diagrams with the Sugiyama algorithm — how the layout pass works
- Simulate a BPMN process without Camunda — run what you just built
@bpmnkit/corepackage reference