The Fluent Builder
Bpmn.createProcess(id) returns a ProcessBuilder — a chainable object that tracks the
current “cursor” position in the process graph. Each method call appends an element and
advances the cursor:
import { Bpmn } from "@bpmnkit/core";
Bpmn.createProcess("my-process")
.startEvent("start") // cursor at startEvent
.serviceTask("task-1") // cursor at task-1; sequence flow start → task-1 added
.endEvent("end") // cursor at end; sequence flow task-1 → end added
.build();
Sequential flow
Methods like .serviceTask(), .userTask(), .scriptTask(), and .endEvent() all create an
element and a sequence flow from the previous cursor position.
Branches
.exclusiveGateway() and .parallelGateway() create a gateway and advance the cursor to it.
Use .branch(id, builder) to define outgoing paths:
.exclusiveGateway("gw")
.branch("approved", (b) =>
b.condition("= approved").serviceTask("notify").endEvent("done")
)
.branch("rejected", (b) =>
b.defaultFlow().endEvent("rejected")
)
Each branch builder starts at the gateway. Branches merge automatically when two paths lead to the same element.
Auto-Layout
Call .withAutoLayout() before .build() to apply the Sugiyama layered graph algorithm.
It produces clean, left-to-right layouts without any coordinate math:
const process = Bpmn.createProcess("flow")
.startEvent("start")
.serviceTask("work")
.endEvent("end")
.withAutoLayout() // assigns x/y/width/height to all elements
.build();
Under the hood, the layout algorithm:
- Topologically sorts elements into layers
- Assigns X coordinates based on layer depth
- Assigns Y coordinates by crossing-minimisation within each layer
- Adds waypoints to sequence flow edges
You can access element sizes via the ELEMENT_SIZES export if you need to build custom layouts.
Parsing and Serializing
Parse BPMN 2.0 XML into a typed object, modify it in TypeScript, and export it back:
import { Bpmn, findProcess } from "@bpmnkit/core";
// Parse XML into a typed object
const definitions = Bpmn.parse(xmlString);
// Access the first process, or look one up by id
const process = definitions.processes[0];
const approval = findProcess(definitions, "approval-flow");
// Export back to XML
const newXml = Bpmn.export(definitions);
Round-trip fidelity
Bpmn.parse() → Bpmn.export() preserves the document. Content the SDK models round-trips
through its typed fields; content it does not model is kept verbatim and re-emitted:
- Unmodelled attributes land in
unknownAttributeson the element that carried them. - Unmodelled children of
definitions, aprocess, acollaboration, a flow node, a sequence flow, amultiInstanceLoopCharacteristicsor a data association land inunknownChildren— sobpmn:import,bpmn:itemDefinition,bpmn:ioSpecification,bpmn:correlationKey,bpmn:potentialOwner,bpmn:complexBehaviorDefinitionand vendor elements outsideextensionElementsall survive, including their nested content. extensionElementsare kept as rawXmlElementtrees wherever BPMN allows them, including on root-levelmessage,error,escalationandsignalelements — which is what carrieszeebe:subscriptioncorrelation keys — and on data associations.- Diagram interchange from other tools survives too:
BPMNLabelStylefonts, attributes onBPMNDiagram,BPMNPlane,BPMNLabeland waypoints, and DI elements that omit the optionalidorbpmnElement(read as""and written back absent). - Namespaces are kept as the file declared them. A model that puts BPMN in the default
namespace — common in files from Signavio, Trisotech and Visio add-ins — is written back
with unprefixed names, not rewritten to
bpmn:.
semanticHash(definitions) gives you this as a check you can run yourself: it hashes the
model with the diagram excluded, so semanticHash(applyAutoLayout(defs)) equals
semanticHash(defs), and any difference means the model changed rather than the layout.
A corpus of BPMN documents is round-tripped on every build and compared structurally —
element counts, per-element attribute names, parent/child nesting and text content, keyed by
namespace rather than prefix — by a scanner written independently of the parser, so a
regression fails CI rather than reaching a release. The corpus includes all 22 reference
models of the OMG BPMN Model Interchange test suite.
See packages/core/tests/roundtrip-corpus.test.ts.
A second gate works from the other direction. The corpus can only find losses in documents
somebody wrote; this one asks the BPMN, DI and Zeebe schema descriptors what exists at all,
builds a document containing each of the 151 types they define, and requires it to come back
out — as a typed field or verbatim. A descriptor bump that widens the specification fails the
build instead of quietly widening the loss. See
packages/core/tests/descriptor-coverage.test.ts, or run
pnpm --filter @bpmnkit/core check:descriptors for the report.
Deliberate normalisations. The output is not byte-identical to the input, and two rewrites are intentional:
isExecutable,isSequential,isForCompensationandisCollectionare written only when true. BPMN treats the absent attribute as false, so this is stable and changes nothing.- Empty
<extensionElements/>elements are dropped, and whitespace and attribute order are not preserved.
Still not preserved. Unmodelled children are captured on the containers listed above
only. On other elements — lanes, artifacts, root-level messages and errors —
documentation and extensionElements round-trip, but any other unrecognised child does
not. A second <documentation> on the same element is also dropped;
only the first is kept, with its attributes. If you need one of these, open an issue rather
than working around it.
exportPreserving goes further than Bpmn.export: it writes the model into the original
file’s own bytes, so an unchanged model comes back byte for byte — including a coordinate
spelled 30.0 and an empty <extensionElements/>, which a plain export normalises.
AI-Compact Format
Raw BPMN XML is verbose — a simple three-node process takes ~60 lines of XML. The compact format reduces this to a small JSON object that fits in a single LLM prompt:
import { compactify, expand } from "@bpmnkit/core";
// Definitions → CompactDiagram (small JSON)
const compact = compactify(definitions);
// CompactDiagram → Definitions (full object)
const restored = expand(compact);
The compact format is designed for AI agents:
- Every element has an
idand a human-readablename - Sequence flows are represented as
{ from, to, condition? }pairs - Common Zeebe extensions (task type, headers, form and decision bindings) are inlined
- The full diagram of a typical approval workflow fits in ~500 tokens
compactify()is a lossy view, not a round trip.CompactElementmodels about fifteen properties, soexpand(compactify(definitions))is notdefinitions: collaborations, participants, message flows, lanes, data stores, artifacts, root-level messages and errors, multi-instance loop characteristics, fullzeebe:ioMappingentries and most diagram interchange do not survive it.Use it to show a model to an LLM and to build a new model from one. Do not use it as an edit path for a file you need to keep — read the file, apply the change to the
BpmnDefinitions, and export that. For the same reasoncasen generate bpmn --inputrefuses to overwrite its input unless you pass--force.
Zeebe Extensions
Camunda 8 (Zeebe) uses XML extension elements for its engine-specific config. The builder exposes these as first-class TypeScript options:
.serviceTask("send-email", {
name: "Send Confirmation Email",
taskType: "io.camunda.connectors.SMTP.v1", // connector type
taskHeaders: {
from: "[email protected]",
subject: "Your order is confirmed",
},
inputMappings: [
{ source: "= orderId", target: "orderId" },
{ source: "= customer.email", target: "to" },
],
outputMappings: [
{ source: "= messageId", target: "emailMessageId" },
],
})