# BPMN Kit — Full Content > A TypeScript SDK for building, modeling, simulating, and deploying BPMN processes on Camunda 8. --- ## What is BPMN Kit? BPMN Kit is an open-source TypeScript monorepo that makes it possible to work with BPMN 2.0 processes entirely in code. Instead of wrestling with hundreds of lines of raw XML and manual coordinates, you call a fluent TypeScript API and get back valid, auto-laid-out BPMN XML ready to deploy to Camunda 8. The SDK is AI-native: a compact intermediate format lets an entire diagram fit in a single LLM prompt. AI agents can generate, read, and modify process definitions without ever seeing raw XML. Every package is strict-TypeScript, ESM-only, tree-shakeable, and runs in browsers, Node.js, Deno, Bun, and edge runtimes. --- ## Packages - [@bpmnkit/core](https://github.com/bpmnkit/bpmnkit/tree/main/packages/core): Fluent process builder, BPMN 2.0 parser/serializer, DMN support, AI-compact format (compactify/expand), auto-layout (Sugiyama algorithm), SVG export (zero third-party deps, all runtimes). Multi-process support: Bpmn.createDiagram() assembles caller/callee process pairs in one definitions document. Full branching inside sub-processes (exclusiveGateway, parallelGateway, branch()). Ergonomic boundary events via withBoundary() — cursor auto-restores to main flow after the error path. Includes 29 TypeScript type guard predicates (isBpmnServiceTask, isBpmnGateway…), typed error classes (ParseError, ValidationError — instanceof-catchable), and element lookup utilities (findElement, getZeebeExtensions, etc.) - [@bpmnkit/engine](https://github.com/bpmnkit/bpmnkit/tree/main/packages/engine): BPMN simulation engine, zero third-party dependencies (browser + Node.js) — service tasks, user tasks, gateways, timers, message correlation, DMN evaluation - [@bpmnkit/api](https://github.com/bpmnkit/bpmnkit/tree/main/packages/api): Camunda 8 REST API client — 180 typed methods, 30+ resource classes, OAuth2 / Bearer / Basic auth, LRU+TTL cache, exponential backoff, TypedEventEmitter - [@bpmnkit/canvas](https://github.com/bpmnkit/bpmnkit/tree/main/packages/canvas): Zero-third-party-dependency SVG BPMN viewer with pan/zoom, dark/light theme, plugin API - [@bpmnkit/editor](https://github.com/bpmnkit/bpmnkit/tree/main/packages/editor): Full BPMN editor — canvas + properties panel + AI bridge + storage - [casen (CLI)](https://github.com/bpmnkit/bpmnkit/tree/main/apps/cli): Interactive TUI for managing Camunda 8 from the terminal: processes, jobs, incidents, decisions, variables, messages --- ## Key Features - Fluent builder API: chain .startEvent().serviceTask().exclusiveGateway().branch()... - Multi-process definitions: Bpmn.createDiagram(id) assembles multiple processes (caller/callee, sub-flows) into one BPMN definitions document - Sub-process branching: full gateway and branch() support inside subProcess() callbacks, with auto-join insertion - Ergonomic boundary events: .withBoundary(id, options, handler) attaches error/timeout paths and auto-restores the main flow cursor - Auto-layout: Sugiyama algorithm produces clean diagrams with no coordinate math - AI-native: compact intermediate format fits an entire diagram in a single LLM prompt - Camunda 8 ready: native Zeebe task definitions, IO mappings, connectors, forms - Roundtrip fidelity: parse → modify → export without data loss - SVG export: generate diagram images from BpmnDefinitions — zero third-party deps, works in Node.js, browser, Deno, Bun - Type guards: 29 predicates (isBpmnServiceTask, isBpmnGateway…) narrow BpmnFlowElement unions at compile time - Typed errors: ParseError and ValidationError extend a common BpmnSdkError base — all instanceof-catchable with error codes - Element lookup utilities: findElement, findProcess, getZeebeExtensions and friends traverse parsed diagrams - Full JSDoc coverage: @param, @returns, @throws, @example on every public API - Simulation engine: deploy and run processes locally, register job workers, evaluate DMN - REST API client: full Camunda 8 Orchestration Cluster API coverage - CLI: arrow-key TUI, connection profiles, tabular results --- ## Core SDK (@bpmnkit/core) ### Basic Process ```typescript import { Bpmn } from "@bpmnkit/core"; const xml = Bpmn.export( Bpmn.createProcess("my-flow") // fluent API .startEvent("start") // trigger .serviceTask("task", { name: "Do Something", taskType: "my-worker", // Zeebe type }) .endEvent("end") .withAutoLayout() // Sugiyama .build() ); // ✓ Valid BPMN 2.0 XML // ✓ Auto-layout applied // ✓ Zeebe extensions set ``` ### Exclusive Gateway (Branching) ```typescript const xml = Bpmn.export( 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("end-ok") ) .branch("no", (b) => b.defaultFlow().endEvent("end-no")) .withAutoLayout() .build() ); ``` ### Parallel Gateway ```typescript const xml = Bpmn.export( Bpmn.createProcess("order-fulfillment") .startEvent("start") .parallelGateway("split") .branch("warehouse", (b) => b.serviceTask("pick", { taskType: "warehouse-pick" }) ) .branch("payment", (b) => b.serviceTask("charge", { taskType: "payment-charge" }) ) .parallelGateway("join") .endEvent("end") .withAutoLayout() .build() ); ``` --- ## Simulation Engine (@bpmnkit/engine) Run BPMN processes locally in the browser or Node.js. Zero external dependencies. Supports service tasks, user tasks, exclusive/parallel/event-based gateways, timers, message correlation, and DMN decision evaluation. ```typescript import { Engine } from "@bpmnkit/engine"; // Simulate the process in-process — for tests and local development. // Deploying to a real Camunda 8 cluster? See the API client below. const engine = new Engine(); engine.deploy({ bpmn: defs }); engine.registerJobWorker( "greet", async (job) => { console.log("Hello!"); job.complete(); } ); engine.start("hello"); ``` --- ## REST API Client (@bpmnkit/api) ```typescript import { CamundaClient } from "@bpmnkit/api"; const client = new CamundaClient({ baseUrl: "https://api.cloud.camunda.io", auth: { type: "oauth2", clientId: process.env.CAMUNDA_CLIENT_ID!, clientSecret: process.env.CAMUNDA_CLIENT_SECRET!, tokenUrl: "https://login.cloud.camunda.io/oauth/token", audience: process.env.CAMUNDA_AUDIENCE!, }, }); // Start a new instance of an already-deployed process const instance = await client.processInstance.createProcessInstance({ processDefinitionId: "my-flow", variables: { orderId: "ord-123" }, }); // React to lifecycle events client.on("request", (e) => console.log(e.method, e.url)); client.on("error", (e) => metrics.inc("api.error")); ``` Resource coverage: Processes, Jobs & Workers, Decisions (DMN), Messages, Incidents, Variables, Signals, User Tasks, Authorizations, Groups, Users. Non-functional: LRU+TTL cache, exponential backoff, TypedEventEmitter, ESM tree-shakeable, zero transitive runtime dependencies. --- ## CLI — casen Interactive TUI for managing Camunda 8. Navigate with arrow keys. ``` casen ``` Main menu groups: profile, process, job, incident, decision, variable, message. Example — list deployed process definitions: Navigate to: process → list → Enter Result: bpmnProcessId name ver ───────────────────────────────────────────────────── ▶ order-validation Order Validation 1 payment-processing Payment Processing 2 ai-support-agent AI Support Agent 1 approval-flow Approval Flow 3 --- ## Getting Started ### 1. Install ``` pnpm add @bpmnkit/core ``` ### 2. Create a process ```typescript import { Bpmn, exportSvg } from "@bpmnkit/core"; const defs = Bpmn.createProcess("hello") .startEvent("start") .serviceTask("task", { name: "Hello World", taskType: "greet", }) .endEvent("end") .withAutoLayout() .build(); const xml = Bpmn.export(defs); // ✓ BPMN 2.0 XML const svg = exportSvg(defs); // ✓ SVG image, zero deps ``` ### 3. Deploy and run ```typescript import { Engine } from "@bpmnkit/engine"; // Simulate the process in-process — for tests and local development. // Deploying to a real Camunda 8 cluster? See the API client below. const engine = new Engine(); engine.deploy({ bpmn: defs }); engine.registerJobWorker( "greet", async (job) => { console.log("Hello!"); job.complete(); } ); engine.start("hello"); ``` --- ## Documentation — Getting Started ### Installation Install BPMN Kit packages in your project. Source: https://bpmnkit.com/docs/getting-started/installation BPMN Kit is a collection of focused packages. Install only what you need. ## Core SDK The `@bpmnkit/core` package is the foundation — it provides the fluent process builder, BPMN 2.0 parser/serializer, auto-layout, and the AI-compact format. ```sh pnpm add @bpmnkit/core # or npm install @bpmnkit/core # or yarn add @bpmnkit/core ``` ## Simulation Engine To run BPMN processes locally (browser or Node.js): ```sh pnpm add @bpmnkit/engine ``` ## Camunda 8 REST API Client To interact with a live Camunda 8 cluster: ```sh pnpm add @bpmnkit/api ``` ## SVG Canvas Viewer To embed an interactive BPMN diagram viewer in a web page: ```sh pnpm add @bpmnkit/canvas ``` ## Full Editor The editor bundles the canvas, a properties panel, and an AI bridge: ```sh pnpm add @bpmnkit/editor ``` ## CLI The `casen` CLI is a standalone tool — install it globally: ```sh pnpm add -g @bpmnkit/cli ``` With the CLI installed, you can use the AI-first workflow to implement processes from natural language using Claude Code: ```sh casen skills install # install /implement, /review, /test, /deploy slash commands casen proxy # start the AI bridge casen reebe # start local workflow engine ``` Then in Claude Code: `/implement an invoice approval process` See [AI-Driven Implementation](/docs/guides/ai-implement) for a full walkthrough. ## Worker client For standalone workers that connect to Zeebe without the full SDK: ```sh npm install @bpmnkit/worker-client ``` Workers scaffolded by `/implement` depend only on this package at runtime. ## TypeScript Requirements All packages require **TypeScript 5.0+** with `strict: true`. The recommended `tsconfig.json` settings: ```json { "compilerOptions": { "strict": true, "module": "NodeNext", "moduleResolution": "NodeNext", "target": "ES2022" } } ``` For browser/bundler projects (Vite, Webpack, etc.), use: ```json { "compilerOptions": { "strict": true, "module": "ESNext", "moduleResolution": "bundler" } } ``` ## Runtime Requirements | Runtime | Minimum Version | |---|---| | Node.js | 20 LTS | | Deno | 1.40+ | | Bun | 1.0+ | | Browsers | ES2022 (Chrome 94, Firefox 93, Safari 15.4) | All packages are **ESM-only** (`"type": "module"`). CommonJS is not supported. --- ### Quick Start Build and run your first BPMN process in minutes. Source: https://bpmnkit.com/docs/getting-started/quick-start This guide walks you from zero to a deployed, running BPMN process in three steps. ## Step 1: Create a process Use the fluent builder to describe your process in TypeScript: ```typescript import { Bpmn } from "@bpmnkit/core"; const xml = Bpmn.export( Bpmn.createProcess("hello") .startEvent("start") .serviceTask("task", { name: "Hello World", taskType: "greet", // Zeebe worker type }) .endEvent("end") .withAutoLayout() // apply Sugiyama layout .build() ); console.log(xml); // valid BPMN 2.0 XML ``` The `xml` string is a complete, valid BPMN 2.0 document that any standards-compliant engine can load. ## Step 2: Simulate locally The simulation engine runs the process right in Node.js — no Camunda cluster required: ```typescript import { Engine } from "@bpmnkit/engine"; const engine = new Engine(); await engine.deploy({ bpmn: xml }); // Register a job worker for the "greet" service task engine.registerJobWorker("greet", async (job) => { console.log("Hello from the worker!"); await job.complete({ greeting: "Hello!" }); }); const instance = engine.start("hello"); // Wait for the process to finish await new Promise((resolve) => { instance.onChange((state) => { if (state === "completed") resolve(); }); }); ``` Prefer to see it? Write `xml` to `hello.bpmn` and run [`casen dev`](/docs/cli/dev) in that folder: the editor opens in your browser with the simulator, and every save is linted and runs the file's scenario tests. ```sh npx @bpmnkit/cli dev ``` ## Step 3: Deploy to Camunda 8 When you're ready for production, deploy to a real Camunda 8 cluster: ```typescript import { CamundaClient } from "@bpmnkit/api"; const client = new CamundaClient({ baseUrl: "https://api.cloud.camunda.io", auth: { type: "oauth2", clientId: process.env.CAMUNDA_CLIENT_ID, clientSecret: process.env.CAMUNDA_CLIENT_SECRET, audience: process.env.CAMUNDA_AUDIENCE, }, }); // Deploy the process definition await client.process.deploy({ resources: [{ content: xml, name: "hello.bpmn" }], }); // Start a new process instance const instance = await client.process.startInstance({ bpmnProcessId: "hello", variables: { greeting: "world" }, }); console.log("Started instance:", instance.processInstanceKey); ``` ## What's next? - [AI-Driven Implementation](/docs/guides/ai-implement) — let Claude implement processes end-to-end from a description - [Core Concepts](/docs/getting-started/concepts) — understand how the builder, layout, and roundtrip work - [Building Processes](/docs/guides/building-processes) — tasks, events, sub-processes, and markers - [Gateways & Branching](/docs/guides/gateways) — exclusive, parallel, and event-based gateways > **AI-first workflow** — if you have Claude Code, the fastest path is `casen skills install` > then `/implement `. Claude generates the BPMN, scaffolds workers, and deploys — > all from a single prompt. --- ### Core Concepts Understand how the builder, serializer, auto-layout, and AI-compact format work. Source: https://bpmnkit.com/docs/getting-started/concepts ## 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: ```typescript 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: ```typescript .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: ```typescript 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: 1. Topologically sorts elements into layers 2. Assigns X coordinates based on layer depth 3. Assigns Y coordinates by crossing-minimisation within each layer 4. 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: ```typescript 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 `unknownAttributes` on the element that carried them. - **Unmodelled children** of `definitions`, a `process`, a `collaboration`, a flow node, a sequence flow, a `multiInstanceLoopCharacteristics` or a data association land in `unknownChildren` — so `bpmn:import`, `bpmn:itemDefinition`, `bpmn:ioSpecification`, `bpmn:correlationKey`, `bpmn:potentialOwner`, `bpmn:complexBehaviorDefinition` and vendor elements outside `extensionElements` all survive, including their nested content. - **`extensionElements`** are kept as raw `XmlElement` trees wherever BPMN allows them, including on root-level `message`, `error`, `escalation` and `signal` elements — which is what carries `zeebe:subscription` correlation keys — and on data associations. - **Diagram interchange from other tools** survives too: `BPMNLabelStyle` fonts, attributes on `BPMNDiagram`, `BPMNPlane`, `BPMNLabel` and waypoints, and DI elements that omit the optional `id` or `bpmnElement` (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](/docs/getting-started/conformance#bpmn-model-interchange-miwg). 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`, `isForCompensation` and `isCollection` are written only when true. BPMN treats the absent attribute as false, so this is stable and changes nothing. - Empty `` 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 `` 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 ``, 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: ```typescript 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 `id` and a human-readable `name` - 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.** `CompactElement` models about fifteen > properties, so `expand(compactify(definitions))` is not `definitions`: collaborations, > participants, message flows, lanes, data stores, artifacts, root-level messages and errors, > multi-instance loop characteristics, full `zeebe:ioMapping` entries 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 reason `casen generate bpmn --input` > refuses 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: ```typescript .serviceTask("send-email", { name: "Send Confirmation Email", taskType: "io.camunda.connectors.SMTP.v1", // connector type taskHeaders: { from: "noreply@example.com", subject: "Your order is confirmed", }, inputMappings: [ { source: "= orderId", target: "orderId" }, { source: "= customer.email", target: "to" }, ], outputMappings: [ { source: "= messageId", target: "emailMessageId" }, ], }) ``` --- ### Stability and Versioning What a version number promises — what counts as a breaking change, what is public API, and how long each release is supported. Source: https://bpmnkit.com/docs/getting-started/stability ## What this page is for A version number is a promise about breakage. This page says exactly which promise BPMN Kit makes, so you can decide what to write in your `package.json` and know what an upgrade can do to you. Every package follows [Semantic Versioning 2.0.0](https://semver.org). The rest of this page is the part semver leaves open: what counts as *the API* in a toolkit that also emits XML files, writes state to disk and speaks HTTP. ## What is covered today The promises below take effect for a given package **when it reaches 1.0.0**. Twelve packages do — they are listed at the end of this page. A package still on 0.x is **not** covered by them, even though other packages in the workspace are: under semver, 0.x makes no compatibility promise at all. Releases of those have been additive in practice, but *in practice* is not a contract, so pin an exact version if one of them matters to you. ## Product tiers Every product BPMN Kit ships is in one of three tiers. The tier says how much you can rely on it. Each README and each package page on this site shows it. | Tier | What it promises | |---|---| | **Core** | The promises on this page, from 1.0: nothing breaks without a major release. Core is exactly the twelve packages at 1.0. | | **Tools** | Maintained: bugs are fixed and releases continue. Versions are 0.x, so a minor release can break. Pin a version. | | **Experimental** | May change a lot or be discontinued. Not for production. Always below 1.0. | - **Core:** `@bpmnkit/core`, `@bpmnkit/canvas`, `@bpmnkit/editor`, `@bpmnkit/plugins`, `@bpmnkit/engine`, `@bpmnkit/feel`, `@bpmnkit/api`, `@bpmnkit/ascii`, `@bpmnkit/docspack`, `@bpmnkit/connector-gen`, `@bpmnkit/connectors`, `@bpmnkit/cli`. - **Tools:** BPMN Kit for VS Code, Drop, `@bpmnkit/proxy` (the MCP server, `casen proxy mcp`), `@bpmnkit/markdown`, `@bpmnkit/camunda-docspack`, `@bpmnkit/patterns`, `@bpmnkit/worker-client`, `@bpmnkit/cli-sdk`, `@bpmnkit/create-casen-plugin`, `@bpmnkit/casen-report`, `@bpmnkit/casen-worker-http`, `@bpmnkit/casen-worker-ai`, and the shared plumbing `@bpmnkit/ui`, `@bpmnkit/profiles` and `@bpmnkit/astro-shared`. - **Experimental:** Reebe, `@bpmnkit/reebe-wasm`, `@bpmnkit/operate`, `@bpmnkit/user-tasks`, `@bpmnkit/flow`, Studio, the Desktop app and proxy-rs. Reebe is a dev/test engine: use it on your machine and in CI, not in production. The `@bpmnkit/engine/wasm-runner` entry point is part of a Core package, so its API is covered, but the Reebe engine it runs is Experimental and its behaviour can change in any release. The tiers live in `TIER` and `APPS` in [`scripts/published-packages.mjs`](https://github.com/bpmnkit/bpmnkit/blob/main/scripts/published-packages.mjs). The repo's checks fail if a published package has no tier, if Core differs from the packages at 1.0, or if an Experimental product reaches 1.0. ## Versions are per package, not per repo Packages version independently. `@bpmnkit/core` reaching 2.0.0 does not make `@bpmnkit/canvas` 2.0.0, and the two can sit many majors apart. Sibling dependencies are declared as carets, so `@bpmnkit/plugins` depending on `^1.2.0` of `@bpmnkit/core` resolves to one shared copy alongside your own `^1.4.0`. Two copies of `@bpmnkit/core` in one tree is not a duplicate of one copy — class identity, `instanceof` and module-level registries all stop matching across the seam — so keep BPMN Kit packages within one major of each other. Releases are cut by [Changesets](https://github.com/changesets/changesets). Every change that reaches npm has a changeset naming its packages and its bump, and lands in that package's `CHANGELOG.md`. ## What counts as public API **The public API of a package is what its `exports` entry points export, minus anything marked `@internal`.** Nothing else. ```ts import { Bpmn } from "@bpmnkit/core" // ✅ API import { minimap } from "@bpmnkit/plugins/minimap" // ✅ API — a declared subpath ``` These are **not** API, and may change in any release: | Not API | Why | |---|---| | Deep paths into `dist/` | An implementation layout, not an entry point | | Members marked `/** @internal */` | Reachable from the `.d.ts` because TypeScript has no other way to say "not yours" — `ProcessBuilder` carries several | | Anything reachable only by structural inference | If you cannot import it by name from an entry point, it is not named in the contract | | `src/` in the repo | The published package is the artifact; the repository is not | Each package's entry points are listed in its `exports` map. `@bpmnkit/plugins` is the one to watch: it has **no root export**, only 34 subpaths, one per plugin. **For a package whose product is a command**, `exports` says nothing — `@bpmnkit/cli` has none at all. Its public API is instead its **documented commands**: the command and flag names, the meaning of its exit codes, and the shape of any `--format json` output. Prose written to a terminal for a human to read is not API, and neither is the exact wording of an error. For a package that renders UI, the **rendered DOM and its class names are not API** either. Style through the documented CSS custom properties; a panel's internal markup can change in a minor. ## What is a breaking change ### Runtime behaviour Breaking: removing or renaming an export or an entry point; removing a function parameter or making an optional one required; throwing where a value used to be returned; changing a documented default. ### Types Type-level breakage is real breakage: a build that no longer compiles is a broken build. The direction matters, and it is the opposite for things we hand you and things you hand us. | Change | Verdict | |---|---| | Adding an export, or a new entry point | minor | | Adding an **optional** property to an options object | minor | | Adding a member to a union we **accept** | minor | | Adding a member to a union we **return** | **major** — your exhaustive `switch` stops compiling | | Adding a **required** property to anything you construct | **major** | | Making a returned property optional | **major** — you now have to narrow it | | Narrowing a return type | **major** | | Widening a parameter type | minor | | Renaming an exported type | **major**, even when the shape is identical | ### Generated BPMN, DMN and Form documents This is the promise that matters most here, and the one a general semver policy has nothing to say about. The rule is: > **A change is breaking if it moves `semanticHash` for the same input. A change to the bytes > alone is not.** `semanticHash` is BPMN Kit's canonical, presentation-free projection of a model, and it is exported for exactly this purpose. Verified against the current build: | Property | Holds | |---|---| | The same input rebuilt produces the same hash | ✅ | | `applyAutoLayout` does not move it | ✅ | | Renaming an element moves it | ✅ | | Changing an element **id** moves it | ✅ | So, concretely: - **Major** — different element ids, a changed document structure, a different default attribute on an emitted element, a changed FEEL expression. Anyone diffing generated files in review, or deploying them by id, sees these. - **Minor or patch** — different layout coordinates, different attribute order, different whitespace, a nicer waypoint route. The picture moved; the model did not. For the avoidance of doubt about precedent: `@bpmnkit/core` 0.4.0 derived element ids from the model instead of generating them randomly. That moved `semanticHash` for every document, and shipped as a *minor*. Under this policy it is a major, and 0.x is the only reason it was not. ### Formats outside the package These are contracts even though no TypeScript signature describes them, and the same rule applies — a change that makes an existing file, store or caller stop working is major: - The **`.bpmn.tests.json` sidecar** read by `casen test` and the runner's Tests tab. - **Profile storage on disk** — `~/.config/casen` on Linux, `~/Library/Application Support/casen` on macOS, `%APPDATA%\casen` on Windows. A format change must migrate existing profiles, not invalidate them. - The **`@bpmnkit/proxy` HTTP surface**, for the routes the documentation names. - **Element template validation** — a template that validates today does not start failing in a minor. ## What is not a breaking change - Adding a feature, an export, an entry point, or an optional parameter. - Fixing a bug so behaviour matches its documentation. If you relied on the bug, this can still move under you; it is a patch, and the changelog will say what changed. - Performance, internal structure, dependency versions inside a compatible range. - Anything about layout, formatting or diagram interchange that leaves `semanticHash` alone. - Documentation, READMEs, or the contents of `@bpmnkit/docspack`. ## Runtime support The supported runtimes are listed under [Runtime Requirements](/docs/getting-started/installation#runtime-requirements): Node.js 20 LTS and newer, Deno 1.40+, Bun 1.0+, and browsers supporting ES2022. Every package is **ESM-only** — there is no CommonJS build, and there will not be one. Raising the floor follows the runtime's own lifecycle: - Dropping a Node.js major that is **still in LTS** is a **major**. - Dropping one that has reached **end of life** is a **minor**, called out in the release notes. - Raising the browser baseline past ES2022 is a **major**. ## Deprecation Nothing that is public API disappears without warning. 1. It is marked `@deprecated` in the type declarations, naming what to use instead. Your editor and your build show it; nothing breaks. 2. It keeps working for **at least one minor release**, and is listed in the changelog entry that deprecated it. 3. It is removed only in a major, and the major's release notes list every removal. An alias kept purely for compatibility is documented as such — `ProcessBuilder`'s `strict` option is the existing example, a deprecated alias for `explicitJoins`. ## Support window Fixes land on the **latest minor of the current major**. When a new major ships, the previous major gets security fixes for **six months**; other fixes require an upgrade. Security issues should be reported through [GitHub](https://github.com/bpmnkit/bpmnkit/issues) rather than in a public pull request. ## Which packages this covers A package is covered by this page once it is at **1.0.0 or above**, and not before. The distinction is deliberate: several packages are published, useful, and not yet ready to freeze an API — shipping them as 1.0 to make the list tidy would be a promise the project could not keep. Joining later costs nothing, because going from 0.x to 1.0 breaks no one, so the bar is applied strictly rather than generously. Three conditions, all of which must hold: 1. **A test suite that would catch its own breakage.** 2. **A documentation page** on this site. 3. **An API worth defending for a year.** **Twelve packages** meet them today and carry the promise: | | | |---|---| | [`@bpmnkit/core`](/docs/packages/core) | [`@bpmnkit/feel`](/docs/packages/feel) | | [`@bpmnkit/canvas`](/docs/packages/canvas) | [`@bpmnkit/editor`](/docs/packages/editor) | | [`@bpmnkit/engine`](/docs/packages/engine) | [`@bpmnkit/plugins`](/docs/packages/plugins) | | [`@bpmnkit/api`](/docs/packages/api) | [`@bpmnkit/ascii`](/docs/packages/ascii) | | [`@bpmnkit/connectors`](/docs/packages/connectors) | [`@bpmnkit/connector-gen`](/docs/packages/connector-gen) | | [`@bpmnkit/docspack`](/docs/packages/docspack) | [`@bpmnkit/cli`](/docs/cli/casen) | The other sixteen published packages stay on 0.x on purpose, and make no semver promise; their [tier](#product-tiers) says what they do promise. Most are short of the first two conditions; the rest are worked examples, scaffolders, or generated builds with no API of their own to freeze. The membership is not only prose. It lives in `STABLE` in [`scripts/published-packages.mjs`](https://github.com/bpmnkit/bpmnkit/blob/main/scripts/published-packages.mjs), and the repo's own checks enforce both directions of it: nothing on the list may lack tests or a documentation page, and nothing at 1.0.0 or above may be missing from the list. A major version cannot arrive by accident. Whatever this page says, a package's current version on [npm](https://www.npmjs.com/org/bpmnkit) is the authoritative answer. --- ### Conformance What BPMN Kit implements of BPMN 2.0, DMN 1.3, FEEL and Camunda Forms — measured where a measurement exists, and with the gaps listed. Source: https://bpmnkit.com/docs/getting-started/conformance This page says how much of each standard BPMN Kit implements, and how that was checked. Where there is a public test suite the number comes from it. Where there is not, the page lists what is supported and what is missing, and links to the test that enforces it. Everything below describes the current `main` branch. ## FEEL — 94.5% of the DMN TCK, 375 of 378 Camunda examples `@bpmnkit/feel` passes **1,941 of the 2,053 FEEL test cases** in the [DMN Technology Compatibility Kit](https://dmn-tck.github.io/tck/). - The cases are extracted from a checkout of the TCK by `packages/feel/tasks/extract-tck-tests.mjs` and run by `packages/feel/tests/tck.test.ts`. - The [DMN TCK workflow](https://github.com/bpmnkit/bpmnkit/actions/workflows/dmn-tck.yml) runs them against the latest TCK every Monday. - The 112 cases that do not pass are listed in `KNOWN_FAILURES` in that test file, each with its reason. The run fails if a listed case starts passing, so the list cannot go stale. The TCK's own [results table](https://dmn-tck.github.io/tck/) scores whole DMN engines across every test, not only the FEEL cases. The figure above is not directly comparable with it, and BPMN Kit has not submitted results. **Camunda 8.** Zeebe evaluates FEEL with Camunda's own engine, which adds built-ins and behaviour DMN does not define (`assert`, `partition`, `context put` with a key path, `to json`, `fromAi`, …). There is no test suite for that dialect, so the measure is Camunda's documentation: `@bpmnkit/feel` matches **375 of the 378 worked examples** in the FEEL pages of the Camunda 8 docs. - `packages/feel/tests/camunda-parity.test.ts` reads the examples from `@bpmnkit/camunda-docspack` at test time, evaluates each one, and compares the result with the documented one. It runs with the package's normal tests. - Examples that cannot run standalone — signatures, results written in prose, anything that reads the clock — are skipped. `node packages/feel/tasks/extract-camunda-examples.mjs --skipped` lists each one with its reason. - The three that differ are in `KNOWN_DIFFERENCES` in that file, and on the [FEEL page](/docs/packages/feel#camunda-parity): `round up` without a scale (twice), and moving a date and time without a zone into another zone. - Camunda fails an evaluation that errors. This package returns `null`, as DMN specifies. ## BPMN 2.0 — model and round trip `@bpmnkit/core` parses and writes BPMN 2.0 XML. Coverage is measured against the BPMN, BPMN DI and Zeebe moddle descriptors vendored into the repository, which list every type the standard and Camunda 8 define (151 in total): | Status | Types | Meaning | |---|---|---| | Modelled | 109 | Parsed into typed objects and written back | | Preserved | 34 | Not modelled, but kept verbatim on round trip | | Dropped | 6 | Lost on round trip — see below | | Unprobed | 2 | Not reachable from `bpmn:definitions` | The dropped types are the data-association internals (`Assignment`, `DataAssociation`, `FormalExpression` inside one, `DataState`, `ItemAwareElement`) and `ImplicitThrowEvent`. The `packages/core/tests/descriptor-coverage.test.ts` gate fails the build if a new type would be dropped silently; `pnpm --filter @bpmnkit/core check:descriptors` prints the full report. [Round-trip fidelity](/docs/getting-started/concepts#round-trip-fidelity) lists the deliberate normalisations and the children that are still not preserved. ## BPMN Model Interchange (MIWG) The [OMG BPMN Model Interchange Working Group test suite](https://github.com/bpmn-miwg/bpmn-miwg-test-suite) is how BPMN tools show they can read and write each other's files. Its reference models cover layout (`A.*`), the descriptive and analytic conformance classes (`B.*`) and complex real-world scenarios (`C.*`). They were exported by Trisotech, Signavio, W4, BOC, itp commerce and others, so they carry each vendor's namespaces, label styles and optional attributes. All 22 reference models are part of the round-trip corpus (suite commit `2ff82d4`, 2026-09-21) and are checked on every build: | Check | Result | |---|---| | Import (parse) | 22 / 22 | | Export re-imports to the same model | 22 / 22 | | Export is a fixed point (a second round trip changes nothing) | 22 / 22 | | Semantic content preserved | 22 / 22 | | Diagram interchange preserved (bounds, waypoints, label styles, vendor DI attributes) | 22 / 22 | | `exportPreserving` of an unchanged model is byte-identical | 22 / 22 | What a plain export changes is listed per model in `ALLOWED` in `packages/core/tests/roundtrip-corpus.test.ts`: - `false` defaults such as `isForCompensation="false"` are not written back; - an empty `` is not written back. Neither loses content. The comparison is keyed by namespace, so ``, `` and `` count as the same element. Running the suite found seven models BPMN Kit could not open and several losses, all fixed before these results were recorded: - DI elements without the optional `id` or `bpmnElement` would not parse; - a document with BPMN as its default namespace was written back as invalid XML; - a default flow on an activity was dropped; - so were documentation on sequence flows, the name of `` and of a collaboration, and empty timer and condition expressions; - extensions on data associations and label styles were lost; - so were the `id` and `textFormat` attributes of ``. The results are BPMN Kit's own run of the suite's models, not a submission to the MIWG, which publishes vendor-submitted results separately. ## BPMN elements by component | Elements | Model | Renderer (`canvas`) | Editor palette | TS simulator (`engine`) | |---|---|---|---|---| | Service, user, script, business rule tasks | ✓ | ✓ | ✓ | Executed | | Send, receive, manual and plain tasks | ✓ | ✓ | ✓ | Pass through | | Exclusive, parallel, inclusive gateways | ✓ | ✓ | ✓ | Executed; inclusive joins do not wait | | Event-based, complex gateways | ✓ | ✓ | ✓ | Event-based executed; complex splits like inclusive, without its activation condition | | Embedded sub-process, transaction | ✓ | ✓ | ✓ | Executed (child scope); no transaction cancel events | | Event sub-process, ad-hoc sub-process, call activity | ✓ | ✓ | ✓ | Event sub-process executed; call activity executed when the called process is deployed in the same engine; ad-hoc sub-process with a job worker executed (job results activate its tools), without one it passes through | | Start / end: none, terminate, error | ✓ | ✓ | ✓ | Executed | | Timer and message catch events | ✓ | ✓ | ✓ | Executed | | Timer (interrupting) and error boundary events | ✓ | ✓ | ✓ | Executed, including errors thrown by job workers | | Signal, escalation, compensation, conditional, link events | ✓ | ✓ | ✓ | Executed, except conditional events (not evaluated) | | Message and non-interrupting boundary events | ✓ | ✓ | ✓ | Executed (timer, message, signal, escalation) | | Multi-instance and loop markers | ✓ | ✓ | Properties panel | Multi-instance executed | | Data objects, data stores, associations | ✓ | ✓ | — | — | | Groups, text annotations | ✓ | ✓ | ✓ | — | | Pools, lanes, message flows | ✓ | ✓ | ✓ | — | | Choreography and conversation diagrams | — | — | — | — | The TypeScript simulator is for tests, demos and step-through debugging. It follows Zeebe's rules where it implements an element — error and escalation propagation through scopes and call activities, variable propagation and mappings, multi-instance variables and completion conditions — and `packages/engine/tests/semantics.test.ts` checks each one. It is not checked against Zeebe itself, and it differs in the places the table notes: a call activity can only call a process deployed in the same `Engine`, and it runs compensation handlers one at a time in reverse order, as BPMN specifies, where Zeebe starts them all at once. For Zeebe semantics, `@bpmnkit/engine/wasm-runner` runs the same scenarios on **Reebe** compiled to WebAssembly. Reebe's model covers the task types, call activities, embedded and event sub-processes, exclusive, parallel, inclusive and event-based gateways, catch, throw and boundary events (timer, message, signal, error, escalation, terminate, link, compensation) and multi-instance. Errors and escalations, from end events, throw events and job workers, propagate out through sub-processes and call activities to a boundary event or an event sub-process, which receives the variables a job worker threw the error with. An uncaught error raises an incident. An exclusive gateway with no matching condition and no default flow raises an incident, as does an inclusive split, and resolving it evaluates the gateway again; resolving any incident raised while an element was activating retries that element instance. A gateway condition that does not evaluate to a boolean (a missing variable is `null`) raises an `EXTRACT_VALUE_ERROR` incident instead of counting as false, and resolving it evaluates the gateway again. Parallel gateways, and every element other than an exclusive or inclusive gateway, ignore conditions on their outgoing flows, as Zeebe does. A link throw event continues at the link catch event of its name in the same scope, and deployment rejects links that do not pair up. A compensation throw or end event starts, all at once, the handlers of the activities that completed in its scope and in the completed sub-processes inside it (or only `activityRef`), and waits for them; a throw event in an event sub-process compensates the event sub-process and the scope around it. Every handler runs in the throw event's scope, as in Zeebe, and, as in Zeebe, only a handler that completes releases the throw event: one terminated on its own leaves it waiting. Timer, message and signal boundary events are armed when their activity starts and cancelled when it ends. An interrupting one terminates the activity; a non-interrupting one leaves it running, and a timer cycle repeats. An event-based gateway waits for the first of its events and cancels the others. The timer, message and signal start events of event sub-processes are armed when their process or sub-process starts and disarmed when it ends. An interrupting event sub-process terminates the rest of its scope and triggers once; a non-interrupting one runs alongside, as often as its event occurs. An inclusive gateway takes every flow whose condition holds, or its default flow, and its join waits until no token in the scope can still reach an incoming flow that has none. A token waiting at a parallel or inclusive join keeps its scope active, as in Zeebe, even if the join can never fire. Deploying a process schedules its timer start events (a date, a repeating interval or a cron expression) and subscribes its message start events; a timer firing or a matching message creates an instance, at most one active instance per message correlation key, and a new version replaces the previous version's timers and subscriptions. A sub-process or process instance completes only when nothing inside it is active any more, and a terminate end event ends the rest of its own scope and completes that scope. Multi-instance runs, in parallel or in sequence, on every task type, sub-process and call activity. Each instance has its own `inputElement` and `loopCounter`, the output is collected in input order, and a `completionCondition` ends the loop early. A multi-instance or ad-hoc `completionCondition` that does not evaluate to a boolean raises an `EXTRACT_VALUE_ERROR` incident, with Zeebe's message, on the instance that was completing (for an ad-hoc sub-process, the activation or the event sub-process inside it that was completing); resolving it evaluates the condition again. An ad-hoc `completionCondition` is evaluated in the ad-hoc sub-process's own scope, as in Zeebe. Undefined and manual tasks pass through, and a flow element written as an empty tag (``) is read like one with children. A complex gateway fails deployment, as in Zeebe, which does not execute it. An ad-hoc sub-process activates its inner elements, each in its own activation, from `activeElementsCollection` or from the job result of its job worker implementation (such as the AI Agent Sub-process), completes by its `completionCondition` or the job result, and creates the job again after each activation. It creates the `adHocSubProcessElements` variable with the elements it can activate and their `fromAi()` parameters in Zeebe's shape: a parameter is named by its whole reference (`toolCall.orderId`), a `fromAi()` call on any reference is listed, and a field that is null or empty is left out. The TypeScript engine gives the same shape, and a test checks that the two agree. A `fromAi()` call that Zeebe rejects at deployment (a value that is not a reference, a description or type that is not a string literal, a schema or options that is not a context of literals, `null` included) fails the deployment with Zeebe's message, on Reebe and in the TypeScript engine. The gRPC calls pass their `variables` documents on as variables and reject a document that is not a JSON object, as Zeebe's gateway does, and Reebe's REST API can activate elements of an active one (`POST /v2/element-instances/ad-hoc-activities/{key}/activation`). Process instance modification (REST `POST /v2/process-instances/{key}/modification` and gRPC `ModifyProcessInstance`) activates elements, with ancestor selection and variables, terminates element instances and moves them, with Zeebe's rules and rejection messages. A redeployed DMN gets a new version when its content changes and keeps its version when it does not, and a decision evaluates by its id (the latest version) or by its key. Every scenario of the [template gallery](/docs/guides/templates) passes on it. Reebe is a dev/test engine in the [Experimental tier](/docs/getting-started/stability#product-tiers), not for production. It is a clean-room implementation of the Zeebe API written from Camunda's public documentation, it is not affiliated with Camunda, and its behaviour is checked by its own tests rather than against Zeebe. "Zeebe" and "Camunda" are trademarks of Camunda Services GmbH. ## Zeebe extensions Every `zeebe:` element in the vendored Zeebe descriptor is modelled or preserved, and 26 of them are checked for placement — which BPMN element each may appear on — by `packages/core/src/bpmn/zeebe-placement.ts`. That includes task definitions, IO mappings, task headers, called decisions and elements, forms, user tasks, scripts, linked resources, ad-hoc sub-process and AI agent settings, version tags, and execution and task listeners. [Element templates](/docs/packages/connectors) (Camunda's connector template JSON) are applied for outbound and inbound connectors. `applyTemplateToElement` writes the inbound bindings to the element's message and its `zeebe:subscription` correlation key, where Camunda reads them, and applies `zeebe:linkedResource` bindings. Camunda 7 (`camunda:` extensions) is not modelled: those attributes and elements are kept on round trip, including those on multi-instance loops and event definitions. `convertCamunda7()` / `casen migrate c7` convert Camunda 7 models to Camunda 8 and report what needs manual work. See [Migrate from Camunda 7](/docs/guides/migrate-from-camunda-7). ## Linting — bpmnlint compatibility `casen lint` and the VS Code extension read a project's `.bpmnlintrc`. All 28 of bpmnlint's built-in rules map onto BPMN Kit findings exactly, in the top-level process and inside every kind of sub-process. When the project has bpmnlint installed, that bpmnlint runs the configuration itself, so `bpmnlint-plugin-*` rules work too. [bpmnlint Compatibility](/docs/guides/bpmnlint) has the rule-by-rule table. On the 43 `.bpmn` files in this repository under `bpmnlint:all`, the MIWG models included, every rule reports the same elements as bpmnlint. A test checks this on every run. ## DMN 1.3 - Decision tables with all seven hit policies (`UNIQUE`, `FIRST`, `ANY`, `COLLECT`, `RULE ORDER`, `OUTPUT ORDER`, `PRIORITY`) and the `COLLECT` aggregations (`SUM`, `MIN`, `MAX`, `COUNT`). - Decision requirements graphs: input data, business knowledge models, knowledge sources, and information, knowledge and authority requirements, with DMNDI layout. - Input and output `typeRef` is limited to `string`, `boolean`, `number` and `date`. - Not modelled: literal-expression decisions and the boxed expressions of DMN 1.3 (context, relation, list, invocation, function definition). ## Camunda Forms Camunda form JSON at schema version 16, with 22 component types: text, text field, text area, number, date/time, select, radio, checkbox, checklist, tag list, group, dynamic list, table, image, document preview, iframe, HTML, expression, file picker, button, separator and spacer. ## Known gaps, in one list - Choreography and conversation diagrams - Camunda 7 extensions (preserved, not modelled; `casen migrate c7` converts them to Camunda 8) - DMN boxed expressions and literal-expression decisions - TS simulator: conditional events, message start events of a top-level process, transaction cancel events, compensation event sub-processes, inclusive and complex joins (they do not wait), complex gateway activation conditions, and inner activities of ad-hoc sub-processes without a job worker (`activeElementsCollection` is not evaluated) - Reebe: a decision evaluation reports no `evaluatedDecisions` (matched rules and inputs); a redeployed BPMN process always gets a new version, even when it has not changed; single-node only, no published comparison with Zeebe, and no published performance figures Found something this page gets wrong? [Open an issue](https://github.com/bpmnkit/bpmnkit/issues). --- ## Documentation — Guides ### Building Processes Service tasks, user tasks, events, sub-processes, boundary events, multi-process diagrams, and multi-instance patterns. Source: https://bpmnkit.com/docs/guides/building-processes ## Service Tasks A service task represents work done by a system. In Camunda 8 / Zeebe, a job worker picks up the task and completes it: ```typescript .serviceTask("charge-card", { name: "Charge Credit Card", taskType: "payment-charge", // worker subscribes to this type taskHeaders: { retries: "3", }, inputMappings: [ { source: "= amount", target: "chargeAmount" }, { source: "= card.token", target: "token" }, ], outputMappings: [ { source: "= transactionId", target: "paymentTransactionId" }, ], }) ``` ## User Tasks A user task waits for a human actor. Optionally attach a Camunda form: ```typescript .userTask("review-order", { name: "Review Order", assignee: "= initiator", // FEEL expression candidateGroups: "approvers", formKey: "camunda-forms:bpmn:review-form", dueDate: "= now() + duration(\"P2D\")", }) ``` ## Events ### Start Events ```typescript // None start .startEvent("start") // Timer start (runs on a schedule) .startEvent("start-daily", { timer: { timeCycle: "R/PT24H" }, // ISO 8601 repeating interval }) // Message start .startEvent("start-on-order", { message: { name: "order-received", correlationKey: "= orderId" }, }) ``` ### Intermediate Events ```typescript // Catch a timer (delay) .intermediateCatchEvent("wait-1h", { timer: { timeDuration: "PT1H" }, }) // Catch a message (wait for external signal) .intermediateCatchEvent("wait-for-payment", { message: { name: "payment-confirmed", correlationKey: "= orderId" }, }) // Throw a message .intermediateThrowEvent("notify-warehouse", { message: { name: "order-ready" }, }) ``` Link events are a "go-to" inside one process or sub-process: a link throw continues at the link catch with the same name. The throw has no outgoing flow and the catch no incoming one, so the builder stops chaining at the throw and starts a new chain at the catch: ```typescript .serviceTask("check", { name: "Check", taskType: "check" }) .intermediateThrowEvent("to-retry", { linkName: "Retry" }) // nothing chains from here .intermediateCatchEvent("from-retry", { linkName: "Retry" }) // starts a new chain .serviceTask("retry", { name: "Retry", taskType: "retry" }) ``` ### End Events ```typescript // Normal end .endEvent("end") // Error end (triggers error boundary event) .endEvent("end-error", { error: { code: "PAYMENT_FAILED", message: "Payment processing failed" }, }) // Message end .endEvent("end-notify", { message: { name: "process-complete" }, }) ``` ## Sub-Processes Embed a child process inline. Sub-processes support the full builder API including gateways and branching: ```typescript .subProcess("handle-payment", (sub) => sub .startEvent("sub-start") .serviceTask("charge", { taskType: "payment-charge" }) .exclusiveGateway("charge-ok?") .branch("success", (b) => b.condition("= success").serviceTask("receipt", { taskType: "send-receipt" }).endEvent("sub-end") ) .branch("failure", (b) => b.defaultFlow().endEvent("sub-failed", { error: { code: "CHARGE_FAILED" } }) ) ) ``` ### Event Sub-Processes An event sub-process starts when a boundary event fires: ```typescript .eventSubProcess("compensation", { triggeredByEvent: true, startEvent: { interrupting: false, error: { code: "PAYMENT_FAILED" }, }, }, (sub) => sub .serviceTask("refund", { taskType: "issue-refund" }) .endEvent("refunded") ) ``` ## Boundary Events ### `.withBoundary()` — recommended `.withBoundary()` attaches a boundary event to the preceding task, lets you build the error/timeout path inline, then **automatically restores the cursor to the original task** so the main flow continues naturally: ```typescript .serviceTask("charge", { name: "Charge Card", taskType: "payment-charge" }) .withBoundary("on-fail", { errorCode: "PAYMENT_FAILED", cancelActivity: true }, (p) => p .serviceTask("notify", { taskType: "send-email" }) .endEvent("end-failed"), ) // cursor is back on "charge" — main flow continues here .serviceTask("fulfill", { name: "Fulfill Order", taskType: "warehouse-pick" }) .endEvent("end-ok") ``` Timer boundaries work the same way: ```typescript .serviceTask("slow-task", { taskType: "long-job" }) .withBoundary("on-timeout", { timerDuration: "PT30S", cancelActivity: false }, (p) => p.serviceTask("escalate", { taskType: "alert" }).endEvent("escalated"), ) .serviceTask("next-task", { taskType: "continue" }) ``` ### `.boundaryEvent()` — lower-level Use `.boundaryEvent()` directly when you need precise cursor control. It moves the builder cursor to the boundary event itself: ```typescript .serviceTask("process-order", { taskType: "order-processor" }) .boundaryEvent("timeout", { attachedTo: "process-order", cancelActivity: true, timer: { timeDuration: "PT30M" }, }) .endEvent("timed-out") ``` ## Call Activities Invoke a separate (reusable) process definition: ```typescript .callActivity("run-subprocess", { name: "Run Fulfillment Sub-Process", calledElement: "fulfillment-process", propagateAllChildVariables: false, inputMappings: [ { source: "= orderId", target: "orderId" }, ], outputMappings: [ { source: "= trackingNumber", target: "trackingNumber" }, ], }) ``` ## Multi-Process Diagrams `Bpmn.createDiagram()` assembles multiple processes into a single definitions document. This is useful for caller/callee pairs or any workflow that references another process: ```typescript import { Bpmn } from "@bpmnkit/core"; const defs = Bpmn.createDiagram("OrderSystem") .process("order-flow", (p) => p .startEvent("start") .callActivity("run-payment", { processId: "payment-flow" }) .endEvent("end"), ) .process("payment-flow", (p) => p .startEvent("s") .serviceTask("charge", { name: "Charge", taskType: "payment-charge" }) .endEvent("e"), ) .build(); const xml = Bpmn.export(defs); // both processes in one XML file ``` ## Task Defaults `.defaults()` sets process-wide defaults applied to all subsequent task calls. Useful when every service task in a process should share the same retry policy: ```typescript Bpmn.createProcess("my-flow") .defaults({ serviceTask: { retries: "5" } }) .startEvent("s") .serviceTask("t1", { name: "Task 1", taskType: "worker-a" }) // retries: "5" .serviceTask("t2", { name: "Task 2", taskType: "worker-b" }) // retries: "5" .serviceTask("t3", { name: "Task 3", taskType: "worker-c", retries: "1" }) // override .endEvent("e") .build() ``` ## Multi-Instance Run a task or sub-process once per item in a collection: ```typescript .serviceTask("notify-all", { name: "Notify Each Customer", taskType: "send-email", multiInstance: { parallel: true, // false = sequential inputCollection: "= customers", inputElement: "customer", outputCollection: "results", outputElement: "= { sent: true, email: customer.email }", }, }) ``` ## Generated IDs You name every flow node. The builder names everything else, and it derives those names from the model so that rebuilding an unchanged process produces unchanged BPMN — a diff that shows only what you actually changed. A sequence flow is named after the two elements it connects: ```typescript Bpmn.createProcess("orders") .startEvent("start") .serviceTask("validate", { taskType: "validate" }) .endEvent("done") .build() // Flow_start_validate, Flow_validate_done ``` When several flows connect the same pair — the branches of a gateway converging on one join — each gets a discriminator taken from the branch name, or from its condition when the branch is unnamed: ```typescript .exclusiveGateway("gw") .branch("approved", (b) => b.connectTo("done")) .branch("rejected", (b) => b.connectTo("done")) // Flow_gw_gw_join_approved, Flow_gw_gw_join_rejected ``` Because the id comes from the connection rather than from a counter, adding or reordering an unrelated element leaves the other flows' ids alone. The same is true of root definitions created from event options — `Message_Order_Received` for `messageName: "Order Received"`, and likewise `Error_`, `Signal_` and `Escalation_` from the code or name they carry. Two consequences worth knowing: - **Ids are only as stable as the elements they name.** Omitting an element id (`.startEvent()` with no argument) gets you a generated one that changes on every build, and the flow ids around it inherit that. Name the nodes you care about. - **`defaultFlow` becomes predictable.** Setting a gateway's default by id no longer requires knowing a random value: `{ defaultFlow: "Flow_gw_gw_join_rejected" }`. `.branch().defaultFlow()` is still the shorter way to say it. `continueProcess()` never renumbers what the document arrived with: flows the builder did not create keep their ids, and new ones avoid them. ### Naming a root definition yourself When the id of a message, error, signal or escalation matters — because a message flow in another pool, a worker, or a deployed process already refers to it — declare it before the events that use it: ```typescript Bpmn.createProcess("orders") .message("Msg_OrderReceived", { name: "Order Received" }) .error("Err_OutOfStock", { code: "OUT_OF_STOCK", name: "Out of stock" }) .signal("Sig_Cancelled", { name: "Order Cancelled" }) .escalation("Esc_Review", { code: "NEEDS_REVIEW" }) .startEvent("start", { messageName: "Order Received" }) // messageRef: Msg_OrderReceived ``` Events still name these by name or code, exactly as before — the declaration only decides the id they resolve to. Order matters: an event built first declares the definition itself, and declaring it afterwards under a different id throws rather than leaving two definitions of one message behind. --- ### Gateways & Branching Exclusive, parallel, inclusive, and event-based gateways with condition expressions. Source: https://bpmnkit.com/docs/guides/gateways ## Exclusive Gateway (XOR) Only one outgoing path is taken — the first branch whose condition evaluates to `true`. One branch should always be the default to handle the fallthrough case: ```typescript import { Bpmn } from "@bpmnkit/core"; const xml = Bpmn.export( Bpmn.createProcess("approval-flow") .startEvent("start", { name: "Request Submitted" }) .userTask("review", { name: "Review Request" }) .exclusiveGateway("approved?", { name: "Approved?" }) .branch("yes", (b) => b.condition("= approved") .serviceTask("notify", { taskType: "send-email", name: "Send Approval" }) .endEvent("done") ) .branch("no", (b) => b.defaultFlow() .endEvent("rejected", { name: "Request Rejected" }) ) .withAutoLayout() .build() ); ``` ### Conditions Branch conditions are [FEEL expressions](https://docs.camunda.io/docs/components/modeler/feel/): ```typescript .branch("high-value", (b) => b.condition("= amount > 10000") .serviceTask("manual-review", { taskType: "escalate" }) .endEvent("escalated") ) ``` ## Parallel Gateway (AND) All outgoing paths run concurrently. A matching parallel join gateway waits for all paths to complete before continuing: ```typescript const xml = Bpmn.export( Bpmn.createProcess("order-fulfillment") .startEvent("start") .parallelGateway("split") .branch("warehouse", (b) => b.serviceTask("pick", { taskType: "warehouse-pick", name: "Pick Items" }) ) .branch("payment", (b) => b.serviceTask("charge", { taskType: "payment-charge", name: "Charge Card" }) ) .parallelGateway("join") // waits for all branches .serviceTask("ship", { taskType: "shipping", name: "Ship Order" }) .endEvent("end") .withAutoLayout() .build() ); ``` ## Inclusive Gateway (OR) One or more outgoing paths are taken based on conditions. All active paths converge at the matching inclusive join: ```typescript .inclusiveGateway("options") .branch("express", (b) => b.condition("= expressShipping").serviceTask("fedex", { taskType: "fedex-ship" }) ) .branch("gift-wrap", (b) => b.condition("= giftWrap").serviceTask("wrap", { taskType: "wrap-items" }) ) .branch("standard", (b) => b.defaultFlow().serviceTask("standard-ship", { taskType: "usps-ship" }) ) .inclusiveGateway("join") .endEvent("end") ``` ## Event-Based Gateway Waits for the _first_ of several events to occur, then takes that path: ```typescript .eventBasedGateway("wait-for-event") .branch("payment", (b) => b.intermediateCatchEvent("payment-received", { message: { name: "payment-confirmed", correlationKey: "= orderId" }, }) .endEvent("paid") ) .branch("timeout", (b) => b.intermediateCatchEvent("payment-timeout", { timer: { timeDuration: "PT24H" }, }) .endEvent("cancelled") ) ``` ## Branching Inside Sub-Processes The full gateway and branching API works inside `subProcess()` callbacks too: ```typescript .subProcess("handle-request", (sub) => sub .startEvent("s") .exclusiveGateway("route") .branch("fast-path", (b) => b.condition("= priority == \"high\"") .serviceTask("express", { taskType: "express-handler" }) .endEvent("done-fast"), ) .branch("normal", (b) => b.defaultFlow() .serviceTask("standard", { taskType: "standard-handler" }) .endEvent("done-normal"), ) ) ``` Auto-join gateway insertion applies inside sub-processes the same way it does at the top level — converging branches automatically get a matching join gateway. ## Nested Branching Branches can contain further gateways: ```typescript .exclusiveGateway("route") .branch("enterprise", (b) => b.condition("= tier == \"enterprise\"") .parallelGateway("enterprise-split") .branch("account-mgr", (b2) => b2.userTask("assign-am", { name: "Assign Account Manager" }) ) .branch("onboarding", (b2) => b2.serviceTask("kick-off", { taskType: "onboarding-kit" }) ) .parallelGateway("enterprise-join") .endEvent("enterprise-done") ) .branch("self-serve", (b) => b.defaultFlow() .serviceTask("auto-setup", { taskType: "self-serve-setup" }) .endEvent("self-serve-done") ) ``` --- ### Pattern Library Domain BPMN patterns that Claude uses as context when implementing processes with /bpmnkit:implement. Source: https://bpmnkit.com/docs/guides/patterns The pattern library (`@bpmnkit/patterns`) provides domain knowledge for common business processes. When you run `/bpmnkit:implement`, Claude checks the library for a matching pattern (`casen pattern list`/`get`) and uses its readme and worker specs as context while writing the `ProcessPlan` — the pattern's own `template` field predates the plan/synth pipeline and is a rough structural reference, not something pasted in directly. Patterns are hints, not templates. Claude adapts them to the specific request and ignores them entirely when nothing relevant matches. ## Available patterns | Pattern ID | Domain | Typical use | |---|---|---| | `invoice-approval` | Finance / accounts payable | Multi-level invoice review and ERP payment trigger | | `employee-onboarding` | HR | Account provisioning, orientation scheduling, system access | | `supplier-contract-review` | Procurement / legal | Contract classification, risk scan, CLM storage, e-signature | | `incident-response` | IT / ops | Severity classification, on-call paging, post-mortem creation | | `loan-origination` | Financial services | Identity verification, credit check, risk scoring, disbursement | | `content-moderation` | Trust & safety | AI scan, action enforcement, CSAM reporting, user notification | | `order-fulfillment` | E-commerce / supply chain | Inventory validation, payment, warehouse order, shipment tracking | ## How matching works `casen pattern get ` (via `findPattern()`) scores each pattern by counting how many of its keywords appear in the request. The highest-scoring pattern is selected. Exact pattern ID matches take priority over keyword scoring. Examples: | Request | Matched pattern | |---|---| | "invoice approval workflow" | `invoice-approval` | | "employee onboarding with Okta and Jira" | `employee-onboarding` | | "on-call incident escalation" | `incident-response` | | "custom blockchain process" | _(no match — Claude works from scratch)_ | ## What a pattern contains Each pattern has four components: **README** — domain context, common variations, relevant regulations, and conventions. Claude reads this before generating the BPMN. **Template** — a compact BPMN template in the BPMNKit intermediate format (not raw XML). Used as a starting-point structure, not a fixed output. **Worker specs** — typical service tasks with job type, inputs, outputs, and real integration options (e.g. "SAP, NetSuite, or QuickBooks" for a payment trigger). **Variations** — common process variants (e.g. "3-way match", "auto-approve below threshold") so Claude can adapt the flow to the user's specific context. ## Using patterns from the CLI ```sh casen pattern list casen pattern get invoice-approval casen pattern get "employee onboarding workflow" # free-text match ``` ## Patterns and templates The same package has a second export, `@bpmnkit/patterns/templates`. It holds the [runnable process templates](/docs/guides/templates) behind the [gallery](/templates): complete, deployable processes with test scenarios. A pattern gives an AI model context to write its own process. A template is a finished process you copy and change. Use `casen pattern` for patterns and `casen template` for templates. ## Using patterns from code You can access the pattern library directly in TypeScript: ```typescript import { ALL_PATTERNS, findPattern } from "@bpmnkit/patterns" // List all patterns console.log(ALL_PATTERNS.map((p) => p.id)) // Find by keyword match const pattern = findPattern("employee onboarding workflow") console.log(pattern?.id) // "employee-onboarding" // Find by exact ID const invoice = findPattern("invoice-approval") console.log(invoice?.workers.map((w) => w.jobType)) ``` ## Pattern schema ```typescript interface Pattern { id: string name: string description: string keywords: string[] readme: string // domain context, Markdown workers: WorkerSpec[] variations: string // common customizations, Markdown template: PatternTemplate // compact BPMN structure — a rough reference, predates ProcessPlan } interface WorkerSpec { name: string jobType: string description: string inputs: Record outputs: Record externalApis?: string[] // e.g. ["Stripe", "Adyen", "Braintree"] optional?: boolean } ``` ## Adding custom patterns Create a new pattern file in `packages/patterns/src/patterns/` and export it from `index.ts`. Follow the existing patterns as a reference — each is a single TypeScript file that exports a `Pattern` object. For private or organisation-specific patterns, add them to your project and contribute the pattern object to `ALL_PATTERNS` via the `findPattern` API. Contributions to the seed library are welcome via pull request. ## See also - [Building Processes with AI](/docs/guides/ai-implement) — how Claude uses patterns during `/bpmnkit:implement` - [`@bpmnkit/patterns` source](https://github.com/bpmnkit/bpmnkit/tree/main/packages/patterns) --- ### Process Templates 25 runnable Camunda 8 process templates — business processes and AI agent patterns — with test scenarios, from the gallery, the editor or casen template use. Source: https://bpmnkit.com/docs/guides/templates The [template gallery](/templates) holds 25 Camunda 8 processes you can start from. Each one is built with the `@bpmnkit/core` builder and laid out automatically. Job types, IO mappings, correlation keys, timers and error codes are already set. It lints with no errors, and it comes with test scenarios: a happy path and at least one alternative path. The templates live in `@bpmnkit/patterns/templates`. The package's tests build every template, lint it, round-trip it through XML and run each scenario on `@bpmnkit/engine`. A template that stops passing fails the build. ## What is in the gallery | Category | Templates | |---|---| | Order to cash | `order-to-cash`, `payment-collection` | | Approvals | `purchase-request-approval`, `expense-approval`, `contract-approval` | | Onboarding | `employee-onboarding`, `customer-kyc-onboarding` | | Incident & escalation | `incident-escalation`, `security-alert-triage` | | Document processing | `invoice-capture`, `document-signature`, `document-classification` | | SLA & timers | `support-ticket-sla`, `scheduled-report` | | Sagas & error handling | `travel-booking-saga`, `payment-saga` | | Human in the loop | `four-eyes-review`, `content-review` | | AI agent patterns | `ai-prompt-chaining`, `ai-routing`, `ai-parallelization`, `ai-orchestrator-workers`, `ai-evaluator-optimizer`, `ai-human-approval-gate`, `ai-agent-tool-loop` | The AI agent patterns follow Anthropic's [Building effective agents](https://www.anthropic.com/engineering/building-effective-agents) taxonomy. Each fixed step is one model call through Camunda's **AI Agent Task** connector (`io.camunda.agenticai:aiagent:1`). The orchestrator–workers and tool-loop templates use the **AI Agent Sub-process** connector (`io.camunda.agenticai:aiagent-job-worker:1`). That connector is an ad-hoc sub-process whose tools are the activities inside it. The model decides which tools to call and when. ## Use a template Pick one of three ways from a template's page: - **Open in editor** opens the diagram in the [browser editor](/editor). The link is `/editor?template=`, and it only accepts a template id from the gallery. - **Download .bpmn** saves the process model. The page also links the scenarios file and any DMN or form files. - **`casen template use`** writes all the files into your project: ```sh casen template list # all templates casen template list --category ai-agents # one category casen template use purchase-request-approval processes/ ``` ```text ✓ Wrote processes/purchase-request-approval.bpmn ✓ Wrote processes/purchase-request-approval.bpmn.tests.json ✓ Wrote processes/approval-matrix.dmn ``` `casen template use` does not overwrite a file that already exists. Pass `--force` to overwrite it. Then deploy each file with `casen deploy deploy --target camunda8`, or deploy them from the editor. ## Run the scenarios The `.bpmn.tests.json` file has the scenario format that `runScenario` in `@bpmnkit/engine` reads. Each scenario sets the start variables and the mocked job workers, keyed by job type. Native user tasks are keyed `userTask`. The scenario also sets the element path and the variables to expect. To run them in a Vitest or Node test: ```typescript import { readFileSync } from "node:fs" import { Bpmn, Dmn } from "@bpmnkit/core" import { Engine, runScenario } from "@bpmnkit/engine" import type { ProcessScenario } from "@bpmnkit/engine" const defs = Bpmn.parse(readFileSync("processes/purchase-request-approval.bpmn", "utf8")) const scenarios = JSON.parse( readFileSync("processes/purchase-request-approval.bpmn.tests.json", "utf8"), ) as ProcessScenario[] for (const scenario of scenarios) { const engine = new Engine() // runScenario deploys the BPMN; deploy the decisions it calls yourself. engine.deploy({ decisions: Dmn.parse(readFileSync("processes/approval-matrix.dmn", "utf8")) }) const result = await runScenario(engine, defs, scenario) console.log(result.passed ? "PASS" : "FAIL", scenario.name, result.failures) } ``` What the scenarios can and cannot exercise follows from what the TypeScript engine runs (see [Conformance](/docs/getting-started/conformance)): - **Receive tasks** pass through in the simulator, so the scenario gives the message's variables as start inputs, for example `signatureStatus` in `document-signature`. On Camunda the receive task waits for the message with the correlation key set in the model. - **Timer boundary events**, such as the SLA and reminder timers, are in every model. No scenario takes a timer path, because a scenario completes each task at once, before a timer can fire. - **The AI Agent Sub-process** runs as one job in the simulator. The scenario mocks that job type and returns the agent's final `agent` result. The simulator does not run the tools inside the sub-process. - **The inclusive split** in `employee-onboarding` ends each branch at its own end event. The simulator does not synchronise an inclusive join. `casen test` runs the same scenarios on the Reebe WebAssembly engine, which has Zeebe's semantics. Every gallery scenario passes on both engines, and the package tests check both. Reebe runs the scenario in the same way as the simulator: - A native user task is completed with the outputs of the `userTask` mock. - A receive task gets its message at once, with the correlation key from the model and no variables. The scenario gives the message's variables as start inputs. - An AI Agent Sub-process runs as one job. Completing the job completes the sub-process. The tools inside it are not run. - An error end event throws its error code. The error boundary on the enclosing sub-process catches it, or an incident is raised. ## AI templates: model and secrets Every AI step sends its API key as `{{secrets.ANTHROPIC_API_KEY}}`. Add that connector secret to your cluster before you deploy. The model is set in one place in the package (`TEMPLATE_MODEL`). In a template you copied, change the `provider.anthropic.model.model` input. The AI Agent Sub-process templates also set an `errorExpression` header. It turns a connector failure, such as reaching the model-call limit, into the BPMN error `AGENT_FAILED`. An error boundary on the sub-process catches it, so a failure goes to a fallback path and does not become an incident. ## From code ```typescript import { ALL_TEMPLATES, TEMPLATE_CATEGORIES, getTemplate, listJobTypes, templateFiles, } from "@bpmnkit/patterns/templates" const template = getTemplate("ai-evaluator-optimizer") const defs = template?.build() // BpmnDefinitions, laid out const files = template ? templateFiles(template) : [] // [{ path, content }] — what casen writes const workers = defs ? listJobTypes(defs) : [] // job types your workers must serve ``` `build()` returns a new model on each call, so you can change it with `Bpmn.continueProcess` or the builder before you export it. ## Add a template Templates are TypeScript files in `packages/patterns/src/templates/`, one file per category. Add a `ProcessTemplate` object to the file, then add it to `ALL_TEMPLATES` in `index.ts`. The gallery test checks it with no other change: it must build, lint with no errors, round-trip, and pass every scenario. The gallery page, the `casen template` commands and `llms.txt` all read the same list. --- ### Simulation Run BPMN processes locally with @bpmnkit/engine — no Camunda cluster required. Source: https://bpmnkit.com/docs/guides/simulation The `@bpmnkit/engine` package is a zero-dependency BPMN simulation engine that runs in browsers and Node.js. It's useful for: - **Testing** process logic before deploying - **Prototyping** without a Camunda cluster - **CI pipelines** — unit-test your process definitions - **Demos** — run live in a browser ## Basic Usage ```typescript import { Engine } from "@bpmnkit/engine"; import { Bpmn } from "@bpmnkit/core"; // Build a process const xml = Bpmn.export( Bpmn.createProcess("order") .startEvent("start") .serviceTask("charge", { taskType: "payment" }) .serviceTask("ship", { taskType: "shipping" }) .endEvent("end") .build() ); // Create engine and deploy const engine = new Engine(); await engine.deploy({ bpmn: xml }); // Register workers engine.registerJobWorker("payment", async (job) => { console.log("Charging card...", job.variables); await job.complete({ transactionId: "txn-123" }); }); engine.registerJobWorker("shipping", async (job) => { console.log("Shipping order...", job.variables); await job.complete({ trackingNumber: "1Z999AA1" }); }); // Start an instance const instance = engine.start("order", { orderId: "ord-456", amount: 99.99, }); ``` ## Instance State The `ProcessInstance` object tracks the running state: ```typescript const instance = engine.start("order"); console.log(instance.state); // "running" | "completed" | "cancelled" console.log(instance.activeElements); // Set — currently active element IDs // Subscribe to state changes instance.onChange((newState) => { console.log("State changed to:", newState); }); ``` ## Variables Variables flow through the process via IO mappings. You can read the current snapshot: ```typescript const vars = instance.variables_snapshot; console.log(vars.orderId); // input variable console.log(vars.transactionId); // output from a task ``` ## Message Correlation Deliver a message to a waiting `intermediateCatchEvent`: ```typescript // Process has a catch event waiting for "payment-confirmed" await instance.deliverMessage("payment-confirmed", { paymentMethod: "card", confirmedAt: new Date().toISOString(), }); ``` ## Step-by-Step Execution Use the `beforeComplete` hook to pause execution between elements — useful for debugging and building animated process runners: ```typescript const instance = engine.start("order", {}, { beforeComplete: async (elementId) => { console.log("About to complete:", elementId); // Inspect state, update UI, etc. // Resume by returning from this function }, }); ``` ## Time Travel in the Editor The editor's play mode (the process-runner plugin) records every engine event of a run, and a timeline scrubber above its tabs lets you move back through them: - **Drag the scrubber** to any event. The Variables, FEEL and Errors tabs show the state as it was at that event, and the canvas redraws the tokens: elements holding a token then are marked active, elements already passed through are marked visited. - **Live** returns to the latest event, with the tokens as it left them. - **Re-run with these variables** starts a new run from the start event, with the variables as they were at the scrubbed event, which is the quickest way to try a different branch with the data a real run produced. The state is projected from the recorded log, not by re-executing the process, so scrubbing is instant and never calls a worker twice. A run keeps up to 10,000 events. ## DMN Decision Evaluation Deploy decision tables alongside the process: ```typescript await engine.deploy({ bpmn: xml, decisions: [dmnXml], // DMN 1.3 XML strings }); ``` ## Testing with Vitest Write deterministic unit tests for your process logic: ```typescript import { describe, it, expect } from "vitest"; import { Engine } from "@bpmnkit/engine"; import { buildOrderProcess } from "./processes.js"; describe("order process", () => { it("completes when payment succeeds", async () => { const engine = new Engine(); await engine.deploy({ bpmn: buildOrderProcess() }); let completed = false; engine.registerJobWorker("payment", async (job) => { await job.complete({ success: true }); }); engine.registerJobWorker("shipping", async (job) => { await job.complete({ trackingNumber: "TRK-001" }); }); const instance = engine.start("order", { amount: 50 }); await new Promise((resolve) => { instance.onChange((state) => { if (state === "completed") { completed = true; resolve(); } }); }); expect(completed).toBe(true); expect(instance.variables_snapshot.trackingNumber).toBe("TRK-001"); }); }); ``` ## Multiple Deployments The engine supports multiple deployed processes. Use `engine.getDeployedProcesses()` to list them: ```typescript await engine.deploy({ bpmn: processAXml }); await engine.deploy({ bpmn: processBXml }); const processes = engine.getDeployedProcesses(); // [{ id: "process-a", name: "..." }, { id: "process-b", name: "..." }] ``` --- ### Camunda 8 Deployment Deploy and manage BPMN processes on a live Camunda 8 cluster using @bpmnkit/api. Source: https://bpmnkit.com/docs/guides/deployment The `@bpmnkit/api` package is a fully-typed Camunda 8 REST API client. Use it to deploy processes, start instances, and manage your cluster from Node.js scripts, backend services, or CI/CD pipelines. ## Setup ### SaaS (Camunda 8 Cloud) ```typescript import { CamundaClient } from "@bpmnkit/api"; const client = new CamundaClient({ baseUrl: "https://api.cloud.camunda.io", auth: { type: "oauth2", clientId: process.env.CAMUNDA_CLIENT_ID, clientSecret: process.env.CAMUNDA_CLIENT_SECRET, audience: process.env.CAMUNDA_AUDIENCE, tokenUrl: process.env.CAMUNDA_TOKEN_URL, }, }); ``` ### Self-Managed ```typescript const client = new CamundaClient({ baseUrl: "http://localhost:8080", auth: { type: "bearer", token: process.env.ZEEBE_TOKEN, }, }); ``` ### No Auth (local dev) ```typescript const client = new CamundaClient({ baseUrl: "http://localhost:8080", auth: { type: "none" }, }); ``` ## Deploying a Process ```typescript import { Bpmn } from "@bpmnkit/core"; import { CamundaClient } from "@bpmnkit/api"; const xml = Bpmn.export( Bpmn.createProcess("invoice-approval") .startEvent("start") .userTask("review", { name: "Review Invoice" }) .endEvent("end") .withAutoLayout() .build() ); const result = await client.process.deploy({ resources: [ { content: xml, name: "invoice-approval.bpmn" }, ], }); console.log("Deployed version:", result.deployments[0]?.processDefinition?.version); ``` ## Starting Process Instances ```typescript const instance = await client.process.startInstance({ bpmnProcessId: "invoice-approval", variables: { invoiceId: "inv-1234", amount: 2500, submittedBy: "alice@example.com", }, }); console.log("Instance key:", instance.processInstanceKey); ``` ### With a Specific Version ```typescript const instance = await client.process.startInstance({ bpmnProcessId: "invoice-approval", version: 2, variables: { invoiceId: "inv-5678" }, }); ``` ## Handling Jobs Register a long-poll job worker to process service tasks: ```typescript const subscription = await client.jobs.activateAndProcess({ type: "send-email", maxJobsToActivate: 5, timeout: 30_000, worker: "email-service", handler: async (job) => { const { to, subject, body } = job.variables; await sendEmail({ to, subject, body }); await client.jobs.complete({ jobKey: job.key, variables: { sent: true, sentAt: new Date().toISOString() }, }); }, }); // Stop the worker subscription.close(); ``` ## Querying Instances ```typescript // List running instances const instances = await client.process.listInstances({ bpmnProcessId: "invoice-approval", state: "ACTIVE", }); // Get a specific instance const instance = await client.process.getInstance({ processInstanceKey: "2251799813685249", }); // Get variables const variables = await client.variables.list({ processInstanceKey: instance.key, }); ``` ## Managing Incidents ```typescript // List open incidents const incidents = await client.incidents.list({ state: "ACTIVE", }); // Resolve an incident (after fixing the underlying issue) await client.incidents.resolve({ incidentKey: incident.key }); ``` ## Lifecycle Events Use the TypedEventEmitter to react to API events: ```typescript client.on("request", (e) => { console.log(`→ ${e.method} ${e.url}`); }); client.on("response", (e) => { console.log(`← ${e.status} in ${e.durationMs}ms`); }); client.on("error", (e) => { metrics.increment("camunda.api.error", { url: e.url }); }); ``` ## CI/CD: Deploy on Push A typical GitHub Actions step: ```yaml - name: Deploy BPMN processes run: node scripts/deploy.mjs env: CAMUNDA_CLIENT_ID: ${{ secrets.CAMUNDA_CLIENT_ID }} CAMUNDA_CLIENT_SECRET: ${{ secrets.CAMUNDA_CLIENT_SECRET }} CAMUNDA_AUDIENCE: ${{ secrets.CAMUNDA_AUDIENCE }} CAMUNDA_TOKEN_URL: ${{ secrets.CAMUNDA_TOKEN_URL }} ``` ```typescript // scripts/deploy.mjs import { Bpmn } from "@bpmnkit/core"; import { CamundaClient } from "@bpmnkit/api"; import { readdir, readFile } from "node:fs/promises"; const client = new CamundaClient({ baseUrl: "https://api.cloud.camunda.io", auth: { type: "oauth2", clientId: process.env.CAMUNDA_CLIENT_ID, clientSecret: process.env.CAMUNDA_CLIENT_SECRET, audience: process.env.CAMUNDA_AUDIENCE, tokenUrl: process.env.CAMUNDA_TOKEN_URL, }, }); const files = await readdir("./processes"); const resources = await Promise.all( files .filter((f) => f.endsWith(".bpmn")) .map(async (f) => ({ name: f, content: await readFile(`./processes/${f}`, "utf8"), })) ); const result = await client.process.deploy({ resources }); console.log(`Deployed ${result.deployments.length} processes`); ``` --- ### Standalone Workers Build, run, and deploy TypeScript workers that connect to Zeebe without any BPMNKit dependency. Source: https://bpmnkit.com/docs/guides/workers-standalone Workers are TypeScript programs that poll Zeebe for jobs and execute business logic. They depend only on `@bpmnkit/worker-client` — no BPMNKit SDK required at runtime. Run them anywhere: terminal, Docker, serverless, or via `casen worker start`. ## Scaffold a worker The fastest way to get a worker is via the `/implement` skill, which scaffolds workers for every service task in a generated process. You can also scaffold manually using the MCP tool: ``` Call worker_scaffold with jobType: "com.example:send-invoice:1" ``` Or generate one directly from Claude Code: ``` /implement a worker that sends invoices via SendGrid ``` Either way, the result is a directory in `./workers/`: ``` workers/ send-invoice/ index.ts ← implement handle() here package.json tsconfig.json README.md ``` ## Anatomy of a worker ```typescript // workers/send-invoice/index.ts import { createWorkerClient } from "@bpmnkit/worker-client" const JOB_TYPE = "com.example:send-invoice:1" const WORKER_NAME = "send-invoice" const client = createWorkerClient({ workerName: WORKER_NAME }) interface Inputs { invoiceId: unknown // Invoice ID to send recipientEmail: unknown // Recipient email address } interface Outputs { // (no outputs defined) } async function handle(variables: Inputs): Promise { // TODO: implement send invoice logic throw new Error("Not implemented") } console.log(`[${WORKER_NAME}] polling ${JOB_TYPE}`) for await (const job of client.poll(JOB_TYPE)) { try { const outputs = await handle(job.variables as Inputs) await job.complete(outputs) console.log(`[${WORKER_NAME}] completed ${job.key}`) } catch (err) { const msg = err instanceof Error ? err.message : String(err) await job.fail(msg, job.retries - 1) console.error(`[${WORKER_NAME}] failed ${job.key}: ${msg}`) } } ``` ## Environment variables | Variable | Required | Description | |---|---|---| | `ZEEBE_ADDRESS` | no | Zeebe REST base URL (default: `http://localhost:26500`) | | `ZEEBE_CLIENT_ID` | Camunda SaaS only | OAuth2 client ID | | `ZEEBE_CLIENT_SECRET` | Camunda SaaS only | OAuth2 client secret | | `ZEEBE_TOKEN_URL` | no | OAuth2 token URL (default: Camunda SaaS endpoint) | | `ZEEBE_TOKEN_AUDIENCE` | no | OAuth2 audience (default: `zeebe.camunda.io`) | For local development with reebe, only `ZEEBE_ADDRESS` matters (or leave it at the default). ## Running workers ### Development (tsx, no build step) ```sh cd workers/send-invoice npm install npm start # runs: tsx index.ts ``` ### Production (compiled JS) ```sh cd workers/send-invoice npm install npm run build # runs: tsc npm run start:prod # runs: node dist/index.js ``` ### All workers at once ```sh casen worker start ``` To start a specific worker: ```sh casen worker start send-invoice ``` ## Docker Each scaffolded worker includes a multi-stage `Dockerfile` recipe in its README: ```dockerfile FROM node:22-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm install COPY index.ts tsconfig.json ./ RUN npm run build FROM node:22-alpine WORKDIR /app COPY package*.json ./ RUN npm install --omit=dev COPY --from=build /app/dist ./dist CMD ["node", "dist/index.js"] ``` Build and run: ```sh docker build -t my-org/send-invoice . docker run -e ZEEBE_ADDRESS=http://reebe:26500 my-org/send-invoice ``` ## Camunda SaaS For Camunda 8 cloud, pass OAuth2 credentials as environment variables: ```sh ZEEBE_ADDRESS=https://your-cluster.bru-2.zeebe.camunda.io:443 \ ZEEBE_CLIENT_ID=... \ ZEEBE_CLIENT_SECRET=... \ npm run start:prod ``` `@bpmnkit/worker-client` handles OAuth2 token fetching and caching automatically. ## Listing available workers ```sh casen worker start --help ``` Or from Claude Code, call `worker_list` to see all built-in and scaffolded workers: ``` worker_list() ``` Built-in workers (provided by the proxy): | Job type | Description | |---|---| | `bpmnkit:llm:1` | Call an LLM and return the response text | | `bpmnkit:cli:1` | Run a shell command | | `bpmnkit:http:scrape:1` | Fetch and extract text from a URL | | `bpmnkit:fs:read:1` | Read a file from the local filesystem | | `bpmnkit:fs:write:1` | Write content to a file | | `bpmnkit:fs:append:1` | Append content to a file | | `bpmnkit:fs:list:1` | List files in a directory | | `bpmnkit:js:1` | Evaluate a JavaScript expression | | `bpmnkit:email:fetch:1` | Fetch email from an IMAP mailbox | | `bpmnkit:email:send:1` | Send email via SMTP | ## See also - [`@bpmnkit/worker-client`](/docs/packages/worker-client) — full API reference - [AI-Driven Implementation](/docs/guides/ai-implement) — scaffold workers as part of `/implement` --- ### Using BPMN Kit with AI Give an AI agent the three things it needs to build a process — BPMN Kit's own docs, Camunda's docs, and your team's prose — all indexed offline, then generate the BPMN. Source: https://bpmnkit.com/docs/guides/using-bpmnkit-with-ai An agent asked to turn a folder of specs into a deployable process needs three different kinds of knowledge, and only one of them is in its weights: | It needs to know | Where that lives | How it asks | | --- | --- | --- | | How to drive this library | `@bpmnkit/docspack` | `bpmnkit-docs ask "…"` | | How the engine behaves | `@bpmnkit/camunda-docspack` | `bpmnkit-docs ask "…" --pack @bpmnkit/camunda-docspack` | | What the process actually does | your own Markdown | index it yourself, below | All three are offline retrieval. Nothing here calls a model, starts a server or touches the network, so a question costs milliseconds and no tokens. The model is only spent on the last step — turning what it found into a diagram. ## The two packs BPMN Kit ships ```sh npm i -D @bpmnkit/docspack @bpmnkit/camunda-docspack ``` **[`@bpmnkit/docspack`](/docs/packages/docspack)** carries the documentation you are reading — the builder API, the CLI, the guides — pinned to the version this project installed. **[`@bpmnkit/camunda-docspack`](/docs/packages/camunda-docspack)** carries the Camunda 8 documentation: BPMN and FEEL references, engine concepts, the best-practice pages with their diagrams rendered as text, and one digest per Orchestration Cluster API operation. It is Camunda's work, redistributed under CC BY-SA 3.0, not documentation BPMN Kit wrote. One `bpmnkit-docs` command reads both. ## Telling an agent they exist An agent that does not know a pack is installed will answer from memory instead, and its memory describes an older release. One paragraph in `AGENTS.md`, `CLAUDE.md` or `.cursor/rules` is the whole setup — **name both packs**, because an agent told only about the first will never think to ask the second: ```md Documentation is installed locally. Ask it before answering from memory. - BPMN Kit's own APIs, CLI and guides: `npx bpmnkit-docs ask ""` - Camunda 8 — BPMN semantics, FEEL, engine behaviour, the REST API: `npx bpmnkit-docs ask "" --pack @bpmnkit/camunda-docspack` A returned chunk beats recalled knowledge: it describes the version this project installed. If the two disagree, the chunk is right — do not blend them. Answers cap at 3 chunks / 3,000 tokens, so ask several narrow questions rather than one broad one. ``` ## Asking ```sh npx bpmnkit-docs ask "what does compactify drop from a diagram" ``` ``` ## @bpmnkit/docspack@0.0.5/packages.core.installation-compactify-definitions Projects a `BpmnDefinitions` object onto a `CompactDiagram` — a small JSON object suitable for LLM prompts. **Lossy:** it keeps topology, names … --- cost: 204 tokens, capped at 3,000 ``` Every answer names the pack, the version and the chunk, and closes with what it cost — so an agent can quote the chunk id back when a passage turns out to be wrong. Ask the engine's documentation with `--pack`: ```sh npx bpmnkit-docs ask "what happens when no exclusive gateway condition is true" \ --pack @bpmnkit/camunda-docspack ``` `--pack` is worth using even when you are not disambiguating. Building the index reads every chunk off disk, so narrowing to one pack is the difference between roughly 150ms and 650ms per question. A name that is not installed is an error listing what is, never an empty answer — an empty answer would read as "the documentation does not cover this", which is a different claim. `search` ranks without printing the content, and `list` shows what was found: ```sh npx bpmnkit-docs search "exclusive gateway condition" npx bpmnkit-docs list ``` ``` @bpmnkit/camunda-docspack@0.1.1 1054 chunks @bpmnkit/docspack@0.0.5 206 chunks ``` ## From the library instead of the CLI A long-lived agent, an editor extension or an MCP server should index once and ask many times, rather than paying for the index on every question: ```typescript import { answer, discoverPacks, indexPacks } from "@bpmnkit/docspack"; const index = indexPacks(discoverPacks()); const { hits, tokens } = answer(index, "verify a worker's job type", { packs: ["@bpmnkit/docspack"], limit: 3, maxTokens: 3000, }); for (const hit of hits) console.log(hit.chunkId, hit.content); console.log(`${tokens} tokens`); ``` ## Indexing your own corpus The packs above answer for BPMN Kit and for Camunda. Nothing answers for *your* process — the five Markdown files somebody wrote describing how orders are fulfilled. Handed the folder, an agent reads the wrong file or spends its whole context on all of them; a corpus larger than the context window cannot be read into it at all. Index it, and it becomes something the agent asks questions of. The same builder behind `bpmnkit-docs build` takes any folder of Markdown: ```sh mkdir -p flow-corpus cat > flow-corpus/package.json <<'JSON' { "name": "order-fulfilment-corpus", "version": "1.0.0", "private": true, "docspack": { "source": "../flow-docs", "minTokens": 60, "maxTokens": 800 } } JSON npx bpmnkit-docs build --cwd flow-corpus npx bpmnkit-docs ask "when does an order need manager approval" --cwd flow-corpus ``` ``` Built 5 chunks from 5 documents (421 tokens). ## order-fulfilment-corpus@1.0.0/02-approval.when-approval-is-needed # Approval — When approval is needed An order over 10000 EUR needs a regional manager to approve it before anything is picked. At or under that figure the order goes straight to fulfilment. ``` Indexing runs no model, so building the corpus costs no tokens — five documents take about 9ms. A few things decide whether the result is any good: - **Split at headings that answer something.** A chunk is a `##` section. `minTokens` merges a section too short to answer anything into the one before it, so a two-line heading does not become a chunk of its own. - **`--cwd` points at the pack, not at the sources.** A directory holding a `package.json` and a `.llms/` payload *is* a pack, so `bpmnkit-docs` reads it without it ever being published. - **Your corpus and the installed packs are searched together.** Discovery walks `node_modules` up from `--cwd`, so a corpus inside your project sees `@bpmnkit/docspack` too, and `--pack` narrows to whichever you want. - **Keep it out of git, and re-index after editing.** The chunks are a copy of your files. Edit a source and the index is stale until you build again — `bpmnkit-docs build` is cheap enough to run on every change. - **Tags weigh 3× prose.** Front matter, the slug and the heading's own words become tags automatically; steer them from the document itself when the words a reader would search for are not the words on the page: ```md ## Escalation ``` ### The other route: `docspack index` Upstream [docspack](https://docspack.dev) 1.1.0 added [a second way to do this](https://docspack.dev/docs/your-own-corpus/). Rather than building a pack, it keeps an SQLite index of loose sources, and answers from it with `recall` instead of `ask`: ```sh npm i -D docspack npx docspack index --from ./flow-docs npx docspack recall "when does an order need manager approval" ``` ``` indexing @local/flow-docs + @local/flow-docs 10 chunks ~527 tokens ``` Pick it over `bpmnkit-docs build` when the corpus is not a folder of Markdown. Anything that emits JSON can be indexed without a database driver: ```sh sqlite3 -json shop.db 'select id, title, body as text from orders' | npx docspack index --from-json - ``` It also tracks each source's size, mtime and hash, so a re-run does nothing when nothing changed, and `recall` leads with a warning rather than quoting a passage that has since been edited: ``` NOTE: the corpus is out of date. 1 indexed source has changed since it was built: flow-docs/04-shipping.md. The passages below may be superseded — run `docspack index` again before relying on them. ``` `recall` is deliberately not `ask`: your notes are never an installed version, so a corpus cannot reach an answer about a dependency and a dependency cannot reach an answer about your notes. The index lands in `.docspack/local.db` as plaintext and the tool writes a `.gitignore` beside it. The two routes do not merge. `bpmnkit-docs` searches your corpus and the installed packs together because a pack directory is just a directory; upstream keeps them in separate query paths on purpose. Build a pack when you want one answer drawn from both; use `docspack index` when the corpus is a database, or when you want the staleness check. > **Use `docspack@1.2.0` or newer for the Camunda pack.** Earlier versions named > one pack per npm scope, so `docspack sync` read `@bpmnkit/camunda-docspack` as > an ordinary dependency and indexed its type declarations — `0 chunks > (declarations)` — leaving a Camunda question to be answered out of the wrong > pack. 1.2.0 discovers `@/-docspack` and indexes all 1,054 chunks. > `bpmnkit-docs` has read the suffix from the start and needs no version floor. ## The whole loop: five Markdown files to a BPMN diagram Put the three together and the agent's job is narrow enough to be reliable: 1. **Index the team's prose** and ask it what the flow actually does — where the thresholds are, what runs in parallel, what happens on the unhappy path. 2. **Ask `@bpmnkit/docspack`** how to express that with this library. 3. **Ask `@bpmnkit/camunda-docspack`** whatever the engine, not the library, decides — gateway semantics, FEEL syntax, job types. 4. **Return a `CompactDiagram`**, not XML. About 40 lines of JSON for a diagram that is 200 lines of BPMN, and a model that has never written valid BPMN XML can still produce a valid diagram. 5. **`expand` it**, which is deterministic — layout included. ```typescript import { Bpmn, expand } from "@bpmnkit/core"; import type { CompactDiagram } from "@bpmnkit/core"; import { answer, buildPack, indexPacks, loadPack } from "@bpmnkit/docspack"; // 1. The team's own files, indexed. buildPack({ source: "flow-docs", packDir: "flow-corpus", name: "order-fulfilment-corpus", version: "1.0.0", documents: ["order-fulfilment-corpus"], minTokens: 60, }); const corpus = indexPacks([loadPack("flow-corpus")]); const facts = ["when does an order need manager approval", "what runs in parallel"] .flatMap((q) => answer(corpus, q, { limit: 1, maxTokens: 600 }).hits) .map((hit) => hit.content); // 2–4. The model reads `facts` and returns this. Nothing here is XML. const compact: CompactDiagram = await writeDiagram(facts); // 5. Deterministic from here on: topology, Zeebe bindings and layout. const definitions = expand(compact); const xml = Bpmn.export(definitions); ``` **Mark every gateway's fallthrough branch.** An exclusive gateway whose conditions are all false and which has no default deadlocks at runtime, so the branch without a condition carries `isDefault` instead: ```typescript flows: [ { id: "f5", from: "needsApproval", to: "approveOrder", condition: "= total > 10000" }, { id: "f6", from: "needsApproval", to: "splitWork", name: "at or under", isDefault: true }, ] ``` `expand` turns it into the gateway's `bpmn:default` attribute, `compactify` reads it back, and `reconcileCompact` sets it on a file somebody else authored. A flow marked `isDefault` that does not leave an exclusive, inclusive or complex gateway, or a gateway with two of them, throws rather than being dropped. > `CompactDiagram` still does not model everything. See > [AI Integration](/docs/guides/ai) for what it drops, and use `reconcileCompact` > rather than `expand` when you are editing a file you need to keep. ## Runnable examples Three scripts in [`apps/examples/src/ai`](https://github.com/bpmnkit/bpmnkit/tree/main/apps/examples/src/ai) do exactly the above. They need no API key and no network — the whole set runs in about three seconds: ```sh pnpm --filter @bpmnkit/examples ai:ask # ask both packs, from the library pnpm --filter @bpmnkit/examples ai:index # 5 Markdown files → an askable corpus pnpm --filter @bpmnkit/examples ai:bpmn # corpus → CompactDiagram → .bpmn ``` `ai:bpmn` writes `output/order-fulfilment.bpmn`: 18 elements, 20 sequence flows, laid out, with the gateway defaults set. ## Keeping it fast | What | Cost | Why | | --- | --- | --- | | `ask` scoped with `--pack @bpmnkit/docspack` | ~150ms | indexes one pack | | `ask` across both packs | ~650ms | indexes both, 1,200+ chunks | | `indexPacks` once, then `answer` per question | ~0ms per question | the index is the expensive part | | Building a 5-document corpus | ~9ms | no model, no network | Reading chunks off disk dominates, and a fresh `npx bpmnkit-docs ask` pays it on every question. Scope with `--pack` when you know which pack answers; use the library and hold the index when you are asking more than a handful of questions. ## Where to go next - [AI Integration](/docs/guides/ai) — the compact format, prompting, the Claude and OpenAI calls, and the MCP server - [Building Processes with AI](/docs/guides/ai-implement) — the `ProcessPlan` pipeline, which compiles rather than generating XML - [AI Agents](/docs/guides/ai-agents) — putting an LLM *inside* a process as a Camunda AI Agent Sub-process - [Claude Code Plugin](/docs/guides/claude-code-plugin) — the slash commands that drive all of it --- ### AI Integration Use BPMN Kit with LLMs to generate and modify process diagrams from natural language. Source: https://bpmnkit.com/docs/guides/ai BPMN Kit is designed from the ground up to work with AI agents. The compact intermediate format lets a complete process diagram fit in a single LLM prompt, and the builder API produces valid BPMN without requiring the AI to write raw XML. ## The Compact Format Raw BPMN XML is far too verbose for LLMs — a simple three-node process generates ~60 lines. The compact format carries the topology and the common Zeebe bindings as a small JSON object: ```typescript import { Bpmn, compactify, expand } from "@bpmnkit/core"; // Parse some BPMN XML const definitions = Bpmn.parse(existingXml); // Convert to compact format const compact = compactify(definitions); // compact is ~500 tokens for a typical approval workflow // Send to your LLM, get back a modified compact object const modified = await llm.modify(compact, "Add a parallel notification step after approval"); // Convert back to full BPMN const updatedDefinitions = expand(modified); const updatedXml = Bpmn.export(updatedDefinitions); ``` > **`expand()` does not restore what `compactify()` left behind.** `CompactElement` models > about fifteen properties; collaborations, participants, message flows, lanes, data stores, > artifacts, root-level messages and errors, multi-instance loop characteristics, full > `zeebe:ioMapping` entries and most diagram interchange are not among them. > > The loop above is safe for a model **you generated** from a compact definition. Running it > over a file authored elsewhere — a Camunda blueprint, anything touched in Web Modeler — > will silently strip those parts. > > **Use `reconcileCompact` instead when editing an existing file.** It applies the same compact > input as changes rather than expanding it over the model, so what the compact form cannot > describe survives: > > ```typescript > import { reconcileCompact } from "@bpmnkit/core"; > > const { definitions } = reconcileCompact(Bpmn.parse(existingXml), modified); > const updatedXml = Bpmn.export(definitions); > ``` > > If the model returns edit operations rather than a whole diagram, `applyBpmnOperations` takes > them directly against the full model. ## Minimal Empty Diagram When an AI agent needs to start fresh, use `Bpmn.makeEmpty()` to get a valid starting point with a single start event: ```typescript import { Bpmn } from "@bpmnkit/core"; // Returns a valid BPMN XML string — one start event, ready for an agent to extend const xml = Bpmn.makeEmpty("my-process", "My Process"); ``` ## Prompting Strategy For best results, give the LLM the compact diagram and a clear instruction. A good system prompt excerpt: ``` You are a BPMN process designer. The user will describe a business process and you will return a CompactDiagram JSON object. Rules: - Use camelCase IDs - Every service task needs a jobType string (the Zeebe worker subscription) - Use FEEL expressions for gateway conditions (start with "= ") - Every exclusive gateway needs one branch with no condition and `isDefault: true` - Always include a start event and at least one end event - Do not add fields that are not part of the CompactDiagram schema ``` ## Claude API Example Using the Anthropic SDK to generate a process from a description: ```typescript import Anthropic from "@anthropic-ai/sdk"; import { Bpmn, expand } from "@bpmnkit/core"; import type { CompactDiagram } from "@bpmnkit/core"; const anthropic = new Anthropic(); async function generateProcess(description: string): Promise { const response = await anthropic.messages.create({ model: "claude-opus-4-6", max_tokens: 2048, system: `You are a BPMN process designer. Return only valid JSON matching the CompactDiagram schema. No explanation, no markdown — raw JSON only.`, messages: [ { role: "user", content: `Create a BPMN process for: ${description}`, }, ], }); const json = response.content[0]; if (json?.type !== "text") throw new Error("Unexpected response type"); const compact = JSON.parse(json.text) as CompactDiagram; const definitions = expand(compact); return Bpmn.export(definitions); } const xml = await generateProcess( "An invoice approval process where invoices over $10,000 need manager approval" ); ``` ## OpenAI Function Calling Use function/tool calling for reliable structured output: ```typescript import OpenAI from "openai"; import { expand, Bpmn } from "@bpmnkit/core"; const openai = new OpenAI(); const response = await openai.chat.completions.create({ model: "gpt-4o", messages: [ { role: "user", content: "Create a customer onboarding process with email verification and KYC check", }, ], tools: [ { type: "function", function: { name: "create_bpmn_process", description: "Create a BPMN process diagram", parameters: compactDiagramJsonSchema, // export from @bpmnkit/core }, }, ], tool_choice: { type: "function", function: { name: "create_bpmn_process" } }, }); const toolCall = response.choices[0]?.message.tool_calls?.[0]; if (!toolCall) throw new Error("No tool call"); const compact = JSON.parse(toolCall.function.arguments); const xml = Bpmn.export(expand(compact)); ``` ## Previewing While the Model Writes A diagram is visual, and a model takes seconds to write one. Waiting for the last token to show anything means the user watches prose scroll past while the only interesting part of the answer is already most of the way written. Nothing downstream can use a half-written document, though — `JSON.parse` wants the closing brace, and the outermost one is the very last character a tool call sends. `createCompactStream` sidesteps that by not parsing the document at all. It takes complete `{…}` literals as they close and keeps the ones shaped like an element or a flow, which are the innermost objects and so the first to finish: ```typescript import { createCompactStream } from "@bpmnkit/core"; const stream = createCompactStream({ base: currentDiagram }); for await (const chunk of tokens) { const frame = stream.push(chunk); // null until the frame changes if (frame) canvas.loadDefinitions(frame, { keepViewport: true }); } ``` Pass `base` when the model is editing something. It streams only what it is adding, so without the diagram it started from a frame is a disconnected fragment rather than the process with the fragment in it. `keepViewport` matters as much as the frames do: without it the canvas re-frames the diagram on every update and pulls the view out from under whoever is watching. With it, the process grows in place — the layout is stable enough for that, because appending to a diagram does not move what is already placed. ### Frames are advisory Every frame is a guess at an unfinished document. `push` never throws, drops what it cannot place, and strips a flow's `isDefault` rather than failing when the gateway it claims has not arrived. That is the whole bargain: a frame that guesses wrong costs one render, so the authoritative result is whatever the model finishes with, and that is what you save or deploy. ### Where the frames come from The local proxy (`bpmn-ai-server`) sends them on `/chat` as `preview` events, from two places: - **The tool call being written** — argument fragments as they stream, which is the only thing that covers a process the model composes in a single call. - **The MCP server's own state** — one frame per mutating tool call, once anything has been written. These take over as soon as they exist; they are the model's own state rather than a guess at an unfinished document. A client renders `preview` as it likes and treats the `xml` event at the end of the stream as the result. The editor's AI panel does this, and additionally outlines the elements the diagram being edited does not have — so an edit reads as an edit rather than a rewrite. It marks nothing when the diagram has no sequence flows yet: a process built from scratch is new all the way through, and marking everything says no more than marking none of it. ### What the model can do The proxy runs your installed `claude`, `copilot` or `gemini` CLI for the AI panel, but not as a coding agent. The model gets no shell, no file access and no web access, and none of your own MCP servers, settings or plugins. What it can do depends on the request: | Request | The model gets | |---|---| | AI panel chat, improve, explain (`/chat`) | The diagram tools of the proxy's MCP server, which change only the diagram held in that server's memory. The result comes back as the `xml` event; nothing is written to your project. | | Create form or decision (`/chat`), improve with operations (`/improve`, `casen lint --ai`), Operate chat, incident assist, AI search, `casen ask` | No tools. The model answers with text or JSON, and the proxy does the rest. | Everything that comes from the request — your chat text, the diagram, an incident's variables — reaches the model inside `` tags, and the system prompt tells it to treat that as data. A diagram that says "ignore your instructions" is still just a diagram. ## MCP Server BPMN Kit ships a Model Context Protocol (MCP) server that lets any MCP client — Claude Code, Claude Desktop, Cursor, VS Code — create, validate, simulate and deploy processes. It speaks stdio and is started by the CLI: ```sh casen proxy mcp ``` or, without installing the CLI first, in an MCP client's configuration: ```json { "mcpServers": { "bpmnkit": { "command": "npx", "args": ["-y", "@bpmnkit/cli", "proxy", "mcp"] } } } ``` Each CLI release submits it to the [MCP Registry](https://registry.modelcontextprotocol.io) as `io.github.bpmnkit/bpmnkit`. Tools: - `bpmn_create`, `bpmn_read`, `bpmn_update` — write and read `.bpmn` files through the compact format, with auto-layout applied on write - `bpmn_validate` — run the optimizer's findings over a file - `bpmn_simulate`, `bpmn_run_history` — run a process on the local engine and read past runs (needs the proxy running: `casen proxy start`) - `bpmn_deploy` — deploy to the active `casen` profile (Camunda 8 or a local Reebe) - `form_create`, `dmn_create` — Camunda Forms and DMN decision tables - `worker_list`, `worker_scaffold` — list and generate job workers - `pattern_list`, `pattern_get` — the domain patterns in `@bpmnkit/patterns` - `camunda_search`, `camunda_execute` — discover and call any Camunda 8 REST operation The [Claude Code plugin](/docs/guides/claude-code-plugin) configures this server for you. --- ### Building Processes with AI Go from a natural language description to a deployed, tested BPMN process — via a deterministic ProcessPlan pipeline, never hand-written XML. Source: https://bpmnkit.com/docs/guides/ai-implement BPMNKit's AI pipeline never asks an LLM to write BPMN XML. Every process is authored as a `ProcessPlan` JSON file and compiled deterministically by `casen synth` — this is what makes generated processes reliably valid, deployable, and easy to diff. The LLM's job is narrower and more reliable: write the plan, resolve connectors, and fix reported problems. ## The pipeline ``` "implement X" (natural language) │ ▼ ProcessPlan JSON ──casen synth──▶ laid-out, deployable .bpmn │ │ │ casen lint --profile deploy │ │ └──casen plan extract◀──────── casen test (for later edits) ``` 1. **Check for a reusable domain pattern** — `casen pattern list`/`get` (see [Pattern Library](/docs/guides/patterns)) surfaces domain context (regulations, conventions) and realistic worker specs, used as reference while writing the plan below — not pasted in as a `ProcessPlan` directly. 2. **Resolve external interactions** — `casen connector search ""` / `casen connector show ` find the right Camunda connector template and its required inputs, instead of guessing property keys. 3. **Write the plan** — a `ProcessPlan` JSON file (`casen plan schema` prints the full format reference). 4. **Compile** — `casen synth .json --output .bpmn`. Problems are reported keyed by JSON path (e.g. `steps[2].connector.values.token`) — fix the plan, never the XML, and re-run. 5. **Test** — a `tests` array in the plan compiles to a `.bpmn.tests.json` sidecar automatically; run it with `casen test .bpmn`. 6. **Deploy-readiness gate** — `casen lint .bpmn --profile deploy` must report zero errors before deploying. 7. **Deploy** — `casen deploy deploy .bpmn` (local Reebe) or `--target camunda8`. ## The Claude Code plugin The richest way to drive this pipeline is the `bpmnkit` Claude Code plugin: ```sh /plugin marketplace add github:bpmnkit/bpmnkit /plugin install bpmnkit ``` It's **CLI-first**: every skill drives `casen` via Bash — no MCP server, no proxy daemon. Each skill reads the relevant generated/hand-written reference doc (plan format, connector catalog, agentic pattern, FEEL syntax, modeling conventions) before authoring a plan. ``` /bpmnkit:implement an invoice approval process for accounts payable ``` Claude works through: resolve connectors → write the plan → compile → test → scaffold missing workers → present a summary → ask where to deploy. Related skills: `/bpmnkit:extend ` (lift an existing process to a plan, apply a targeted delta, merge), `/bpmnkit:agent` (design an AI Agent Sub-process — see [AI Agents](/docs/guides/ai-agents)), `/bpmnkit:connect ` (wire an existing step to a connector), `/bpmnkit:review`, `/bpmnkit:test`, `/bpmnkit:deploy`. ## Lightweight alternative: `casen skills install` If you don't want the full plugin, four minimal slash commands are available directly from the CLI — see [AIKit Skills](/docs/cli/skills): ```sh casen skills install ``` This installs `/implement`, `/review`, `/test`, `/deploy` into `.claude/commands/`. Same underlying pipeline, less scaffolding around it (no `/extend`/`/agent`/`/connect`, no generated reference docs). ## What gets created ``` project/ invoice-approval.plan.json ← the source of truth — edit this, not the XML invoice-approval.bpmn ← compiled by casen synth invoice-approval.bpmn.tests.json ← scenarios, if the plan had a `tests` array workers/ validate-invoice/ index.ts ← implement the job logic here package.json tsconfig.json README.md ``` ## Deploying ``` Deploy to local Reebe, deploy to Camunda 8, or skip? ``` - **Local Reebe** — deploys via `ZEEBE_ADDRESS` (default `http://localhost:26500`). Start Reebe first: `casen reebe start --port 26500`. - **Camunda 8** — deploys using the active `casen` profile. Set one up with `casen profile create`. - **Skip** — leaves the plan and BPMN file on disk for manual review and deployment. ## Implementing workers Each scaffolded worker uses `@bpmnkit/worker-client`'s real polling API: ```typescript // workers/validate-invoice/index.ts import { createWorkerClient } from "@bpmnkit/worker-client" const client = createWorkerClient({ workerName: "validate-invoice-worker" }) for await (const job of client.poll("validate-invoice:1")) { try { // TODO: implement invoice validation await job.complete({ /* output variables */ }) } catch (err) { await job.fail(String(err)) } } ``` Start a worker for development: ```sh cd workers/validate-invoice npm install npm start ``` Or start all workers at once: ```sh casen worker start ``` See [Standalone Workers](/docs/guides/workers-standalone) for deployment options. ## Extending an existing process ``` /bpmnkit:extend invoice-approval.bpmn add a timeout boundary event to the approval task ``` This lifts the process back into plan form (`casen plan extract`), writes a small delta plan touching only the changed step, and merges it in (`casen synth --merge`) — the diff is reported at the element level, not as an XML diff. --- ### Connectors in AI Generation How BPMN Kit turns "post the open GitHub issues to Slack" into an executable process — connector cards, with lines, the offline API index, and a dry run that proves it runs. Source: https://bpmnkit.com/docs/guides/ai-connectors A generated diagram is only useful in Camunda 8 if its calls to outside systems are configured connectors: the right template, the inputs it needs, credentials as secrets. BPMN Kit does this in code wherever it can and asks a model only for what code cannot know. All the connector data ships in the packages, so nothing is fetched at run time. ## Two passes 1. **The shape.** A model writes the process in the [line format](/docs/packages/core#parseprocesstexttext-and-createprocesstextstream): tasks, gateways, events. Each call to an outside system is its own service task. In Drop, code then checks the draft against the request. A DMN decision, a time limit or a REST call the request names, with no rule task, timer or error boundary for it in the draft, gets one change request asking for exactly that. A complete draft skips it. 2. **The connectors.** A second, small call configures them. Code picks the connector cards each task could use and sends only those. The model answers with `with` lines only, and code applies them. The second pass is skipped without a model call when no task matches a connector. A pure approval flow costs nothing extra. ## Connector cards The 133 Camunda connector templates are bundled in [`@bpmnkit/core/connectors`](/docs/packages/connectors). A template with several operations becomes one **card** per operation, with only the inputs that operation uses: ``` slack chat.postMessage — Slack Outbound Connector: Post message | token*(secret) data.text* data.channel* | optional: data.thread resultVariable resultExpression(=FEEL) ``` `*` marks a required input. `(secret)` takes a `{{secrets.NAME}}` placeholder. - **Picking cards.** `selectConnectors({ text, tasks })` picks the cards for each task, in code. A card fits when the task's name names the system ("Post summary to **Slack**"), or when the request names it and no other task does. - **Searching.** `findConnectorCards(query)` searches all cards. - **From a terminal.** `casen connector cards "post a message to slack"` prints them. ## `with` lines A `with` line configures one node: ``` with notify: slack chat.postMessage | token={{secrets.SLACK_TOKEN}} | data.channel=#ops | data.text== "Order " + orderId + " failed" with fetch: http GET https://api.example.com/orders | result=order: response.body ``` `applyConnectorLines` resolves the line against the catalog and applies the template. A plain task becomes the connector's service task. The resolver repairs what models get nearly right: - a misspelt alias; - a short key (`channel` for `data.channel`); - a credential written as a value, which becomes a secret placeholder. A required input the line left out becomes a **question**, with a line for the reader to finish. The [`with` lines reference](/docs/packages/connectors#with-lines) has the details. ## Any HTTP API Most systems have no dedicated connector. The REST connector reaches them, but only with the right base URL, path and authentication. The [API index](/docs/packages/connector-gen#api-index) has these for about 80 APIs, built offline from their OpenAPI specs. A task that names one of them gets an **API card** next to the REST connector: ``` api stripe — Stripe API https://api.stripe.com auth=bearer secret=STRIPE_TOKEN POST /v1/customers — Create a customer | form body: name email description address balance business_name ``` The model writes `api=stripe` and the path: ``` with customer: http POST /v1/customers | api=stripe | body=={email: email} ``` The line then gets: - the base URL; - the `{params}` of the path as FEEL, from variables of the same name; - the authentication, with a `{{secrets.STRIPE_TOKEN}}` placeholder; - the headers the endpoint needs, such as Stripe's form encoding or Notion's `Notion-Version`. A call the index does not have is kept, as a question to check the URL. A dedicated connector still wins when it covers the task. GitHub's connector creates issues; the index lists workflow runs. ## Proving it runs A diagram that parses is not yet a process that runs. Two checks follow every connected result: - **Dry run.** [`dryRun`](/docs/guides/testing-processes#dry-run) runs the process once from start to end. Every connector answers an empty HTTP 200, and every other job completes. Messages are delivered and timers fire. It says whether the run reached the end, or where it stopped and why. - **Secrets.** `listSecrets` lists every secret the diagram reads, so you can create them in the cluster before deploying. ## Where you meet it - **[Drop](/docs/guides/drop).** "Describe a process" drafts the diagram, connects it, and shows the secrets and the dry run. A shared diagram has **Add connectors** while editing. - **CLI.** - `casen connector cards ""` prints connector cards. - `casen connector api ""` prints the endpoints of an indexed API. - `casen synth --check` dry-runs a compiled plan and lists its secrets. - **The proxy's MCP server.** It is used by the AI chat and by your own agents: - `find_connectors { query }` returns cards and API endpoints. - `add_connector { processId, id, alias, operation, values }` configures a node through the same resolver. - **Studio.** **Try it** runs a model on the local engine. GET requests go out for real; every other connector and task is simulated. ## Keeping the data current - **Connector templates.** They come from Camunda's marketplace registry. - **API index.** It is built from the connector-gen catalog's specs. A weekly workflow refreshes both and opens a pull request when something changed. Specs that state a non-commercial, copyleft or proprietary license are not indexed. --- ### AI Agents Add a Camunda 8 AI Agent Sub-process to a BPMN process — an LLM-in-the-loop step that can call tools, generated deterministically like everything else in the pipeline. Source: https://bpmnkit.com/docs/guides/ai-agents Camunda 8's **AI Agent Sub-process** lets a single process step delegate to an LLM that can call tools (other BPMN activities) in a loop until it produces a final answer. BPMNKit generates this pattern the same way it generates everything else: deterministically, from a `ProcessPlan` — never by hand-writing the underlying ad-hoc-sub-process XML. ## Quick start ``` /bpmnkit:agent add a support-triage agent with tools: search KB (http), escalate to human (user task), post summary to slack ``` Or, from the CLI directly: write an `aiAgent` plan step (see below), then `casen synth`. ## How it's modeled - A `bpmn:AdHocSubProcess` carrying a `zeebe:taskDefinition` of type `io.camunda.agenticai:aiagent-job-worker:1` — the presence of `zeebe:taskDefinition` is itself what marks it as a job-worker implementation. - Each **tool** is a root-node activity inside the sub-process (no incoming sequence flow). The tool's element id is its name as the LLM sees it; its documentation is the description the LLM reads. - Parameters the LLM must supply are `fromAi(toolCall., "", ""?, ?, {required: false}?)` FEEL expressions. - Each tool's result is aggregated into an `outputCollection` (default `toolCallResults`); the agent's own final answer lands in a process variable (default `agent`). None of this is hand-authored — it's generated by the `aiAgent` plan step. ## The `aiAgent` plan step ```json { "kind": "aiAgent", "id": "triage_agent", "name": "Triage agent", "provider": "anthropic", "model": "claude-sonnet-5", "providerInputs": { "provider.anthropic.authentication.apiKey": "{{secrets.ANTHROPIC_API_KEY}}" }, "systemPrompt": "You triage support tickets and post updates to Slack.", "userPrompt": "=ticketText", "tools": [ { "id": "notify_slack", "description": "Post a status update to the #support-escalations Slack channel.", "connector": { "template": "io.camunda.connectors.Slack.v1", "values": { "method": "chat.postMessage", "token": "{{secrets.SLACK_OAUTH_TOKEN}}", "data.channel": "#support-escalations", "data.text": "placeholder" } }, "params": [ { "name": "message", "description": "The status update to post", "type": "string", "target": "data.text" } ] }, { "id": "escalate", "description": "Escalate the ticket to a human agent.", "jobType": "escalate:1", "params": [{ "name": "reason", "description": "Why this needs a human" }] } ], "errorBoundary": { "errorCode": "AGENT_FAILED", "steps": [{ "kind": "end", "id": "agent_failed", "errorCode": "AGENT_FAILED" }] } } ``` Compile it like any other plan: ```sh casen synth support-triage.plan.json --output support-triage.bpmn casen lint lint support-triage.bpmn --profile deploy ``` Key points: - **Every tool needs an `id` and a `description`** written for the LLM to read, like briefing a teammate. - **A tool is either connector-backed (`connector`) or a plain job-worker (`jobType`)** — never both. Search the connector catalog first (`casen connector search`), fall back to `jobType` (with a scaffolded worker) only when no connector exists. - **A `params[].target` matching a connector value's key overrides it** — the standard way to let the LLM control one field of an otherwise-fixed connector call, as in `notify_slack` above: the channel is fixed, but the message text is supplied by the agent at call time. Give the overridden key a placeholder value in `connector.values`, not a real one. - **Always give the agent step an error boundary** — an unhandled agent/tool failure needs somewhere to go. - **Secrets always use `{{secrets.NAME}}`** — never a literal API key or token. ## Deploy-grade validation `casen lint --profile deploy` includes `agentic/*` rules specific to this pattern: | Rule | Catches | |---|---| | `agentic/tool-not-root` | A tool with an incoming sequence flow (tools must be root nodes) | | `agentic/tool-no-description` | A tool with no documentation for the LLM to read | | `agentic/fromai-bad-ref` | A `fromAi()` call whose first argument doesn't reference `toolCall.*` | | `agentic/no-output-collection` | Missing tool-result aggregation | | `agentic/limits-missing` | No `maxModelCalls` safety limit | The `data-flow` category can report spurious "variable never set" findings against the agent's own provider/model/prompt bindings (a hyphenated model id like `claude-sonnet-5` can be misread as an arithmetic expression) — this is a known limitation of that heuristic, not a real issue, and doesn't affect the `deploy` profile. ## Testing without a real LLM call Scenarios mock the whole agent sub-process as a single job — the dispatcher routes any job-worker-backed ad-hoc sub-process (this pattern included) through the same job-mock mechanism as any other task: ```json { "name": "Agent resolves the ticket", "mocks": { "io.camunda.agenticai:aiagent-job-worker:1": { "outputs": { "agent": { "status": "resolved" } } } }, "expect": { "path": ["triage_agent"], "variables": { "agent": { "status": "resolved" } } } } ``` ```sh casen test support-triage.bpmn ``` This verifies the process routes correctly through and around the agent step. It does not exercise the agent's actual tool-calling behavior, prompt quality, or model choice — those require a real (or sandboxed) LLM run, which is out of scope for `casen test`. ## See also - [Building Processes with AI](/docs/guides/ai-implement) — the overall plan/synth pipeline - [`casen connector`](/docs/cli/connector) — searching the catalog for connector-backed tools --- ### AI Decisions Route a Camunda 8 process on a Cloudflare Clef decision model — typed yes/no, choice and score answers with calibrated probabilities, through one element template. Source: https://bpmnkit.com/docs/guides/ai-decisions An [AI agent](/docs/guides/ai-agents) writes text and decides which tools to call. Often a process step needs less than that: one bounded answer that a gateway can branch on. Which team gets this ticket? Is this request urgent? How bad is the impact? A **decision model** answers exactly that kind of question. Cloudflare's [Clef](https://blog.cloudflare.com/clef-decision-models/) models do not generate text. They score the options you define in one pass and return a typed answer with a probability for each option. A gateway can then route on the answer, and route on how sure the model is. | Step needs | Use | |---|---| | Fixed rules over known inputs | A DMN decision (business rule task) | | A judgement over free text, images or messy data, with a bounded answer | **A decision model** (this guide) | | Open-ended work: write, research, call tools in a loop | An [AI Agent](/docs/guides/ai-agents) | ## The template `@bpmnkit/connectors` bundles **Cloudflare Clef Decision** (`io.bpmnkit.connectors.CloudflareClef.v1`). It is an element template for a service task that runs on Camunda's REST connector (`io.camunda:http-json:1`), so any cluster with the connector runtime can run it. You do not deploy a job worker. To set it up on a cluster and test it step by step, follow the how-to [Route a Camunda 8 process on a Cloudflare Clef decision](/blog/cloudflare-clef-decisions-camunda). | Field | Key | Notes | |---|---|---| | API token | `authentication.token` | Default `{{secrets.CLOUDFLARE_API_TOKEN}}`. Needs Workers AI permission. | | Account ID | `accountId` | Default `{{secrets.CLOUDFLARE_ACCOUNT_ID}}`. | | Model | `model` | `clef` (more accurate, has a vision encoder) or `clef-flash` (lower latency). Sets the URL and the body's `model` together. | | State | `body.state` | Required. FEEL. What the model evaluates: text, or a context or list. | | Questions | `body.questions` | Required. FEEL context of 1 to 64 questions, keyed by id. | | Images | `body.images` | Optional. FEEL list of up to 4 base64 data URLs (PNG, JPEG, WebP). | | Result expression | `resultExpression` | Default `={clef: response.body.result.answers}`. | Before you deploy, create the secrets `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` in the cluster's connector secrets. ## Questions and answers Each question has a `type` and `instructions`. Answers come back under the same ids. With the default result expression, the answer to question `team` is in `clef.team`. | Type | `criteria` | Answer | |---|---|---| | `noul` (yes/no) | Optional `{"true": …, "false": …}` | `noul`: probability of yes, 0 to 1 | | `choice` | Required. Context of 2 to 255 options: id → description | `choice`: the option id. `probabilities` per option. `confidence`. | | `score` | Required. List of 2 to 10 levels, lowest first | `score`: probability-weighted level from 0, can fall between levels. `legend`, `probabilities`, `confidence`. | ```feel ={ "urgent": {"type": "noul", "instructions": "Is this support request urgent?"}, "team": { "type": "choice", "instructions": "Which team should handle this request?", "criteria": { "billing": "Payments, invoices and refunds", "technical": "Outages, errors and configuration", "sales": "Plans and upgrades" } }, "severity": { "type": "score", "instructions": "How severe is the customer impact?", "criteria": ["No impact", "Minor", "Major", "Critical"] } } ``` The answers for these questions look like this: ```json { "urgent": { "type": "noul", "noul": 0.94 }, "team": { "type": "choice", "choice": "technical", "probabilities": { "billing": 0.06, "technical": 0.91, "sales": 0.03 }, "confidence": 0.88 }, "severity": { "type": "score", "score": 2.7, "legend": { "0": "No impact", "1": "Minor", "2": "Major", "3": "Critical" }, "probabilities": { "0": 0.01, "1": 0.04, "2": 0.2, "3": 0.75 }, "confidence": 0.71 } } ``` ## Example: triage a support ticket This process asks the three questions above about a ticket. It pages on-call for an urgent technical ticket. It sends a ticket to a person when the model is not sure which team should have it. It queues the remaining tickets by team. The full source is `apps/examples/src/07-ai-ticket-triage-clef.ts`. ```typescript import { applyConnectorTemplate } from "@bpmnkit/connectors" import { Bpmn } from "@bpmnkit/core" const triage = applyConnectorTemplate("io.bpmnkit.connectors.CloudflareClef.v1", { model: "clef-flash", "body.state": "={subject: ticket.subject, body: ticket.body, plan: customer.plan}", "body.questions": QUESTIONS, // the FEEL context above }) if (triage.problems.length > 0 || !triage.serviceTask) throw new Error(triage.problems[0]?.message) const definitions = Bpmn.createProcess("TicketTriage") .withAutoLayout() .startEvent("start", { name: "Ticket Received" }) .serviceTask("triage", { ...triage.serviceTask, name: "Triage Ticket" }) .exclusiveGateway("route", { name: "Route Ticket" }) .branch("incident", (b) => b .condition('=clef.team.choice = "technical" and clef.urgent.noul >= 0.8') .serviceTask("pageOncall", { name: "Page On-Call", taskType: "pagerduty-alert" }) .endEvent("endPaged"), ) .branch("unsure", (b) => b .condition("=clef.team.confidence < 0.6") .userTask("manualTriage", { name: "Triage by Hand", formId: "manual-triage-form" }) .endEvent("endManual"), ) .branch("billing", (b) => b .condition('=clef.team.choice = "billing"') .serviceTask("billingQueue", { name: "Queue for Billing", taskType: "billing-queue" }) .endEvent("endBilling"), ) .branch("other", (b) => b .defaultFlow() .serviceTask("supportQueue", { name: "Queue for Support", taskType: "support-queue" }) .endEvent("endSupport"), ) .build() const xml = Bpmn.export(definitions) ``` The exclusive gateway takes the first flow whose condition is true, in the order above. An urgent incident is paged even when the team answer is not certain. Set the confidence threshold from your own data. Start with a value that sends a good share of tickets to a person. Look at what the people decide, then lower the threshold. ## Test the routing In a test, mock the REST connector's job type with the variables that the result expression writes. You can then test each branch without a call to Cloudflare: ```typescript import { createProcessTest } from "@bpmnkit/engine/testing" const t = await createProcessTest({ bpmn: xml }) t.mockJob("io.camunda:http-json:1", { result: { clef: { urgent: { type: "noul", noul: 0.2 }, team: { type: "choice", choice: "billing", probabilities: {}, confidence: 0.41 }, severity: { type: "score", score: 1, legend: {}, probabilities: {}, confidence: 0.8 }, }, }, }) const run = await t.start("TicketTriage", { ticket: { subject: "Refund", body: "…" } }) // run is waiting at "manualTriage" ``` `apps/examples/tests/ticket-triage-clef.process.test.ts` tests all four branches. ## Use the template in the editor In the BPMN Kit [editor](/editor), select a service task and pick **Cloudflare Clef Decision** in the **Connector** list. The panel then shows the template's fields. ## Use the template in Camunda Modeler To use the template in Desktop Modeler or Web Modeler, write it to a JSON file: ```typescript import { writeFileSync } from "node:fs" import { getTemplate } from "@bpmnkit/connectors" const clef = getTemplate("io.bpmnkit.connectors.CloudflareClef.v1") writeFileSync(".camunda/element-templates/cloudflare-clef.json", JSON.stringify(clef, null, 2)) ``` The template is in the **AI decisions** category of the template chooser. ## Limits These limits are from Cloudflare's model page: - 1 to 64 questions for each call. A `choice` has 2 to 255 options. A `score` has 2 to 10 levels. - The context window is 64k tokens. Long text state is truncated to fit. - Images: 4 at most, 4 MiB each, 8 MiB in total. Data URLs only. Remote URLs are not accepted. - A failed call (for example HTTP 401 or 429) fails the job. The template's retries and retry backoff apply. To raise a BPMN error instead, set the error expression. --- ### Claude Code Plugin Install the bpmnkit plugin for Claude Code to get AI-first BPMN development and operations — generate diagrams, wire connectors, deploy, and resolve incidents from natural language. Source: https://bpmnkit.com/docs/guides/claude-code-plugin The bpmnkit Claude Code plugin adds BPMN-aware slash commands, autonomous agents, and ambient quality hooks directly into Claude Code. It's **CLI-first**: every skill drives `casen` via Bash — no MCP server, no proxy daemon. Every process is authored as a `ProcessPlan` JSON file and compiled deterministically by `casen synth`; no skill ever writes BPMN XML by hand. ## Prerequisites Install the BPMNKit CLI globally: ```sh npm install -g @bpmnkit/cli ``` That's it — nothing else needs to be running. ## Installation ### Local (development or team) ```sh claude --plugin-dir ./plugins-claude/bpmnkit-claude ``` Or add to your project's `.claude/settings.json`: ```json { "plugins": [ { "path": "./plugins-claude/bpmnkit-claude", "scope": "project" } ] } ``` ### Via marketplace ```sh /plugin marketplace add github:bpmnkit/bpmnkit /plugin install bpmnkit ``` ## Configuration When the plugin is enabled, Claude Code prompts for two optional values: | Config key | Description | |---|---| | `camunda_endpoint` | Camunda 8 REST API endpoint (leave blank for local Reebe) | | `camunda_token` | Camunda 8 OAuth2 token (leave blank for local Reebe) | Leave both blank to use the local [Reebe engine](/docs/cli/casen#local-engine-reebe). --- ## Skills ### `/bpmnkit:implement ` The main entry point — natural language to a compiled, tested BPMN process. ``` /bpmnkit:implement order fulfillment with payment and inventory check /bpmnkit:implement employee onboarding process with HR approval ``` Resolves connectors, writes a `ProcessPlan`, compiles it with `casen synth`, tests it, scaffolds any missing workers, and asks where to deploy. --- ### `/bpmnkit:extend ` Extend an existing process from a natural-language change. ``` /bpmnkit:extend order-fulfillment.bpmn add a timeout boundary on the payment step ``` Lifts the process to plan form (`casen plan extract`), writes a small delta plan, and merges it in (`casen synth --merge`) — the summary is at the element level, not an XML diff. --- ### `/bpmnkit:agent [file] ` Design and add a Camunda AI Agent Sub-process. ``` /bpmnkit:agent add a support-triage agent with tools: search KB (http), escalate to human (user task) ``` Works out the provider/model, prompts, and tools (each mapped via connector search or an existing worker job type), then compiles and mock-tests the agent step. See [AI Agents](/docs/guides/ai-agents) for the full pattern. --- ### `/bpmnkit:connect ` Wire an existing plan step to an external system via a Camunda connector template. ``` /bpmnkit:connect order-fulfillment.bpmn notify_ops slack ``` --- ### `/bpmnkit:review [file.bpmn]` Run the full static analyzer and report deploy-readiness. ``` /bpmnkit:review /bpmnkit:review order-fulfillment.bpmn ``` Reports findings grouped by severity and ends with an explicit "Deploy-ready: yes/no" verdict. --- ### `/bpmnkit:test [file.bpmn]` Run scenario tests and report path/branch coverage. ``` /bpmnkit:test /bpmnkit:test order-fulfillment.bpmn ``` --- ### `/bpmnkit:deploy [file.bpmn] [--local|--camunda]` Gate on deploy-readiness, then deploy to local Reebe or Camunda 8. ``` /bpmnkit:deploy /bpmnkit:deploy order-fulfillment.bpmn --camunda ``` --- ### `/bpmnkit:instances [process-id] [--active|--failed]` List running process instances. ``` /bpmnkit:instances /bpmnkit:instances order-process --failed ``` --- ### `/bpmnkit:incidents [--process-id X]` List open incidents with suggested resolution actions. ``` /bpmnkit:incidents /bpmnkit:incidents --process-id order-process ``` --- ## Reference docs Every skill reads the relevant reference doc before authoring a plan: | File | Contents | |---|---| | `references/plan-format.md` | The `ProcessPlan` JSON schema + annotated, tested examples | | `references/connectors.md` | The bundled connector catalog index | | `references/agentic.md` | The AI Agent Sub-process pattern, binding keys, a full example | | `references/feel.md` | FEEL syntax crib sheet + the `"="`-means-expression convention | | `references/modeling-style.md` | Camunda naming/structure conventions | --- ## Agents ### `process-builder` Builds a complete BPMN process end-to-end from a description. Invoke directly: ``` Build me an invoice approval process for accounts payable ``` **What it does:** 1. Asks clarifying questions (error paths, user tasks, deploy target) 2. Checks domain patterns (`casen pattern list`/`get`) 3. Resolves connectors, writes the plan 4. Compiles it with `casen synth` 5. Shows a preview and **waits for your approval** 6. Tests the process, fixing failures by adjusting the plan 7. Scaffolds a worker stub for every job type with no connector 8. Gates on `casen lint --profile deploy`, then deploys to the chosen target 9. Reports the process ID, files created, and next steps --- ### `incident-resolver` Triages and resolves open Camunda incidents. Invoke directly: ``` Investigate and resolve the open incidents ``` **What it does:** 1. Fetches all open incidents (`casen incident list`) 2. Groups by process + error type, sorted by count 3. Investigates root cause per group 4. **Proposes a fix and waits for your approval** before executing 5. Executes approved fixes (retry jobs, resolve incidents, migrate instances) 6. Verifies the count dropped 7. Reports a resolution summary --- ## Ambient Hooks The plugin installs two background hooks: | Hook | Trigger | What it does | |---|---|---| | SessionStart | Every Claude Code session | Checks `casen` is installed | | PostToolUse | After any Write or Edit | Silently lints any `.bpmn` file that was written | The PostToolUse hook means every `.bpmn` file you (or Claude) writes is automatically checked against the BPMNKit optimizer — zero extra steps. --- ## No MCP server required Unlike earlier versions of this plugin, none of the skills above call an MCP tool — they call `casen` directly via Bash. The separate `@bpmnkit/proxy` MCP server (`bpmn-aikit`) still exists for Claude Desktop, Cursor, or other MCP-only hosts, but it's unrelated to this plugin. --- ### Drop — Share & Co-edit Turn a BPMN, DMN or Camunda Form file — or a FEEL expression and the variables it reads — into a link anyone can open, watch live, comment on, and edit one writer at a time. No account, no modeler install, no Camunda cluster. Source: https://bpmnkit.com/docs/guides/drop [BPMN Kit Drop](/drop) is the shortest path between a diagram on your machine and the person who needs to look at it. Drag a file onto the page, get a short link back, send it. Whoever opens the link sees the diagram rendered in their browser — no account, no modeler install, and no Camunda cluster anywhere in the story. It is also where a review happens. A drop is not frozen: anyone with the link can comment on an element and @mention the people reviewing with them, take the edit baton and change the diagram while everyone else watching sees the change arrive, and a shared FEEL statement can be opened, run against different values, and saved back. ## Sharing a file Open and drop a file onto the page — the whole page is the target — or paste one from the clipboard. `.bpmn`, `.dmn`, `.form` and `.feel` are all rendered; `.xml` and `.json` are accepted and sniffed for the kinds above. | Limit | Value | | --- | --- | | Files per drop | 20 | | Size of a single file | 900 KB | | Total per drop | 5 MB | | Retention | 90 days after the last view or edit | The share id is 11 base58 characters — about 64 bits, so a drop is unguessable and unlistable, but it is **not** access-controlled: anyone holding the link can view the diagram and take a turn editing it. Treat a drop the way you would treat an unlisted link, not the way you would treat a private repository. You can also start a drop from a diagram you just drew rather than a file you already had: the [browser editor](/editor) has **Share as a drop** in its main menu, which uploads the open diagram through the same endpoint and the same checks. ### From the command line A drop is a plain HTTP upload, so `curl` works: ```sh curl -s -X POST https://bpmnkit.com/drop/api/drops \ -F files=@order-process.bpmn # → { "shareId": "7Fq2mKd9xTs", "url": "https://bpmnkit.com/drop/7Fq2mKd9xTs", "files": [...] } ``` And the stored file comes back either as uploaded or as the parsed model: ```sh curl -s https://bpmnkit.com/drop//manifest.json curl -s "https://bpmnkit.com/drop//f/order-process.bpmn" # original bytes curl -s "https://bpmnkit.com/drop//f/order-process.bpmn?format=json" # @bpmnkit/core model ``` A FEEL statement is a file like any other, so the same upload carries one: ```sh echo '{"expression":"amount > limit","context":{"amount":90,"limit":50}}' \ | curl -s -F "files=@-;filename=condition.feel" https://bpmnkit.com/drop/api/drops ``` ## Sharing a FEEL expression A gateway condition or a decision-table entry is unreadable on its own: `order.amount * (1 + vat)` says nothing until you know what `order` and `vat` were. So a FEEL drop carries both halves — the expression **and** the context it runs against — and the share page evaluates them in the reader's browser rather than showing a value you typed in by hand. Three ways in, all producing the same thing: - **The composer on [/drop](/drop).** Two boxes and a result that updates as you type, then **Get a share link**. - **The [FEEL playground](/feel-functions).** **Share as a drop** posts whatever is in the expression and context boxes. - **A `.feel` file.** Drop or `curl` it like any other file. A `.feel` file is either the bare expression: ```text if risk.score < 40 then "approve" else "refer to underwriting" ``` or a JSON document, which is what the composer and the playground post and what a drop stores: ```json { "expression": "if risk.score < 40 then \"approve\" else \"refer to underwriting\"", "context": { "risk": { "score": 22, "band": "low" } }, "mode": "expression" } ``` `mode` is `"expression"` (the default) or `"unary-tests"`. In `unary-tests` mode the statement is read the way a decision-table input entry is, and the value under test is whatever the context bound to `?`: ```json { "expression": "[18..65]", "context": { "?": 30 }, "mode": "unary-tests" } ``` A bare expression is stored as the document it became, so the **Original** download always round-trips back through the same parser. Expressions that do not parse are refused at upload, exactly as unparseable BPMN is — a link that renders a syntax error is not worth sending. ### Trying it with your own numbers The reason to open somebody else's expression is usually to run it against your own data: change the amount, see whether the gateway still goes the way they said it does. So the share page has an **Edit** button for a statement too, and it opens the same two boxes the composer has, on whatever the drop currently says. Everything you type there stays in your browser — it evaluates as you type, and the link is untouched until you press a button that says otherwise: - **Save to this drop** writes your statement back to the link, so everyone opening it afterwards sees the new one. The statement it replaces becomes a milestone in the [version log](#reviewing-it-together), and the expression the drop was uploaded with is still there as **Original**, as always. - **Share as new** posts what is in the boxes as a drop of its own and takes you there. The original link is left exactly as it was — this is the way out when you were only playing, or when the drop is one you may not rewrite. - **Reset** puts the drop's own statement back in the boxes. A statement is not edited through the baton: there is no op vocabulary for two boxes of text, and no live replay to watch, so nothing is claimed and nothing is locked. What protects the drop instead is the save itself. A save carries the hash of the statement the page opened, and one made against a statement that has since been replaced is refused with what the drop says now — so two people editing the same link cannot quietly overwrite each other, and a reader who sees that message can reload, or keep theirs with **Share as new**. Where the deployment configures Turnstile, the challenge is on the save and not on opening the editor: trying an expression out is never worth a challenge, and writing to somebody else's link is. ## Reviewing it together Everything below happens on the share page itself — there is nothing to install on either end. **Live presence.** The topbar counts the people who have the drop open, and says when one of them is editing. A diagram moving under a reader's eyes is only unsettling when nothing on the page explains it. **One writer at a time.** Editing is a baton, not a merge. Press **Edit** to claim it and the diagram becomes editable for you and stays live for everyone else; press **Done** to hand it back. A second person pressing Edit while you hold it is told who has it. There is no conflict resolution because there is never a second writer to conflict with. **No save button.** Changes persist as you make them, so there is no moment at which a reviewer can lose work by closing the tab — and none at which they can be asked whether they meant it. That is what makes the version log below load-bearing rather than a nicety. **Asking the AI while you edit.** Where the deployment has AI changes on (a closed beta, behind an access code), **Ask AI** opens a chat beside the editor. Say what to change — select elements first to make the request about them — and the change is made in the editor with the layout kept, as one edit that **Undo** reverts. A diagram drafted with *Describe a process* on the Drop landing page has **Open in editor**: it is stored as a drop like a share link, and opens straight into the editor with this chat, so you can keep refining it there. **Version history.** Every file keeps the original it was uploaded as, pinned and never overwritten, plus its ten most recent milestones. Repeated saves inside an hour collapse into one milestone, and a new editing session always starts its own, so a stranger editing at 10:45 cannot overwrite the previous editor's 10:30 state. Restoring **appends**: the state being replaced becomes a milestone first, so restoring can never be the thing that loses work. Undoing a restore is another restore. **Visual diff.** Two drops can be put side by side at `/drop//diff/`: added, removed, changed and *moved* elements marked on synchronised canvases, with pan and zoom locked together. The same diff is available offline as [`casen diff bpmn`](/docs/cli/diff) and as a plugin in `@bpmnkit/plugins`. **Comments.** Press **Comments** to open the review panel. With it open, click an element on the diagram to comment on that element, or just write to comment on the file as a whole. DMN, form and FEEL files take whole-file comments only. Each element with open threads gets a small numbered marker on the canvas; click it to jump to its threads. Threads take replies, and anyone who has commented on the drop can **Resolve** a thread or **Reopen** it. New comments, edits and resolutions reach everyone who has the drop open as they happen. A comment is anchored by the element's id, not by its position. It stays with the element when the element moves, and survives any number of edits. If a later edit deletes the element, the comment is still listed, marked **on a removed element** with the name the element had when the comment was written. While you preview an older version from **History**, the same label reads **not in this version**. **@mentions.** Type `@` in a comment to pick from the names this drop has seen: the people who have it open now and everyone who has commented on it. A mentioned name is highlighted in the comment. If the mentioned person has the drop open, a notice appears at the top of their page with a button that opens the thread. Drop has no accounts and stores no email addresses, so **a mention sends no email and no push notification**. Someone who does not have the drop open sees the mention the next time they open it. If it matters, send them the link yourself. **Who you are, without an account.** Your display name is whatever you type into the comment box. The browser remembers it and shows it to the other people viewing the drop. Nothing checks it, so two people can both call themselves Anna. Your first comment on a drop gives your browser a private key for that drop, and the server stores only a hash of it. That key is what lets you edit or delete **your own** comments, and nobody else's. It lives in this browser only: clear the site data, or change browsers, and your earlier comments can no longer be edited or deleted from there. A deleted comment that has replies leaves a "Comment deleted" placeholder, so the replies still make sense. Comments follow the same abuse rules as edits. Where the deployment configures Turnstile, your first comment on a drop needs one challenge, and your later comments on it do not. Each address can make 60 comment writes an hour. A drop holds at most 500 comments, and a comment is at most 2,000 characters. A drop whose content is on the ban list takes no new comments. The demo drop and drops an operator has pinned are read-only for comments as well as for edits. Comments are deleted with their drop: when it expires, or when an operator removes it. Diagram editing is limited to BPMN files with a single process — the editor handles one process at a time — and the built-in demo drop is read-only, though **Edit a copy** will upload it as a drop of your own. A FEEL statement is edited differently and on its own terms; see [trying it with your own numbers](#trying-it-with-your-own-numbers) above. ### Rate limiting the edit baton Because a drop is editable by anyone holding the link, claiming the baton can be put behind a [Turnstile](https://developers.cloudflare.com/turnstile/) challenge — one challenge per editing session, not per keystroke. It is invisible to somebody who takes the baton once and edits for half an hour, and a real cost to a script that wants to rewrite every drop it can find. On a self-hosted deployment it is configuration (`TURNSTILE_SITE_KEY` and `TURNSTILE_SECRET`); with neither set, claims are not challenged. ## Running your own Drop is a single Cloudflare Worker in the monorepo (`apps/drop`), MIT-licensed like everything else. Files live in D1 as the typed `@bpmnkit/core` model alongside the byte-faithful original; presence and live editing are a Durable Object per share. ```sh pnpm --filter @bpmnkit/drop build wrangler d1 migrations apply bpmnkit-drop --local wrangler dev --local --port 8787 \ --var DROP_ADMIN_TOKEN:devtoken --var REPORT_IP_SALT:devsalt ``` That runs the Worker, D1 and the Durable Object in a local simulator, so the whole app works offline with no Cloudflare account. For a real deployment, `pnpm --filter @bpmnkit/drop provision` is an idempotent script that creates the database, applies the migrations, builds the client bundles and deploys the Worker; re-running it skips whatever is already in place. See `apps/drop/DEPLOY.md` for the full runbook. ## What Drop is not - **Not a permission system.** Link-holders are editors and commenters. If a diagram should not be editable by whoever it reaches, do not put it in a drop. - **Not a notification service.** A mention reaches someone only if they have the drop open. There is no email, no inbox, and no account to send one to. - **Not storage.** A drop expires 90 days after it was last touched, and the version log holds eleven states, not every state. - **Not a modeler.** It renders and edits one process at a time. For authoring, use the [browser editor](/editor), the [VS Code extension](/docs/guides/vscode) or the [builder API](/docs/packages/core). --- ### VS Code Extension View, edit, compare and analyse BPMN, DMN and Camunda Form files inside VS Code — the same renderer, linter and engine the rest of BPMN Kit uses, with no bpmn.io and no round-trip rewrites of your XML. Source: https://bpmnkit.com/docs/guides/vscode **BPMN Kit for VS Code** puts the toolkit where the code already is. It renders `.bpmn`, `.dmn` and `.form` files, reports the same findings `casen lint` reports, compares a diagram against `HEAD`, runs the process without a cluster, and edits the file without reformatting it. The renderer is [`@bpmnkit/canvas`](/docs/packages/canvas), the same from-scratch BPMN 2.0 implementation the website and the browser editor use. There is no bpmn.io anywhere in the extension, which is the point: the files it shows you are the files git has, byte for byte. ## Installing Every version is attached as a `.vsix` to a [GitHub Release](https://github.com/bpmnkit/bpmnkit/releases?q=vscode-v) tagged `vscode-v`. Download it and install it with **Extensions → … → Install from VSIX…**, or from a terminal: ```sh code --install-extension bpmnkit-.vsix ``` The same release workflow publishes to the Visual Studio Marketplace and Open VSX as `bpmnkit.bpmnkit`; those listings are being set up, and this page will link them once they are live. To build it yourself from the monorepo: ```sh git clone https://github.com/bpmnkit/bpmnkit cd bpmnkit && pnpm install pnpm turbo build --filter bpmnkit... pnpm --filter bpmnkit package # → apps/vscode/bpmnkit.vsix ``` It activates on a workspace containing a `.bpmn` file, and requires VS Code 1.90 or newer. ## What it does ### Preview beside the source `.bpmn`, `.dmn` and `.form` all render, with a minimap and zoom for BPMN. The preview follows the buffer as you type rather than on save, and when the XML is momentarily unparseable it keeps the last drawing that worked rather than blanking. The text editor stays the default for all three file types. Open the diagram with **BPMN Kit: Open Diagram to the Side**, with *Reopen Editor With…*, or make it your default through `workbench.editorAssociations`. ### Editing that leaves a readable diff The diagram editor is a **text** custom editor: it edits the same `TextDocument` a text editor would open. The file is dirty when the document is, Ctrl+S saves it, hot exit restores it, undo is the editor's own undo, and a text editor open on the same file is a second view of one document rather than a competing copy. Type in the XML and the diagram follows; move a box and the XML follows. Saving writes a diff a reviewer can read. A visual editor normally serialises the whole model, which reformats the file on the first change and buries one edit in a rewrite of everything. This one writes the file that was already there: renaming a task changes the line with the task on it, moving a box changes two numbers, and your indentation, attribute order and comments come back untouched. Opening a diagram and saving it without editing anything leaves the file byte for byte. Form files get the same treatment — indentation, key order and trailing newline all survive. That behaviour is not extension-specific; it is [`exportPreserving()`](/docs/packages/core#exportpreservingoriginal-definitions) from `@bpmnkit/core`, available to anything that writes a model back over a file it parsed. ### Findings in the Problems panel The same static analysis `casen lint` runs — flow reachability, naming, FEEL syntax, data flow, Camunda 8 deployability — reported against the element that caused it, so clicking a problem takes you to the tag rather than to line 1. The analysis matches the file. A diagram that declares no `modeler:executionPlatform` is not judged against Camunda 8 deployability, because "this service task has no `zeebe:taskDefinition`" is not a defect in a diagram that was never going to be deployed to Zeebe. Turn on `bpmnkit.lint.forceEngineRules` to apply those rules anyway. A `.bpmnlintrc` in the diagram's folder or above is honoured the way `casen lint` honours it. When the workspace has `bpmnlint` installed, bpmnlint's own findings, plugin rules included, appear with source `bpmnlint`. See [bpmnlint Compatibility](/docs/guides/bpmnlint). A connector task's required inputs are checked against the element templates **this file** sees: `.camunda/element-templates/` in the diagram's folder and every folder above it up to the workspace folder, the nearest winning on an id, then the bundled Camunda templates. A diagram in `a/` is never checked against `b/`'s templates. The extension host reads them itself; no proxy is needed. An unsaved file has no folder, so only the bundled templates apply. See [workspace templates](/docs/packages/connectors#workspace-templates). ### A visual diff in Source Control Right-click a `.bpmn` file in Source Control to see it against `HEAD`, or select exactly two in the Explorer to compare them with each other. Added, removed, changed and moved elements are marked on synchronised canvases — a moved element reads as *moved*, not as two unrelated pictures. The text diff stays where it was; this is a second view of the same change, not a replacement for the first. The same comparison is available as [`casen diff bpmn`](/docs/cli/diff) and on a [drop](/docs/guides/drop). ### Run the diagram in the editor [`@bpmnkit/engine`](/docs/packages/engine) is a BPMN engine written in TypeScript, so the preview does not need a cluster to execute what is on screen. Press **Run** and watch tokens move; press **One Step** to advance the instance one element at a time, reading the variables as they change. Nothing is deployed and nothing leaves your machine. ### A FEEL playground on your selection Select an expression anywhere in the XML and open the playground: it comes up pre-filled, with a context you can edit and the result evaluated as you type. Unary tests too, for decision-table input entries. ### Deploy against your own clusters Deployment targets come from `casen`'s profile store — the same file [`casen profile create`](/docs/cli/casen#connection-profiles) writes — so there is no second place to configure a cluster and no credentials in your workspace settings: ```sh casen profile create staging --base-url https://.camunda.io/ \ --auth-type oauth --client-id … --client-secret … ``` Every `c8` profile then appears in the extension. Deploy the open file, or deploy and start an instance with variables; the instance key comes back in a notification. Deploy-and-start also offers the payloads it finds in `.camunda/payloads/*.json`, walking up from the diagram, so the inputs a process is always tried with are a pick rather than a paste. Credentials are read only to sign the request — nothing in the extension stores, displays or logs them. ### Copy the diagram as text For a code review, where a picture cannot go. **BPMN Kit: Copy Diagram as ASCII** renders the layout into a fenced block that pastes into a pull request, an issue or a commit message, dedented so the diagram is not mostly margin. ## Commands | Command | Where | | --- | --- | | **BPMN Kit: Open Diagram to the Side** | Editor title bar, command palette | | **BPMN Kit: Compare Diagram with HEAD** | Source Control context menu, editor title bar | | **BPMN Kit: Compare Diagrams Visually** | Explorer, with exactly two `.bpmn` files selected | | **BPMN Kit: Open FEEL Playground** | Command palette — pre-filled from the selection | | **BPMN Kit: Copy Diagram as ASCII** | Editor title bar, Explorer, command palette | | **BPMN Kit: Deploy to Camunda 8** | Command palette | | **BPMN Kit: Deploy and Start Instance** | Explorer, command palette | ## Settings | Setting | Default | What it does | | --- | --- | --- | | `bpmnkit.lint.enabled` | `true` | Report findings in the Problems panel | | `bpmnkit.lint.run` | `onType` | `onType` or `onSave` | | `bpmnkit.lint.forceEngineRules` | `false` | Apply Camunda 8 rules to an engine-neutral diagram | | `bpmnkit.lint.bpmnlintrc` | `true` | Honour the nearest `.bpmnlintrc`, and run the workspace's bpmnlint if installed | | `bpmnkit.viewer.grid` | `true` | Dot grid behind the diagram | | `bpmnkit.viewer.minimap` | `true` | Minimap in the BPMN viewer | | `bpmnkit.simulation.enabled` | `true` | Offer step-through simulation in the preview | | `bpmnkit.editing.enabled` | `true` | Let the diagram editor change the file | Findings are reported for `.bpmn` files that are open; a file the editor has not loaded is not analysed, same as every other linter in VS Code. Set `bpmnkit.editing.enabled` to `false` for the same editors with editing switched off, when a diagram should be openable with no chance of changing it. ## Support The extension is pre-1.0 and community-supported, developed in the open in the [monorepo](https://github.com/bpmnkit/bpmnkit) alongside the packages it is built from. Minor versions may change behaviour before 1.0: the features above will not disappear, but setting names and command titles may still move. Bugs and requests go to [GitHub issues](https://github.com/bpmnkit/bpmnkit/issues). MIT-licensed, like everything else in the repository. --- ### bpmnlint Compatibility Keep your .bpmnlintrc. casen lint and the VS Code extension read it, map bpmnlint's rules onto BPMN Kit's findings, and run your project's own bpmnlint for bpmnlint-plugin-* rules. Includes the rule-by-rule mapping table. Source: https://bpmnkit.com/docs/guides/bpmnlint Teams coming from bpmn.io usually already have a lint configuration: a `.bpmnlintrc` next to their diagrams, bpmnlint's built-in rules, and maybe a `bpmnlint-plugin-*` package or two. BPMN Kit reads that file. You do not need to translate it. `casen lint` and the [VS Code extension](/docs/guides/vscode) look for `.bpmnlintrc` in the diagram's folder and then in each parent folder, and use the first one they find. What happens next depends on whether your project has bpmnlint installed. ## Two ways a `.bpmnlintrc` is honoured **Your project has bpmnlint installed** (`bpmnlint` and `bpmn-moddle` are in the `node_modules` next to the `.bpmnlintrc` or above it). BPMN Kit loads that bpmnlint and runs your configuration with it. Presets, `plugin:` configs, third-party plugin rules and rule options all resolve exactly as they do for the `bpmnlint` command. The findings appear under the `bpmnlint` category, named by their rule. BPMN Kit then drops its own findings for every rule bpmnlint just ran, so no problem is reported twice. **bpmnlint is not installed.** BPMN Kit applies the configuration to its own analysis: - A finding that is BPMN Kit's equivalent of a bpmnlint rule (see the table below) takes the level your config gives that rule. `"off"` removes it. - A rule your config enables, and that BPMN Kit implements natively for compatibility, runs. These rules never run without a `.bpmnlintrc`, so the default `casen lint` report does not change. - A rule your config does not mention keeps BPMN Kit's default. The `.bpmnlintrc` overrides BPMN Kit where the two overlap. It does not switch off BPMN Kit's other checks (FEEL syntax, data flow, Camunda 8 deployability and so on). - A plugin rule (`acme/no-foo`), an unknown rule, or a `plugin:` config in `extends` cannot be applied without bpmnlint. BPMN Kit says so rather than ignoring it silently. The exception is `bpmnlint-plugin-camunda-compat`: BPMN Kit has its own [Camunda version check](#camunda-version-compatibility) and uses it in place of the plugin. bpmnlint is never a dependency of BPMN Kit. It is loaded with a dynamic `import()` from your project, only when your project has it, the same way editor integrations load a project's own ESLint. ## `casen lint` ```sh casen lint lint diagrams/order.bpmn ``` ```text → Using /work/project/.bpmnlintrc (BPMN Kit's equivalents of its rules). → Not applied — no BPMN Kit equivalent: plugin:acme/recommended (a plugin config — install bpmnlint in the project to use it). → ⚠ [pattern] [Error_payment] Global error "Error_payment" is not referenced by any element. (global) → ✖ [pattern] [End_done] Element "End_done" has no diagram information (BPMNDI). (no-bpmndi) ``` The rule name in parentheses is the bpmnlint rule the finding was reported under. With bpmnlint installed, the first line reads `(bpmnlint 11.14.0)` and bpmnlint's own findings appear as `[bpmnlint]`. | Flag | Effect | |---|---| | `--no-bpmnlintrc` | Ignore any `.bpmnlintrc`. The report is the same as without one. | | `--categories flow,naming` | Leaves out real bpmnlint. Its findings belong to the `bpmnlint` category, so it only runs when `--categories` is not given or includes `bpmnlint`. BPMN Kit's equivalents stand in for it. | | `--format json` | stdout stays a JSON array of findings. A finding governed by the config has a `bpmnlintRule` field. Notices go to stderr. | The exit code works as before: the command fails when an error-level finding remains. Your config can change that in both directions. It can raise a warning to `error`, or lower an error to `warn`. A `.bpmnlintrc` that is not valid JSON, or has a rule level that is not `off`, `warn`, `error`, `info` or `0`–`3`, stops the command with the file's path in the message. Unlike the `bpmnlint` command, which reads `.bpmnlintrc` from the current directory only, BPMN Kit starts at the diagram's folder. Running `casen lint` from anywhere therefore gives the same result. Plugins and `moddleExtensions` resolve from the `.bpmnlintrc`'s folder. ## VS Code The Problems panel honours the same file, found the same way. A finding from real bpmnlint shows `bpmnlint` as its source and the rule as its code. A finding BPMN Kit reports under a bpmnlint rule has the rule name at the end of its message. If some configured rules could not be applied, one information-level problem at the top of the file lists them. A broken `.bpmnlintrc` shows as an error there too. Editing a `.bpmnlintrc` re-analyses the open diagrams. Set `bpmnkit.lint.bpmnlintrc` to `false` to ignore the file. Only diagrams saved on disk are checked against a `.bpmnlintrc`, because an untitled buffer has no folder. ## Rule mapping These are the 28 rules bpmnlint 11.14 ships. All 28 are **exact**: the rule has the same semantics as bpmnlint's, and BPMN Kit reports the same elements, in the top-level process and inside embedded, event and ad-hoc sub-processes and transactions. We check this with a test for each rule, and with a test that runs both linters under `bpmnlint:all` against every `.bpmn` file in the BPMN Kit repository. That corpus has 43 files: BPMN Kit's own samples and fixtures, the OMG MIWG test models, and a fixture that puts each rule's violations and exemptions inside every kind of sub-process. It is still a corpus, so treat it as evidence, not proof. "Native" marks a check BPMN Kit added for this compatibility layer. It runs only when your config enables the rule. For four rules, BPMN Kit's own check looks at a different set of elements, so the native check **replaces** it while your config sets the rule, at any level. For example, `flow/unreachable` reports everything that cannot be reached from a start event, and bpmnlint's `no-implicit-start` reports only elements without an incoming flow. Without a `.bpmnlintrc`, `casen lint` reports BPMN Kit's own checks as before. | bpmnlint rule | recommended | BPMN Kit finding | Match | Notes | |---|---|---|---|---| | `ad-hoc-sub-process` | error | `flow/ad-hoc-start-end-event` (native) | Exact | | | `conditional-event` | — | `flow/conditional-event-no-condition` (native) | Exact | | | `conditional-flows` | error | `feel/missing-condition` (native) | Exact | Replaces `feel/empty-condition`. | | `end-event-required` | error | `flow/no-end-event`, `flow/sub-process-no-end-event` (native) | Exact | | | `event-based-gateway` | error | `flow/event-gateway-invalid` (native) | Exact | | | `event-sub-process-typed-start-event` | error | `flow/event-sub-process-untyped-start` (native) | Exact | | | `fake-join` | warn | `flow/multi-incoming-task` (native inside sub-processes and for start events) | Exact | | | `global` | warn | `pattern/global-element` (native) | Exact | | | `label-required` | error | `naming/missing-label` (native) | Exact | Replaces `naming/unlabeled-task`, `naming/unlabeled-start-event`, `naming/unlabeled-end-event`, `naming/split-gateway-no-label` and `naming/missing-flow-condition`. Lanes are read from the process's first lane set, the one BPMN Kit models. | | `link-event` | error | `flow/link-event-mismatch` (native) | Exact | | | `no-bpmndi` | error | `pattern/missing-di` (native) | Exact | | | `no-complex-gateway` | error | `pattern/complex-gateway` (native) | Exact | | | `no-disconnected` | error | `flow/disconnected` (native) | Exact | | | `no-duplicate-sequence-flows` | error | `flow/duplicate-sequence-flow` (native) | Exact | One finding for each duplicate flow, which names the flow, its source and its target. bpmnlint reports these three separately. | | `no-gateway-join-fork` | error | `flow/mixed-gateway` (native inside sub-processes) | Exact | | | `no-implicit-end` | error | `flow/implicit-end` (native) | Exact | Replaces `flow/dead-end`. | | `no-implicit-split` | error | `flow/implicit-split` (native) | Exact | | | `no-implicit-start` | error | `flow/implicit-start` (native) | Exact | Replaces `flow/unreachable`. | | `no-inclusive-gateway` | warn | `pattern/inclusive-gateway` (native) | Exact | | | `no-overlapping-elements` | warn | `pattern/overlapping-elements` (native) | Exact | | | `single-blank-start-event` | error | `flow/multiple-blank-start-events` (native) | Exact | | | `single-event-definition` | error | `flow/multiple-event-definitions` (native) | Exact | | | `standard-size` | — | `pattern/non-standard-size` (native) | Exact | Accepts the same per-type options, for example `["warn", { "bpmn:Task": { "width": 120, "height": 80 } }]`. | | `start-event-required` | error | `flow/no-start-event`, `flow/sub-process-no-start-event` (native) | Exact | | | `sub-process-blank-start-event` | error | `flow/sub-process-typed-start` (native) | Exact | | | `superfluous-gateway` | warn | `flow/redundant-gateway` (native inside sub-processes) | Exact | | | `superfluous-label` | warn | `naming/superfluous-flow-label` (native) | Exact | | | `superfluous-termination` | warn | `flow/superfluous-termination` (native) | Exact | | `bpmnlint:recommended`, `bpmnlint:all` and `bpmnlint:correctness` expand to bpmnlint 11.14's presets. Rule names are normalised the way bpmnlint normalises them, so `bpmnlint/label-required` and `label-required` are the same rule. Rules that have no equivalent without bpmnlint installed: - **Plugin rules and configs.** Everything from `bpmnlint-plugin-*`, except the `bpmnlint-plugin-camunda-compat` rules listed as covered below. Install bpmnlint in the project and they run. - **Unknown rule names.** These are reported as unknown, the same as bpmnlint would report them. Where the native equivalents check connections, they read the sequence flows, not the ``/`` children. Real bpmnlint reads the children. For files a modeler wrote, the two are the same. For hand-written XML that leaves the children out, bpmnlint sees no connections at all, and BPMN Kit sees the flows. ## Camunda version compatibility Camunda Modeler checks a diagram against the Camunda 8 version it targets. The version is in `modeler:executionPlatformVersion` on ``, and the rules come from `@camunda/linting`, which runs `bpmnlint-plugin-camunda-compat`. BPMN Kit does the same check itself, as `compat/…` findings in the `deploy` category. It runs in `casen lint`, in `optimize()`, in the editor's lint panel and in the VS Code extension, on every model that names a Camunda Cloud / Camunda 8 platform and version. A model without them gets no `compat` findings. ```text ✖ [deploy] [tools] Ad-hoc sub-process "Tools" needs Camunda 8.7 or newer; this model targets Camunda 8.6. ✖ [deploy] [notify] Signal end event "Notify" needs Camunda 8.3 or newer; this model targets Camunda 8.2. ✖ [deploy] [wait] Timer intermediate catch event "wait" has timeDuration "5 minutes", which is not an ISO 8601 duration (PT15M). ``` Each finding is `compat/`, named after the plugin rule it reproduces, and has the plugin's severity. Three kinds of problem are reported: - **Something the target version cannot run.** An element or event definition that is newer than the target (inclusive gateways 8.1, `bpmn:task` 8.2, escalation and link events 8.2, signal events 8.2/8.3, compensation 8.5, ad-hoc sub-processes 8.7, conditional events 8.9), or one no version runs (complex gateway, transaction). Also Zeebe extensions and properties: `zeebe:properties` (8.1), candidate users, task schedule and `propagateAllParentVariables="false"` (8.2), start event forms (8.3), `formId` and collapsed sub-processes (8.4), `zeebe:userTask` (8.5), execution listeners, `bindingType`, version tags and task priority (8.6), task listeners (8.8), and business ids, job priority and `beforeAll`/`cancel` listeners (8.10). A cron timer cycle needs 8.1, and a `timeDate` on a boundary or intermediate event needs 8.3. - **A property the target version requires.** A job type, called decision or script, a called process id, a message name and correlation key, a timer value that parses as ISO 8601 or cron, an error code, an escalation code, a signal name, a multi-instance input collection, a condition on each non-default flow out of a gateway, and listener types. - **Something wrong inside a value.** A FEEL built-in newer than the target (`uuid()` and `trim()` need 8.6, `from json()` 8.9, from `@camunda/feel-builtins`); a mapping target or result variable that is not a variable name; a secret written as `secrets.X`, or as `{{secrets.X}}` once 8.10 has `camunda.secrets.X`; a `camunda.secrets.X` reference in a string, a list or an `if` branch's context, where 8.10 cannot resolve it; inbound connector properties (`messageTtl`, `consumeUnmatchedEvents`, `deduplicationModeManualFlag`) before 8.6; a link event with no name, or two link catch events with the same name; duplicate header keys on one execution listener (8.10); and a loop of plain tasks, manual tasks and call activities with nothing that waits. Agent tools (8.8) are checked too: `fromAi()` only in an input mapping of a tool's first element, with a `toolCall.` key and a quoted description, and each tool's result mapped to `toolCallResult`. These rules keep the plugin's own messages, such as `FEEL function requires Camunda >=8.6`. From 8.2 on, processes that are not marked executable are skipped, as Modeler skips them. A version newer than the table is checked as the newest version the table has (8.10). Later versions only remove restrictions. The version table is data (`CAMUNDA_COMPAT_RULES` in `@bpmnkit/core`), taken from `bpmnlint-plugin-camunda-compat` 2.61.0 (`@camunda/linting` 3.57.0). BPMN Kit does not report a problem twice. When a `deploy/*` check already reports it on the same element, only that finding stays. For example, `deploy/service-task-no-type` stands in for `compat/implementation`, and when a `.bpmnlintrc` runs bpmnlint's `link-event`, `flow/link-event-mismatch` stands in for `compat/link-event`. ### With a `.bpmnlintrc` `extends: "plugin:camunda-compat/camunda-cloud-8-6"` (or the `bpmnlint-plugin-camunda-compat` spelling, and any `camunda-cloud-1-0` to `camunda-cloud-8-10`) runs this check against 8.6, whatever version the model names. This also works on a model that names no platform. The config's rules take the plugin's levels. `camunda-compat/` entries under `rules` change the level or turn a rule `off`, and the finding shows the rule name. Like the plugin's configs, it also turns on `start-event-required`. When your project's own bpmnlint runs the plugin, BPMN Kit's `compat` findings step aside. `camunda-platform-7-*` configs are not mapped. ### What is covered | Plugin rules | Coverage | |---|---| | `element-type`, `implementation`, `timer`, `called-element`, `message-reference`, `error-reference`, `escalation-reference`, `escalation-boundary-event-attached-to-ref`, `signal-reference`, `no-expression`, `event-based-gateway-target`, `inclusive-gateway`, `loop-characteristics`, `sequence-flow-condition`, `no-multiple-none-start-events`, `collapsed-subprocess`, `io-mapping`, `duplicate-task-headers`, `no-template` | Covered | | `no-zeebe-properties`, `no-candidate-users`, `no-propagate-all-parent-variables`, `no-task-schedule`, `task-schedule`, `no-signal-event-sub-process`, `start-event-form`, `start-event-form-embedded`, `user-task-form`, `user-task-definition`, `no-zeebe-user-task`, `zeebe-user-task`, `wait-for-completion` | Covered | | `no-binding-type`, `no-execution-listeners`, `execution-listener`, `duplicate-execution-listeners`, `no-priority-definition`, `priority-definition`, `no-version-tag`, `version-tag`, `ad-hoc-sub-process`, `no-interrupting-event-subprocess`, `no-task-listeners`, `task-listener` | Covered | | `no-business-id`, `no-execution-listener-headers`, `no-before-all-execution-listener`, `before-all-execution-listener`, `no-cancel-execution-listener`, `cancel-execution-listener`, `no-job-priority-definition` | Covered | | `subscription` | Covered. A `zeebe:subscription` on the catch element instead of on its `bpmn:message` is an error, as in the plugin. BPMN Kit's builders put it on the message. | | `feel-compatibility`, `variable-name`, `secrets`, `unresolvable-secret-reference`, `connector-properties`, `duplicate-execution-listener-headers`, `no-loop` | Covered, with the plugin's messages. FEEL is read by BPMN Kit's own parser, so an expression only one of the two parsers accepts can be judged differently. | | `agent-fromai-contract`, `agent-tool-output-key` | Covered, with the plugin's messages | | `link-event` | Covered, with the plugin's messages. Where bpmnlint's `link-event` also runs, its `flow/link-event-mismatch` finding stays instead. | | `executable-process`, `feel`, `agent-tool-documentation` | Reported by an existing finding: `deploy/process-not-executable` (which reports every non-executable process), `feel-syntax/parse-error`, `agentic/tool-no-description` | All 65 rules of the plugin's `camunda-cloud-*` configs are covered: 62 are reproduced, and 3 are reported by existing findings. Checked against the plugin: the test suite has four fixture diagrams that trigger all 62 reproduced rules. It compares BPMN Kit's findings with what the real plugin reported for them under every `camunda-cloud-*` config. The findings match element for element, and message for message where the table says so. The 25 process templates in `@bpmnkit/patterns` give the same result as the plugin too. ## From your own code The configuration and mapping are plain functions in `@bpmnkit/core`, with no filesystem access, so they work in a browser too: ```typescript import { Bpmn, lintDiagram, parseBpmnlintConfig, resolveBpmnlintConfig } from "@bpmnkit/core" const config = resolveBpmnlintConfig(parseBpmnlintConfig(rcText)) const report = lintDiagram(Bpmn.parse(xml), { bpmnlint: config }) report.diagnostics // governed findings carry `bpmnlintRule` report.bpmnlintUnsupported // what the config asked for that could not be applied ``` In Node, `@bpmnkit/core/node` finds the file and runs the project's bpmnlint: ```typescript import { applyBpmnlintConfig, Bpmn, optimize } from "@bpmnkit/core" import { prepareBpmnlint } from "@bpmnkit/core/node" const setup = await prepareBpmnlint(filePath, xml) // undefined when no .bpmnlintrc applies if (setup) { const defs = Bpmn.parse(xml) const { findings, unsupported } = applyBpmnlintConfig(defs, optimize(defs).findings, setup.config, { delegated: setup.delegated, // true when real bpmnlint ran; its reports are in setup.reports }) } ``` `BPMNLINT_RULE_MAP` exports the table above as data. --- ### BPMN Diagrams in Markdown Put real BPMN diagrams in READMEs, Astro, Docusaurus, VitePress and MDX pages — write a fenced ```bpmn or ```bpmn-compact block and @bpmnkit/markdown renders it to inline, themeable, accessible SVG at build time. Source: https://bpmnkit.com/docs/guides/bpmn-in-markdown Mermaid has no BPMN, and PlantUML's BPMN is a sketch. `@bpmnkit/markdown` fills the gap for docs-as-code: write a process as a fenced code block, and the build turns it into a real BPMN diagram — the same renderer, layout engine and shapes as the rest of BPMN Kit, drawn as inline SVG with no client-side JavaScript. This page uses it. The diagram below is this block, rendered when the site was built: ````md ```bpmn-compact title="Order fulfilment" { "id": "order-fulfilment", "elements": [ { "id": "received", "type": "startEvent", "name": "Order received" }, { "id": "check", "type": "serviceTask", "name": "Check stock", "jobType": "check-stock" }, { "id": "inStock", "type": "exclusiveGateway", "name": "In stock?" }, { "id": "ship", "type": "serviceTask", "name": "Ship order", "jobType": "ship-order" }, { "id": "shipped", "type": "endEvent", "name": "Shipped" }, { "id": "backorder", "type": "userTask", "name": "Back-order items" }, { "id": "waiting", "type": "endEvent", "name": "Back-ordered" } ], "flows": [ { "id": "f1", "from": "received", "to": "check" }, { "id": "f2", "from": "check", "to": "inStock" }, { "id": "f3", "from": "inStock", "to": "ship", "condition": "=available" }, { "id": "f4", "from": "inStock", "to": "backorder", "isDefault": true }, { "id": "f5", "from": "ship", "to": "shipped" }, { "id": "f6", "from": "backorder", "to": "waiting" } ] } ``` ```` ```bpmn-compact title="Order fulfilment" { "id": "order-fulfilment", "elements": [ { "id": "received", "type": "startEvent", "name": "Order received" }, { "id": "check", "type": "serviceTask", "name": "Check stock", "jobType": "check-stock" }, { "id": "inStock", "type": "exclusiveGateway", "name": "In stock?" }, { "id": "ship", "type": "serviceTask", "name": "Ship order", "jobType": "ship-order" }, { "id": "shipped", "type": "endEvent", "name": "Shipped" }, { "id": "backorder", "type": "userTask", "name": "Back-order items" }, { "id": "waiting", "type": "endEvent", "name": "Back-ordered" } ], "flows": [ { "id": "f1", "from": "received", "to": "check" }, { "id": "f2", "from": "check", "to": "inStock" }, { "id": "f3", "from": "inStock", "to": "ship", "condition": "=available" }, { "id": "f4", "from": "inStock", "to": "backorder", "isDefault": true }, { "id": "f5", "from": "ship", "to": "shipped" }, { "id": "f6", "from": "backorder", "to": "waiting" } ] } ``` ```bash npm install --save-dev @bpmnkit/markdown ``` ## What goes in a block | Fence | Contents | |---|---| | ` ```bpmn ` | BPMN 2.0 XML — a whole `.bpmn` file. If it carries diagram interchange (``), that layout is drawn as it is; if the DI is missing or incomplete, the diagram is laid out automatically. | | ` ```bpmn-compact ` | The [compact JSON format](/docs/getting-started/concepts) that `compactify()` produces and language models write. A single process can skip the `{ "id", "processes": [...] }` wrapper, as above. Always laid out automatically. | | ` ```bpmn-json ` | An alias of `bpmn-compact`. | There is no text DSL: the compact JSON already is the short form, and it is the same format the rest of BPMN Kit and your AI tooling speak. One fence attribute is read: `title="…"` sets the diagram's accessible name. Without it the name is the process's `name`, then the first pool's, then the process id. ## Astro ```js // astro.config.mjs import { remarkBpmn } from "@bpmnkit/markdown" import { defineConfig } from "astro/config" export default defineConfig({ markdown: { remarkPlugins: [[remarkBpmn, { maxWidth: 760 }]] }, }) ``` That covers `.md` content collections and, through `@astrojs/mdx`, `.mdx` pages too. This site is configured exactly this way. ## Docusaurus ```js // docusaurus.config.mjs import { remarkBpmn } from "@bpmnkit/markdown" export default { presets: [ ["classic", { docs: { remarkPlugins: [remarkBpmn] }, blog: { remarkPlugins: [remarkBpmn] } }], ], } ``` The package is ESM-only; with a CommonJS `docusaurus.config.js`, load it with `const { remarkBpmn } = await import("@bpmnkit/markdown")` inside an async config function. ## VitePress VitePress renders Markdown with markdown-it, so use the markdown-it plugin: ```ts // .vitepress/config.ts import { markdownItBpmn } from "@bpmnkit/markdown" import { defineConfig } from "vitepress" export default defineConfig({ markdown: { config: (md) => md.use(markdownItBpmn) }, }) ``` Every other fence is handed back to the rule that was there before, so VitePress's highlighting and code groups keep working. ## Next.js MDX, unified, anything remark ```js // next.config.mjs import createMDX from "@next/mdx" import { remarkBpmn } from "@bpmnkit/markdown" const withMDX = createMDX({ options: { remarkPlugins: [remarkBpmn] } }) export default withMDX({ pageExtensions: ["ts", "tsx", "md", "mdx"] }) ``` `remarkBpmn` is an ordinary remark plugin, so `unified().use(remarkParse).use(remarkBpmn)…` works the same way. It is a **remark** plugin rather than a rehype one on purpose. At the remark stage the block is still a Markdown `code` node with its language on it; by the rehype stage a syntax highlighter has usually turned it into a tree of coloured spans. And it hands the diagram on as hast elements, not as a raw HTML string, so MDX — which drops raw HTML — renders it too. ## Plain HTML pipelines If your tool produces HTML and offers no plugin hook, rewrite the HTML afterwards. `renderBpmnInHtml` replaces every `
` block:

```ts
import { renderBpmnInHtml } from "@bpmnkit/markdown"

const page = renderBpmnInHtml(markdownToHtml(source))
```

And for anything else there is the function every adapter wraps:

```ts
import { renderBpmnBlock } from "@bpmnkit/markdown"

const result = renderBpmnBlock(xml, "bpmn", { theme: "auto" })
if (result.ok) writeFileSync("process.svg", result.svg)
else console.error(result.error)
```

## GitHub READMEs

GitHub runs no plugins, so the diagram has to be committed as an image. `bpmnkit-md` does
that for you:

```bash
npx bpmnkit-md README.md
```

Each BPMN block becomes a marked region: an image of the diagram, and the block itself folded
into a `
` underneath, so the source stays in the README and stays the thing you edit. ````md ![Order fulfilment](diagrams/order-fulfilment.svg)
BPMN source ```bpmn-compact { … } ```
```` Run it again after editing the source and it re-renders the region in place — the command is idempotent, so it is safe in a pre-commit hook. In CI, `--check` writes nothing and exits 1 when a README or an SVG is out of date: ```bash npx bpmnkit-md --check README.md docs/*.md ``` | Option | Meaning | |---|---| | `--out-dir ` | Where new SVGs go, relative to each Markdown file. Default `diagrams`. A fence attribute `file=path/to.svg` picks one block's path. | | `--theme auto\|light\|dark` | Default `auto`: the SVG follows the reader's colour scheme, GitHub dark mode included. | | `--max-width ` | Largest width to draw a diagram at. | | `--check` | Verify only; exit 1 if anything would change. | `bpmnkit-md file.bpmn` writes `file.svg` next to a standalone diagram. Invalid blocks fail the command with the line number — a committed picture of an error box helps no one. ## Options The plugins, `renderBpmnInHtml` and `renderBpmnBlock` take the same options: | Option | Default | Meaning | |---|---|---| | `theme` | `"auto"` | `auto` uses the page's `--bpmnkit-*` tokens when it defines them, and the reader's `prefers-color-scheme` when it does not. `light` / `dark` pin the palette and ignore the page. | | `maxWidth` | natural size | Largest width in CSS pixels. The diagram still shrinks to fit narrower containers. | | `title` | process name | The accessible name. A fence's `title="…"` overrides it per block. | | `link` | off | `({ xml, title }) => href` — adds an "Open in BPMN Kit" link under the diagram, pointing wherever you host an editor. | | `onError` | `"render"` | `render` draws a readable error box in place of the diagram; `throw` fails the build. | ## Theming The SVG reads the same tokens as every other BPMN Kit surface — `--bpmnkit-bg` for the ground, `--bpmnkit-surface` for shape fills, `--bpmnkit-fg` for strokes and labels, `--bpmnkit-font` for type — each with a light and a dark fallback. A site that defines the tokens gets a diagram in its own palette; one that does not gets the BPMN Kit palette in the reader's colour scheme. The switch is CSS `light-dark()` under `color-scheme: light dark`, written into `style` attributes rather than a `