<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Marc Gille-Sepehri</title>
    <description>The latest articles on DEV Community by Marc Gille-Sepehri (@marcgillesepehri).</description>
    <link>https://dev.to/marcgillesepehri</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3873378%2F6d642848-2404-400f-894f-61c3a7f393e4.png</url>
      <title>DEV Community: Marc Gille-Sepehri</title>
      <link>https://dev.to/marcgillesepehri</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/marcgillesepehri"/>
    <language>en</language>
    <item>
      <title>BPMN as config: one vocabulary runs the model, the runtime, and the analytics</title>
      <dc:creator>Marc Gille-Sepehri</dc:creator>
      <pubDate>Thu, 23 Apr 2026 05:52:11 +0000</pubDate>
      <link>https://dev.to/marcgillesepehri/bpmn-as-config-one-vocabulary-runs-the-model-the-runtime-and-the-analytics-25gm</link>
      <guid>https://dev.to/marcgillesepehri/bpmn-as-config-one-vocabulary-runs-the-model-the-runtime-and-the-analytics-25gm</guid>
      <description>&lt;p&gt;BPMN 2.0 lets you put arbitrary attributes in your own namespace on any element. The established BPM runtimes — &lt;strong&gt;Camunda&lt;/strong&gt; and &lt;strong&gt;Flowable&lt;/strong&gt; — both preserve those attributes through parsing and make them accessible at runtime. That's table stakes for the spec; neither engine violates it.&lt;/p&gt;

&lt;p&gt;What differs is &lt;em&gt;how&lt;/em&gt; those attributes reach your code. In Camunda, a Java delegate pulls the current BPMN model instance, walks to the element in question, and reads the namespaced attribute through the generic model API — &lt;code&gt;execution.getBpmnModelElementInstance().getAttributeValueNs(ns, name)&lt;/code&gt;. Flowable's pattern is similar. Both work, both have a decade of production runtime behind them. Over time, though, the path of least resistance steers teams toward each engine's own extension schema — &lt;code&gt;camunda:inputOutput&lt;/code&gt;, &lt;code&gt;camunda:properties&lt;/code&gt;, &lt;code&gt;flowable:field&lt;/code&gt; — because the platform does more lifting when you author in its vocabulary. Gradually the &lt;em&gt;engine's&lt;/em&gt; vocabulary starts winning over the &lt;em&gt;business's&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;in-concert&lt;/strong&gt; — the BPMN 2.0 engine I work on — flattens that ergonomic gradient. There is no engine-defined extension schema for service tasks, user tasks, sequence flows, or gateways. What the engine does instead: hand your callback every attribute you authored, as a plain JavaScript bag in the payload.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;extensions&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;acme:costCenter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="nx"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;acme:condition1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No model traversal. No namespace-aware API calls. No translation step. Whether you use our bundled &lt;code&gt;tri:*&lt;/code&gt; prefix (which the built-in triggers happen to ship with) or your own &lt;code&gt;acme:*&lt;/code&gt; is invisible to the engine — the authoring surface is intentionally unbiased, so your vocabulary wins by default.&lt;/p&gt;

&lt;p&gt;The rest of this article is what that means in practice — from bpmn.io, through four extension points at runtime, to the analytics payoff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — Author it in the modeler
&lt;/h2&gt;

&lt;p&gt;Pick any BPMN editor. bpmn.io is free and runs in the browser; Camunda Modeler is an Electron app; you can hand-edit XML if you prefer. All three save extension attributes the same way. Here's a fragment of a loan-approval process with four extension points, each carrying &lt;code&gt;acme:*&lt;/code&gt; attributes my fictional company authored for its own runtime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;bpmn:definitions&lt;/span&gt;
  &lt;span class="na"&gt;xmlns:bpmn=&lt;/span&gt;&lt;span class="s"&gt;"http://www.omg.org/spec/BPMN/20100524/MODEL"&lt;/span&gt;
  &lt;span class="na"&gt;xmlns:acme=&lt;/span&gt;&lt;span class="s"&gt;"http://acme.example.com/schema/bpmn"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- 1. Trigger: start the process when an application arrives --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;bpmn:message&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"Msg_Application"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"loan-application"&lt;/span&gt;
    &lt;span class="na"&gt;acme:connectorType=&lt;/span&gt;&lt;span class="s"&gt;"acme-application-inbox"&lt;/span&gt;
    &lt;span class="na"&gt;acme:source=&lt;/span&gt;&lt;span class="s"&gt;"customer-portal"&lt;/span&gt;
    &lt;span class="na"&gt;acme:minAmount=&lt;/span&gt;&lt;span class="s"&gt;"1000"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;bpmn:process&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"LoanApproval"&lt;/span&gt; &lt;span class="na"&gt;isExecutable=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;bpmn:startEvent&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"Start"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;bpmn:messageEventDefinition&lt;/span&gt; &lt;span class="na"&gt;messageRef=&lt;/span&gt;&lt;span class="s"&gt;"Msg_Application"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/bpmn:startEvent&amp;gt;&lt;/span&gt;

    &lt;span class="c"&gt;&amp;lt;!-- 2. Service task: call out to a credit scoring tool --&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;bpmn:serviceTask&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"Task_Score"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"Run credit score"&lt;/span&gt;
      &lt;span class="na"&gt;acme:toolId=&lt;/span&gt;&lt;span class="s"&gt;"credit-score-v2"&lt;/span&gt;
      &lt;span class="na"&gt;acme:timeoutSeconds=&lt;/span&gt;&lt;span class="s"&gt;"5"&lt;/span&gt;
      &lt;span class="na"&gt;acme:retryStrategy=&lt;/span&gt;&lt;span class="s"&gt;"exponential"&lt;/span&gt;
      &lt;span class="na"&gt;acme:costCenter=&lt;/span&gt;&lt;span class="s"&gt;"risk-ops"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

    &lt;span class="c"&gt;&amp;lt;!-- 3. User task: have an underwriter review --&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;bpmn:userTask&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"Task_Review"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"Underwriter review"&lt;/span&gt;
      &lt;span class="na"&gt;acme:roleId=&lt;/span&gt;&lt;span class="s"&gt;"senior-underwriter"&lt;/span&gt;
      &lt;span class="na"&gt;acme:formSchema=&lt;/span&gt;&lt;span class="s"&gt;"loan-review-v3"&lt;/span&gt;
      &lt;span class="na"&gt;acme:slaMinutes=&lt;/span&gt;&lt;span class="s"&gt;"240"&lt;/span&gt;
      &lt;span class="na"&gt;acme:escalationChannel=&lt;/span&gt;&lt;span class="s"&gt;"slack:#underwriting-escalation"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

    &lt;span class="c"&gt;&amp;lt;!-- 4. Transition conditions with authored rule metadata --&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;bpmn:exclusiveGateway&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"Gw_Decide"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"Approve?"&lt;/span&gt; &lt;span class="na"&gt;default=&lt;/span&gt;&lt;span class="s"&gt;"Flow_Reject"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;bpmn:sequenceFlow&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"Flow_Approve"&lt;/span&gt; &lt;span class="na"&gt;sourceRef=&lt;/span&gt;&lt;span class="s"&gt;"Gw_Decide"&lt;/span&gt; &lt;span class="na"&gt;targetRef=&lt;/span&gt;&lt;span class="s"&gt;"End_Approved"&lt;/span&gt;
      &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"Approve"&lt;/span&gt;
      &lt;span class="na"&gt;acme:condition1=&lt;/span&gt;&lt;span class="s"&gt;"score_above_threshold"&lt;/span&gt;
      &lt;span class="na"&gt;acme:condition2=&lt;/span&gt;&lt;span class="s"&gt;"amount_within_limits"&lt;/span&gt;
      &lt;span class="na"&gt;acme:explanation=&lt;/span&gt;&lt;span class="s"&gt;"Auto-approve when both the credit score and the loan amount are in band."&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;bpmn:sequenceFlow&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"Flow_Reject"&lt;/span&gt; &lt;span class="na"&gt;sourceRef=&lt;/span&gt;&lt;span class="s"&gt;"Gw_Decide"&lt;/span&gt; &lt;span class="na"&gt;targetRef=&lt;/span&gt;&lt;span class="s"&gt;"End_Rejected"&lt;/span&gt;
      &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"Reject"&lt;/span&gt;
      &lt;span class="na"&gt;acme:explanation=&lt;/span&gt;&lt;span class="s"&gt;"Everything else routes to manual review."&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

    &lt;span class="c"&gt;&amp;lt;!-- …endEvents, the other flows… --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/bpmn:process&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/bpmn:definitions&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four extension points, each of which would normally force you to either contort into the engine's vocabulary or split your logic into an external config. Here, each attribute is just part of the BPMN — one file, one change, one commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — The engine parses &lt;em&gt;everything&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;Deploy the model. The parser picks up every non-reserved &lt;code&gt;&amp;lt;prefix&amp;gt;:&amp;lt;name&amp;gt;&lt;/code&gt; attribute (reserved: &lt;code&gt;bpmn&lt;/code&gt;, &lt;code&gt;bpmndi&lt;/code&gt;, &lt;code&gt;dc&lt;/code&gt;, &lt;code&gt;di&lt;/code&gt;, &lt;code&gt;xsi&lt;/code&gt;, &lt;code&gt;xml&lt;/code&gt;, &lt;code&gt;xmlns&lt;/code&gt;) and carries the bags verbatim into the internal graph model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;bpmn:message&amp;gt;&lt;/code&gt; extension attributes → &lt;code&gt;node.messageAttrs&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;bpmn:startEvent&amp;gt;&lt;/code&gt; extension attributes → &lt;code&gt;node.selfAttrs&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;bpmn:serviceTask&amp;gt;&lt;/code&gt; / &lt;code&gt;&amp;lt;bpmn:userTask&amp;gt;&lt;/code&gt; extension attributes → &lt;code&gt;node.extensions&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;bpmn:sequenceFlow&amp;gt;&lt;/code&gt; extension attributes → &lt;code&gt;flow.selfAttrs&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The engine &lt;strong&gt;does not&lt;/strong&gt; look inside these bags. It doesn't check &lt;code&gt;acme:toolId&lt;/code&gt;. It doesn't parse &lt;code&gt;acme:condition1&lt;/code&gt;. It doesn't know what &lt;code&gt;escalationChannel&lt;/code&gt; means. These are opaque strings that the engine is legally obligated to carry from XML to your handlers, and nothing more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — Your handlers consume them
&lt;/h2&gt;

&lt;p&gt;At runtime, each extension point has a callback your handler registers for at engine init. When execution reaches that point, the engine fires the callback with a payload that includes the raw attribute bag. Your handler — written in TypeScript, in your project — reads whatever attributes it authored and does whatever it wants.&lt;/p&gt;

&lt;h3&gt;
  
  
  Event trigger
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;acme-application-inbox&lt;/code&gt; is a custom trigger plugin your team wrote. It's a &lt;code&gt;StartTrigger&lt;/code&gt; implementation, roughly 100 lines. When the engine sees the message start event at deploy time, it iterates registered triggers and the plugin claims it based on &lt;code&gt;acme:connectorType&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AcmeApplicationInboxTrigger&lt;/span&gt; &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;StartTrigger&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;triggerType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;acme-application-inbox&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nf"&gt;claimFromBpmn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messageAttrs&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;acme:connectorType&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;acme-application-inbox&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messageAttrs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;acme:source&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;minAmount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messageAttrs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;acme:minAmount&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;fire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invocation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newApplications&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;acmeInbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;poll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invocation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;definition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;starts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;newApplications&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;invocation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;definition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minAmount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;dedupKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;application&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})),&lt;/span&gt;
      &lt;span class="na"&gt;nextCursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;invocation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The engine doesn't know &lt;code&gt;acme-application-inbox&lt;/code&gt; exists until you register it. The attributes that parameterize it (&lt;code&gt;source&lt;/code&gt;, &lt;code&gt;minAmount&lt;/code&gt;) live on the BPMN — operators can change them in bpmn.io without touching code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Service task tool invocation
&lt;/h3&gt;

&lt;p&gt;When execution reaches &lt;code&gt;Task_Score&lt;/code&gt;, the engine fires &lt;code&gt;onServiceCall&lt;/code&gt; with a payload that includes the task's &lt;code&gt;extensions&lt;/code&gt; bag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;onServiceCall&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;extensions&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toolId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;acme:toolId&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timeoutSeconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;acme:timeoutSeconds&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;retryStrategy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;acme:retryStrategy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;acmeToolRuntime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;toolId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;timeoutMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;timeoutSeconds&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;retryStrategy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;readInstanceState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;completeExternalTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workItemId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The engine knows nothing about tool invocation. Your tool runtime — in whatever shape it takes, MCP, a gRPC service, a Python subprocess, Anthropic's tool-use API — consumes the attributes and does the work.&lt;/p&gt;

&lt;h3&gt;
  
  
  User task / communication
&lt;/h3&gt;

&lt;p&gt;Same mechanism, different handler. When the process reaches &lt;code&gt;Task_Review&lt;/code&gt;, &lt;code&gt;onWorkItem&lt;/code&gt; fires:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;onWorkItem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;extensions&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;acme:formSchema&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;slaMinutes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;acme:slaMinutes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;1440&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;escalation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;acme:escalationChannel&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;worklist&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;workItemId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workItemId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;formSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;dueAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;slaMinutes&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;escalateTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;escalation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The worklist UI renders &lt;code&gt;acme:formSchema="loan-review-v3"&lt;/code&gt; however it wants. The escalation job wakes up at the SLA deadline and pings &lt;code&gt;slack:#underwriting-escalation&lt;/code&gt;. Both services read the same BPMN attributes — no shadow config file.&lt;/p&gt;

&lt;h3&gt;
  
  
  Transition conditions
&lt;/h3&gt;

&lt;p&gt;When execution reaches &lt;code&gt;Gw_Decide&lt;/code&gt;, &lt;code&gt;onDecision&lt;/code&gt; fires with a &lt;code&gt;transitions[]&lt;/code&gt; array. Each transition carries its source flow's &lt;code&gt;attrs&lt;/code&gt; bag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;onDecision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;transitions&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;readInstanceState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;transitions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isDefault&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rule1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;acme:condition1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rule2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attrs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;acme:condition2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;evaluateRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rule1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;evaluateRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rule2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submitDecision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;decisionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;selectedFlowIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;flowId&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The engine never attempted to evaluate &lt;code&gt;score_above_threshold&lt;/code&gt;. It just handed you the string. Your rule engine (or LLM, or embedded DSL) does the actual work. The rule names are authored in bpmn.io by whoever models the process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — Analytics: the same attributes, for free
&lt;/h2&gt;

&lt;p&gt;Here's the part that earns its keep in production.&lt;/p&gt;

&lt;p&gt;Every attribute you author on the BPMN — &lt;code&gt;acme:costCenter&lt;/code&gt;, &lt;code&gt;acme:customerTier&lt;/code&gt;, &lt;code&gt;acme:experimentId&lt;/code&gt;, &lt;code&gt;acme:dataClassification&lt;/code&gt;, &lt;code&gt;acme:regulatoryRegime&lt;/code&gt; — lands in your system &lt;strong&gt;in the same shape the analytics layer already expects&lt;/strong&gt;, because &lt;em&gt;you&lt;/em&gt; chose the shape. There's no intermediate translation.&lt;/p&gt;

&lt;p&gt;A few concrete implications:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost allocation.&lt;/strong&gt; Your finance team already allocates spend by &lt;code&gt;costCenter&lt;/code&gt;. Put &lt;code&gt;acme:costCenter="risk-ops"&lt;/code&gt; on every service task that calls a paid API, and your process telemetry joins to your chargeback reports without anyone writing a mapping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Customer-tier SLA reports.&lt;/strong&gt; &lt;code&gt;acme:customerTier&lt;/code&gt; on a user task, and your SLA dashboard filters by tier natively.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A/B experimentation.&lt;/strong&gt; &lt;code&gt;acme:experimentId&lt;/code&gt; on the flow your experiment toggles; your experiments platform already aggregates by that id.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compliance audits.&lt;/strong&gt; &lt;code&gt;acme:dataClassification="PII"&lt;/code&gt; on the tasks that read personal data; GDPR audit queries filter on that flag directly, and the same flag is visible to reviewers opening the BPMN in bpmn.io.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ML training data.&lt;/strong&gt; Every &lt;code&gt;onDecision&lt;/code&gt; event emits a row with &lt;code&gt;flowId&lt;/code&gt;, &lt;code&gt;transitions[].attrs&lt;/code&gt;, and the state the handler saw. Your ML team gets a ready-made training set for the rule engine because the labels were authored in the model.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this requires a separate "process metadata" service or a parallel tagging system. The BPMN &lt;em&gt;is&lt;/em&gt; the config, and the config is whatever vocabulary your business already speaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;Camunda and Flowable are mature, battle-tested engines with enormous BPMN coverage and deep Java integration. in-concert doesn't try to compete on that surface area. What it &lt;em&gt;does&lt;/em&gt; optimize for is a narrower, increasingly relevant shape: &lt;strong&gt;your process model should carry your business vocabulary, and that vocabulary should reach your runtime handlers without an API call&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The concrete payoff of transparent extension attributes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One source of truth.&lt;/strong&gt; Your process logic, tool wiring, form schemas, escalation rules, cost centers, compliance flags — all in the BPMN, all authored in a modeler, all versioned alongside the graph. Not 40% in BPMN and 60% in handler code and YAML.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vocabulary parity across systems.&lt;/strong&gt; &lt;code&gt;acme:customerTier&lt;/code&gt; in the BPMN is the same &lt;code&gt;customerTier&lt;/code&gt; your analytics warehouse already uses. No translation layer between the model and reporting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Editor independence.&lt;/strong&gt; Every BPMN editor that round-trips extension attributes (bpmn.io, Camunda Modeler, Signavio, hand-edited XML) works — nothing in-concert-specific needs to be installed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LLM-friendly.&lt;/strong&gt; When your conditions are prompts, your tool calls are MCP-style invocations, and your routing decisions are LLM-evaluated — all of which in-concert supports natively — keeping every attribute in a plain JSON bag at handler time is the right shape for the AI/agentic workloads the engine is positioned for.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The engine's job is to carry BPMN semantics — sequencing, tokens, gateways, events — and get out of the way of the attributes. What happens at each task, at each trigger, at each gateway is yours to author in the model and yours to implement in code. The bridge between the two is the attribute bag.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;in-concert is open-source (modified MIT with attribution) and on npm as &lt;a href="https://www.npmjs.com/package/@the-real-insight/in-concert" rel="noopener noreferrer"&gt;&lt;code&gt;@the-real-insight/in-concert&lt;/code&gt;&lt;/a&gt;. The trigger-plugin guide is at &lt;a href="https://github.com/The-Real-Insight/in-concert/blob/main/docs/sdk/custom-triggers.md" rel="noopener noreferrer"&gt;&lt;code&gt;docs/sdk/custom-triggers.md&lt;/code&gt;&lt;/a&gt;; the decision-callback reference with the &lt;code&gt;transition.attrs&lt;/code&gt; walkthrough is in &lt;a href="https://github.com/The-Real-Insight/in-concert/blob/main/docs/sdk/usage.md#decision-callback-payload-llm-friendly" rel="noopener noreferrer"&gt;&lt;code&gt;docs/sdk/usage.md&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you've been splitting process logic across BPMN and config files because your engine wouldn't carry your attributes, swap engines or swap strategies — but don't keep doing it. The model should win.&lt;/p&gt;

</description>
      <category>bpmn</category>
      <category>workflow</category>
      <category>architecture</category>
      <category>ai</category>
    </item>
    <item>
      <title>Generalized BPMN triggers: watch files, inboxes, or let an LLM decide when to start</title>
      <dc:creator>Marc Gille-Sepehri</dc:creator>
      <pubDate>Tue, 21 Apr 2026 18:09:52 +0000</pubDate>
      <link>https://dev.to/marcgillesepehri/generalized-bpmn-triggers-watch-files-inboxes-or-let-an-llm-decide-when-to-start-3ogh</link>
      <guid>https://dev.to/marcgillesepehri/generalized-bpmn-triggers-watch-files-inboxes-or-let-an-llm-decide-when-to-start-3ogh</guid>
      <description>&lt;p&gt;BPMN processes traditionally start via API calls. But real events happen in the world — files appear, mailboxes fill, thresholds cross. A generalized trigger mechanism makes 'how my process starts' part of the BPMN itself, not glue code around it.&lt;/p&gt;

&lt;p&gt;Most process engines ask you to tell them when to start. &lt;em&gt;"Dear engine, please spin up a new instance of this workflow now."&lt;/em&gt; That's fine when the event originates in your code — a user clicks a button, a webhook fires, a batch job runs. It's backwards when the event originates in the world.&lt;/p&gt;

&lt;p&gt;Someone drops a PDF into a SharePoint folder. An email arrives. A weather sensor reports conditions that &lt;em&gt;might&lt;/em&gt; matter. A stock price &lt;em&gt;might&lt;/em&gt; have moved far enough to care. In each of these cases, the process engine should be the one paying attention — and starting instances on your behalf — rather than forcing you to write a separate watcher service that calls &lt;code&gt;startInstance()&lt;/code&gt; once it notices.&lt;/p&gt;

&lt;p&gt;That's what &lt;strong&gt;generalized start triggers&lt;/strong&gt; give you. In &lt;a href="https://github.com/The-Real-Insight/in-concert" rel="noopener noreferrer"&gt;&lt;code&gt;in-concert&lt;/code&gt;&lt;/a&gt; — the BPMN 2.0 engine I work on — every way a process can start is an instance of the same plugin interface. Timers, Microsoft 365 mailboxes, SharePoint folders, and (new in the latest release) AI-listener agents are all first-party triggers. The engine handles polling, exactly-once instance creation, crash recovery, pause/resume, and credentials. You write BPMN.&lt;/p&gt;

&lt;p&gt;Let me walk through two of the more interesting cases from a user's perspective — file-monitoring and AI-driven evaluation — and wrap up with a note on the abstraction underneath.&lt;/p&gt;

&lt;h2&gt;
  
  
  The concept in one paragraph
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;trigger&lt;/strong&gt; is a named thing (&lt;code&gt;timer&lt;/code&gt;, &lt;code&gt;graph-mailbox&lt;/code&gt;, &lt;code&gt;sharepoint-folder&lt;/code&gt;, &lt;code&gt;ai-listener&lt;/code&gt;, or one you write) that can produce start requests. A BPMN author references a trigger by putting &lt;code&gt;tri:connectorType="..."&lt;/code&gt; on a &lt;code&gt;&amp;lt;bpmn:message&amp;gt;&lt;/code&gt; element together with a handful of trigger-specific attributes. At deploy time the engine persists a &lt;em&gt;trigger schedule&lt;/em&gt; — a row that remembers what to watch, how often, whether it's paused, and any credentials. A generic scheduler polls active schedules, calls the matching plugin, and creates process instances for whatever the plugin returns. From your side, that's it. No code writing polling loops, no cron jobs, no webhook receivers to deploy and monitor.&lt;/p&gt;

&lt;p&gt;Four properties fall out of this design:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Declared in BPMN.&lt;/strong&gt; Adding or changing an event source doesn't touch your application code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exactly-once.&lt;/strong&gt; Every generated start carries a dedup key, enforced by a unique index. Crashes, retries, and overlapping polls collapse to a single process instance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pausable and resumable.&lt;/strong&gt; Turn a trigger off for maintenance; turn it back on when you're ready. No redeploy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credentials per schedule.&lt;/strong&gt; Different mailboxes, different tenants, different API keys — stored on the schedule row, not hard-coded.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Watching a folder: the SharePoint trigger
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Scenario.&lt;/strong&gt; Your ops team drops order PDFs into &lt;code&gt;/Incoming/Orders&lt;/code&gt; on a SharePoint site. Every file should kick off your existing order-processing workflow. The file's metadata (name, size, path, webUrl) becomes the initial variables for the process instance.&lt;/p&gt;

&lt;p&gt;Before generalized triggers, some team built and operated one of: a webhook receiver with Azure AD plumbing, a cron job with a "last-seen" state file, a Microsoft Graph change-notification subscription with a public HTTPS endpoint, or — most commonly — a human who checks the folder once a morning. Somebody wrote it, somebody keeps it running, and somebody handles the part where the SharePoint delta token silently expires after 30 days of quiet.&lt;/p&gt;

&lt;p&gt;Here's what replaces all of that in the BPMN:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;bpmn:message&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"Msg_NewOrder"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"incoming-orders"&lt;/span&gt;
  &lt;span class="na"&gt;tri:connectorType=&lt;/span&gt;&lt;span class="s"&gt;"sharepoint-folder"&lt;/span&gt;
  &lt;span class="na"&gt;tri:siteUrl=&lt;/span&gt;&lt;span class="s"&gt;"https://contoso.sharepoint.com/sites/Operations"&lt;/span&gt;
  &lt;span class="na"&gt;tri:driveName=&lt;/span&gt;&lt;span class="s"&gt;"Documents"&lt;/span&gt;
  &lt;span class="na"&gt;tri:folderPath=&lt;/span&gt;&lt;span class="s"&gt;"/Incoming/Orders"&lt;/span&gt;
  &lt;span class="na"&gt;tri:fileNamePattern=&lt;/span&gt;&lt;span class="s"&gt;"*.pdf"&lt;/span&gt;
  &lt;span class="na"&gt;tri:pollIntervalSeconds=&lt;/span&gt;&lt;span class="s"&gt;"60"&lt;/span&gt;
  &lt;span class="na"&gt;tri:initialPolicy=&lt;/span&gt;&lt;span class="s"&gt;"skip-existing"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;bpmn:startEvent&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"Start"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"Order arrived"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;bpmn:messageEventDefinition&lt;/span&gt; &lt;span class="na"&gt;messageRef=&lt;/span&gt;&lt;span class="s"&gt;"Msg_NewOrder"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/bpmn:startEvent&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy the process, set your Azure AD app credentials against the generated schedule, and resume it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listTriggerSchedules&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;triggerType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sharepoint-folder&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTriggerCredentials&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clientSecret&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resumeTriggerSchedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Done. From now on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every matching file that arrives in the folder starts a process instance with the file's metadata as variables.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;initialPolicy="skip-existing"&lt;/code&gt; means the engine doesn't flood you with a thousand instances for files that were already there when you deployed. The first poll silently records the current state; only &lt;em&gt;new&lt;/em&gt; arrivals fire.&lt;/li&gt;
&lt;li&gt;Duplicates from retries, crashes, or overlapping polls collapse to one instance per &lt;code&gt;(itemId, eTag)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If the delta token expires, the trigger transparently resets and keeps going.&lt;/li&gt;
&lt;li&gt;Need to pause for maintenance? &lt;code&gt;client.pauseTriggerSchedule(id)&lt;/code&gt;. Resume when ready.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The process designer never sees a polling loop or a state file. The person reviewing the BPMN sees one &lt;code&gt;&amp;lt;bpmn:message&amp;gt;&lt;/code&gt; element with a handful of attributes that say exactly what's being watched and how.&lt;/p&gt;

&lt;h2&gt;
  
  
  Letting an LLM decide: the AI-listener trigger
&lt;/h2&gt;

&lt;p&gt;This one is newer — and harder to do any other way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scenario.&lt;/strong&gt; You run an escalation workflow that wakes up the duty officer when severe weather threatens an outdoor event. "Severe" has a definition that evolved over the years: some combination of precipitation, wind, lightning risk, and whether the event is happening on a field, in a stadium, or on a lake. Your current threshold rules are pages long and still miss edge cases.&lt;/p&gt;

&lt;p&gt;You'd love to write: &lt;em&gt;"Given the current observation, should we wake the duty officer?"&lt;/em&gt; And have something qualified answer that question.&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;ai-listener&lt;/code&gt; trigger does precisely that. It polls an MCP-style tool endpoint (any HTTP endpoint that returns JSON), passes the result to an LLM together with a prompt &lt;strong&gt;authored in the BPMN itself&lt;/strong&gt;, and starts a process instance when the LLM answers &lt;em&gt;"yes"&lt;/em&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;bpmn:message&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"Msg_SevereWeather"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"severe-weather-detected"&lt;/span&gt;
  &lt;span class="na"&gt;tri:connectorType=&lt;/span&gt;&lt;span class="s"&gt;"ai-listener"&lt;/span&gt;
  &lt;span class="na"&gt;tri:toolEndpoint=&lt;/span&gt;&lt;span class="s"&gt;"https://weather.example.com/tools/call"&lt;/span&gt;
  &lt;span class="na"&gt;tri:tool=&lt;/span&gt;&lt;span class="s"&gt;"get_weather"&lt;/span&gt;
  &lt;span class="na"&gt;tri:llmEndpoint=&lt;/span&gt;&lt;span class="s"&gt;"https://llm.example.com/evaluate"&lt;/span&gt;
  &lt;span class="na"&gt;tri:prompt=&lt;/span&gt;&lt;span class="s"&gt;"Given this observation for the upcoming regatta at Lake Zurich, should we wake the duty officer? Consider wind above 30 km/h, lightning within 50 km, and heavy rain forecast for the event window. Answer strictly yes or no."&lt;/span&gt;
  &lt;span class="na"&gt;tri:pollIntervalSeconds=&lt;/span&gt;&lt;span class="s"&gt;"300"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;bpmn:startEvent&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"Start"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"Severe weather"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;bpmn:messageEventDefinition&lt;/span&gt; &lt;span class="na"&gt;messageRef=&lt;/span&gt;&lt;span class="s"&gt;"Msg_SevereWeather"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/bpmn:startEvent&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every five minutes the engine:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Calls the weather tool endpoint to fetch current observations.&lt;/li&gt;
&lt;li&gt;Feeds them to the LLM endpoint together with the prompt.&lt;/li&gt;
&lt;li&gt;Parses the response for a strict yes/no.&lt;/li&gt;
&lt;li&gt;On &lt;em&gt;yes&lt;/em&gt;, starts the escalation process. On &lt;em&gt;no&lt;/em&gt; or ambiguous output, does nothing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The escalation process itself is unchanged BPMN — service tasks, user tasks, gateways, the lot. What changed is that it no longer needs an external watchdog service, a threshold spreadsheet, or a person staring at a dashboard.&lt;/p&gt;

&lt;h3&gt;
  
  
  The business rule lives in the prompt
&lt;/h3&gt;

&lt;p&gt;This is the part that feels genuinely new. Your "when to escalate" policy is right there in the BPMN, in English, next to the process that implements it. Want to tighten the rule? Edit the prompt. Want to add "ignore conditions during the lunch break"? Edit the prompt. Want a different operator to take a different view? They edit their copy of the prompt.&lt;/p&gt;

&lt;p&gt;You get domain experts who can't write TypeScript writing the actual rules of the business.&lt;/p&gt;

&lt;h3&gt;
  
  
  Exactly-once across many polls
&lt;/h3&gt;

&lt;p&gt;The interesting subtlety: if the weather has been bad for an hour, you don't want &lt;em&gt;twelve&lt;/em&gt; escalation instances (one per five-minute poll). You want one.&lt;/p&gt;

&lt;p&gt;The trigger's dedup key comes from one of two places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;From the LLM itself&lt;/strong&gt; — the response can include a &lt;code&gt;correlationId&lt;/code&gt; that names the ongoing event. &lt;code&gt;"lake-zurich-storm-2026-04-21"&lt;/code&gt; stays the same across many polls; all those "yes" answers collapse to one process instance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;From the tool output&lt;/strong&gt; — if the LLM doesn't supply a correlation id, the plugin hashes the tool result. Identical observations → same hash → same instance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Either way: retries, crashes, overlapping polls, and routine "yes, still yes" cycles all produce one instance per &lt;em&gt;event&lt;/em&gt;, not one per &lt;em&gt;observation&lt;/em&gt;. Your handlers don't have to worry about whether they're already running for this storm.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bring your own LLM
&lt;/h3&gt;

&lt;p&gt;The default flow is plain HTTP — any MCP-compatible tool server, any LLM with a &lt;code&gt;POST { prompt, context } → { answer }&lt;/code&gt; endpoint. That keeps the plugin dependency-free and works with whatever inference stack you're already running.&lt;/p&gt;

&lt;p&gt;If you'd rather call the Anthropic or OpenAI SDK directly (for retries, structured output, prompt caching), inject a function and skip HTTP entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;plugin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getDefaultTriggerRegistry&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ai-listener&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;AIListenerTrigger&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;plugin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setEvaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;completion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;claude-opus-4-7&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Answer strictly with yes or no. No other words.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n\nData: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;completion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;parseEvaluation&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that, &lt;code&gt;tri:llmEndpoint&lt;/code&gt; becomes optional metadata and the plugin calls your function for every evaluation. Same idea for &lt;code&gt;setCallTool&lt;/code&gt; if you'd rather use an MCP client SDK for the tool half.&lt;/p&gt;

&lt;h2&gt;
  
  
  One interface, any source
&lt;/h2&gt;

&lt;p&gt;The real win isn't any one trigger. It's that &lt;em&gt;how your process starts&lt;/em&gt; becomes part of the BPMN, not a separate service.&lt;/p&gt;

&lt;p&gt;Underneath, every trigger — timer, mailbox, SharePoint, AI-listener — implements the same five-method interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;StartTrigger&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;triggerType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;defaultInitialPolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fire-existing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;skip-existing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;nextSchedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lastFiredAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;TriggerSchedule&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;fire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invocation&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;TriggerResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An S3 bucket watcher, an SQS queue poller, a webhook receiver, a filesystem watcher — any of these is about 100 lines of code against that interface, registered once at engine init. The engine handles scheduling, exactly-once, crash recovery, pause/resume, and credentials on your behalf.&lt;/p&gt;

&lt;p&gt;The effect on your codebase is that the boundary between &lt;em&gt;the world&lt;/em&gt; and &lt;em&gt;your processes&lt;/em&gt; becomes a BPMN concern rather than a glue-code concern. New event source? Swap one attribute on one message element. Remove it again? Swap it off. The process graph stays the graph; the triggers are plug-ins around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The engine is open-source (modified MIT with attribution) and on npm as &lt;a href="https://www.npmjs.com/package/@the-real-insight/in-concert" rel="noopener noreferrer"&gt;&lt;code&gt;@the-real-insight/in-concert&lt;/code&gt;&lt;/a&gt;. Source, docs, and more triggers at &lt;a href="https://github.com/The-Real-Insight/in-concert" rel="noopener noreferrer"&gt;github.com/The-Real-Insight/in-concert&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you write a trigger for something interesting — an IoT feed, a database change stream, a message bus — I'd love to hear about it. The interface is deliberately small; the space of useful triggers is not.&lt;/p&gt;

</description>
      <category>agenticbpm</category>
      <category>bpmn</category>
      <category>mcp</category>
      <category>workflow</category>
    </item>
    <item>
      <title>Self-Starting Processes: Timer Events, Mailbox Polling, and Why Your Agentic Workflows Should Launch Themselves</title>
      <dc:creator>Marc Gille-Sepehri</dc:creator>
      <pubDate>Thu, 16 Apr 2026 21:55:48 +0000</pubDate>
      <link>https://dev.to/marcgillesepehri/self-starting-processes-timer-events-mailbox-polling-and-why-your-agentic-workflows-should-387a</link>
      <guid>https://dev.to/marcgillesepehri/self-starting-processes-timer-events-mailbox-polling-and-why-your-agentic-workflows-should-387a</guid>
      <description>&lt;p&gt;&lt;em&gt;Follow-up to &lt;a href="https://dev.to/marcgillesepehri/why-we-keep-process-data-outside-the-engine-and-why-it-changes-everything-for-agentic-bpm-27p4"&gt;Why We Keep Process Data Outside the Engine&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;In the first article, we made the case for keeping process data outside the engine. The &lt;code&gt;instanceId&lt;/code&gt; is the only binding key. Your code handles the intelligence. The engine handles the orchestration.&lt;/p&gt;

&lt;p&gt;But there was a gap in that story. Someone still had to start the process.&lt;/p&gt;

&lt;p&gt;A REST call. A button click. A cron job in a separate service calling &lt;code&gt;startInstance()&lt;/code&gt;. The engine could orchestrate anything — once a human or an external trigger told it to begin. The process itself had no agency over its own lifecycle.&lt;/p&gt;

&lt;p&gt;That changes now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Processes That Start Themselves
&lt;/h2&gt;

&lt;p&gt;in-concert now supports &lt;strong&gt;timer start events&lt;/strong&gt; and &lt;strong&gt;message start events&lt;/strong&gt; — standard BPMN elements that let a process definition declare when and why it should launch, without any external trigger.&lt;/p&gt;

&lt;p&gt;A timer start event says: &lt;em&gt;run this process every hour&lt;/em&gt;. Or every weekday at 8:30. Or on the last Friday of every month. Or three times at 10-minute intervals, then stop.&lt;/p&gt;

&lt;p&gt;A message start event says: &lt;em&gt;run this process when an email arrives in this mailbox&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Deploy the BPMN. The engine takes it from there. No Lambda functions. No external scheduler. No webhook plumbing. The process definition is self-sufficient.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timer Start Events — Every Flavour of "When"
&lt;/h2&gt;

&lt;p&gt;Put a timer on a start event and the engine creates a persistent schedule. A background worker fires it, starts an instance, advances the schedule, and goes back to sleep. If the server restarts, the schedule is in MongoDB — nothing is lost.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;bpmn:startEvent&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"TimerStart"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"Daily compliance check"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;bpmn:timerEventDefinition&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;bpmn:timeCycle&amp;gt;&lt;/span&gt;R/P1D&lt;span class="nt"&gt;&amp;lt;/bpmn:timeCycle&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/bpmn:timerEventDefinition&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/bpmn:startEvent&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a process that runs once a day, forever, until you pause it. The engine supports five expression formats:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ISO 8601 repeating interval&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;R/PT1H&lt;/code&gt;, &lt;code&gt;R3/PT10M&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Every hour (unbounded), or 3 times at 10-min intervals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ISO 8601 duration&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PT30M&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Once, 30 minutes after deploy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ISO 8601 date-time&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2026-12-25T00:00:00Z&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Once, at that exact moment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cron (5-field)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;30 8 * * 1-5&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Weekdays at 8:30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RRULE (RFC 5545)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;FREQ=MONTHLY;BYDAY=FR;BYSETPOS=-1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Last Friday of every month&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;RRULE is the format behind Outlook and Google Calendar recurrence. Any pattern you can set in a calendar invitation, you can use to schedule a process. &lt;code&gt;FREQ&lt;/code&gt;, &lt;code&gt;INTERVAL&lt;/code&gt;, &lt;code&gt;BYDAY&lt;/code&gt;, &lt;code&gt;BYMONTHDAY&lt;/code&gt;, &lt;code&gt;BYMONTH&lt;/code&gt;, &lt;code&gt;BYSETPOS&lt;/code&gt;, &lt;code&gt;COUNT&lt;/code&gt;, &lt;code&gt;UNTIL&lt;/code&gt; — all supported. Zero external dependencies.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;bpmn:startEvent&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"TimerStart"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"Last Friday of every month"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;bpmn:timerEventDefinition&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;bpmn:timeCycle&amp;gt;&lt;/span&gt;DTSTART:20260130T090000Z
RRULE:FREQ=MONTHLY;BYDAY=FR;BYSETPOS=-1&lt;span class="nt"&gt;&amp;lt;/bpmn:timeCycle&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/bpmn:timerEventDefinition&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/bpmn:startEvent&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pause and resume any schedule at runtime via the SDK or REST API. The schedule is a first-class object — queryable, manageable, observable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;schedules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listTimerSchedules&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;definitionId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pauseTimerSchedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schedules&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// ... later&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resumeTimerSchedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schedules&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why This Matters for Agentic Workflows
&lt;/h3&gt;

&lt;p&gt;Agentic systems are not request-response. They are continuous. A compliance monitoring agent should check every morning whether anything changed overnight. A portfolio rebalancing agent should evaluate positions on a schedule. A reporting agent should assemble and distribute summaries at the end of every week.&lt;/p&gt;

&lt;p&gt;These are not one-off tasks triggered by a user. They are standing processes with their own heartbeat. Timer start events give them that heartbeat — expressed in standard BPMN, persisted in the engine, surviving restarts and deployments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Message Start Events — Email as a Process Trigger
&lt;/h2&gt;

&lt;p&gt;Timer events handle "when." Message events handle "what happened."&lt;/p&gt;

&lt;p&gt;A message start event with the &lt;code&gt;graph-mailbox&lt;/code&gt; connector tells the engine: poll this Microsoft 365 mailbox, and when an unread email arrives, start a process instance.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;bpmn:message&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"Msg_Inbox"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"inbox-poll"&lt;/span&gt;
  &lt;span class="na"&gt;tri:connectorType=&lt;/span&gt;&lt;span class="s"&gt;"graph-mailbox"&lt;/span&gt;
  &lt;span class="na"&gt;tri:mailbox=&lt;/span&gt;&lt;span class="s"&gt;"support@your-company.com"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;bpmn:startEvent&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"Start"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"Email received"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;bpmn:messageEventDefinition&lt;/span&gt; &lt;span class="na"&gt;messageRef=&lt;/span&gt;&lt;span class="s"&gt;"Msg_Inbox"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/bpmn:startEvent&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two &lt;code&gt;tri:&lt;/code&gt; extension attributes on the &lt;code&gt;&amp;lt;bpmn:message&amp;gt;&lt;/code&gt; element identify the connector type and the mailbox. The Graph API credentials are configured once as engine settings — environment variables or SDK &lt;code&gt;init()&lt;/code&gt; — and never appear in the BPMN.&lt;/p&gt;

&lt;p&gt;Deploy the process. The engine polls. An email arrives. A process instance is created.&lt;/p&gt;

&lt;h3&gt;
  
  
  The onMailReceived Callback
&lt;/h3&gt;

&lt;p&gt;Here is where the "data outside the engine" principle from the first article meets the real world.&lt;/p&gt;

&lt;p&gt;The engine creates the process instance — so you have an &lt;code&gt;instanceId&lt;/code&gt; — but does not advance a single token until your callback returns. Your code receives the full email: subject, sender, body, and attachment metadata. You store it in your domain. You decide whether to proceed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;connectors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;graph-mailbox&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GRAPH_TENANT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GRAPH_CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;clientSecret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GRAPH_CLIENT_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;

  &lt;span class="na"&gt;onMailReceived&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;mailbox&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;getAttachmentContent&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Store the email in your domain, bound to the process instance&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;myStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;saveEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;receivedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;receivedDateTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// Download attachments on demand — metadata is already there, content is lazy&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;att&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attachments&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getAttachmentContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;att&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;myStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;att&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;att&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contentType&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Return { skip: true } to terminate the instance without running&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isSpam&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;skip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;

  &lt;span class="na"&gt;onServiceCall&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Your agentic logic — LLM calls, tool invocations, etc.&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Attachments are not pre-loaded into memory. The callback receives metadata — name, content type, size — and a &lt;code&gt;getAttachmentContent()&lt;/code&gt; function that downloads a single attachment on demand. A 40 MB zip does not sit in your Node process unless you explicitly ask for it.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;{ skip: true }&lt;/code&gt; return value terminates the instance. Spam filter, sender allowlist, duplicate detection — your code, your rules. The engine created the instance so you have an &lt;code&gt;instanceId&lt;/code&gt; to correlate against. If you skip, it is cleanly terminated. If you proceed, the process runs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why This Matters for Agentic Workflows
&lt;/h3&gt;

&lt;p&gt;Email is the entry point for most business processes in the real world. Customer requests, supplier invoices, regulatory notifications, internal approvals — they arrive as emails with attachments, and someone has to triage them, extract data, route them, and act.&lt;/p&gt;

&lt;p&gt;This is exactly what agentic workflows do. The BPMN process models the routing. The LLM handles the triage and extraction. The human task is the escalation point when the AI is uncertain. And now the trigger — the email itself — is part of the process definition.&lt;/p&gt;

&lt;p&gt;No middleware. No separate polling service. No Azure Function glue. The BPMN file declares the mailbox. The engine polls it. Your &lt;code&gt;onMailReceived&lt;/code&gt; callback stores the data. The process runs.&lt;/p&gt;

&lt;p&gt;A support email arrives → the agent extracts the intent → checks the knowledge base → drafts a response → routes to a human reviewer if confidence is low → sends the reply. All modelled in BPMN. All starting from an email. All running without anyone clicking "start."&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture — Same Pattern, Different Triggers
&lt;/h2&gt;

&lt;p&gt;Both timer and message start events follow the same internal pattern we use for the continuation worker:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Deploy&lt;/strong&gt; creates a persistent schedule document in MongoDB&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Worker loop&lt;/strong&gt; polls for due schedules, claims with an optimistic lease&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fire&lt;/strong&gt; calls &lt;code&gt;startInstance()&lt;/code&gt; — same as if you called it yourself via the API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Advance&lt;/strong&gt; updates the schedule (next fire time, or mark as exhausted)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Multi-instance safe. Survives restarts. No in-memory state. The schedule is a MongoDB document with an index — the same infrastructure the engine already uses for continuations and outbox delivery.&lt;/p&gt;

&lt;h2&gt;
  
  
  Come Build With Us
&lt;/h2&gt;

&lt;p&gt;Timer and message start events are available now in &lt;code&gt;@the-real-insight/in-concert&lt;/code&gt; on npm. The full documentation — RRULE expressions, cron, Graph mailbox setup, &lt;code&gt;onMailReceived&lt;/code&gt; callback reference — is in the &lt;a href="https://github.com/The-Real-Insight/in-concert/blob/main/docs/sdk/usage.md" rel="noopener noreferrer"&gt;SDK usage guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you are building agentic workflows and want your processes to have their own lifecycle — starting on a schedule, reacting to emails, running continuously without external triggers — this is the engine layer for that.&lt;/p&gt;

&lt;p&gt;Star the repo. Try it on a real process. Open an issue if something does not work the way you expect. The BPMN subset is growing, and the patterns we are building — timer-driven agents, email-triggered workflows, LLM-routed decisions — are where #agenticbpm gets practical.&lt;/p&gt;

&lt;p&gt;We are The Real Insight GmbH, and we believe BPMN is the orchestration backbone for the agentic era. Processes should not wait to be told when to start. They should know.&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://github.com/The-Real-Insight/in-concert" rel="noopener noreferrer"&gt;github.com/The-Real-Insight/in-concert&lt;/a&gt;&lt;br&gt;
→ &lt;a href="https://www.npmjs.com/package/@the-real-insight/in-concert" rel="noopener noreferrer"&gt;npmjs.com/package/@the-real-insight/in-concert&lt;/a&gt;&lt;br&gt;
→ &lt;a href="https://the-real-insight.com" rel="noopener noreferrer"&gt;the-real-insight.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Powered by The Real Insight GmbH BPMN Engine — &lt;a href="https://the-real-insight.com" rel="noopener noreferrer"&gt;the-real-insight.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agenticbpm</category>
      <category>bpmn</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Why We Keep Process Data Outside the Engine — and Why It Changes Everything for Agentic BPM</title>
      <dc:creator>Marc Gille-Sepehri</dc:creator>
      <pubDate>Sat, 11 Apr 2026 11:19:37 +0000</pubDate>
      <link>https://dev.to/marcgillesepehri/why-we-keep-process-data-outside-the-engine-and-why-it-changes-everything-for-agentic-bpm-27p4</link>
      <guid>https://dev.to/marcgillesepehri/why-we-keep-process-data-outside-the-engine-and-why-it-changes-everything-for-agentic-bpm-27p4</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa8bkkfmys0gtxlpdxoy6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa8bkkfmys0gtxlpdxoy6.png" alt=" " width="800" height="532"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There is a design decision at the heart of &lt;a href="https://github.com/The-Real-Insight/in-concert" rel="noopener noreferrer"&gt;in-concert&lt;/a&gt; that surprises people when they first encounter it: &lt;strong&gt;the engine knows nothing about your data&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;No domain objects stored inside the engine. No variable interpolation in BPMN expressions. No built-in scripting that reaches into your database. When a process instance is running, the engine holds exactly one thing that belongs to you: an &lt;code&gt;instanceId&lt;/code&gt;. Everything else — documents, application state, business context, AI responses — lives in your systems, bound to that id.&lt;/p&gt;

&lt;p&gt;This is not an oversight. It is the central architectural choice, and it shapes everything else about how in-concert works. And this is the  beginning of #agenticbpm.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem with Data-Coupled Engines
&lt;/h2&gt;

&lt;p&gt;Traditional BPM engines — Camunda, Flowable, Activiti — manage process variables alongside process state. You deploy a BPMN, you pass in variables, and the engine stores them, interpolates them into conditions, and threads them through the execution. It is convenient at first. Then reality arrives.&lt;/p&gt;

&lt;p&gt;Your process needs to evaluate a condition against data that lives in your ERP. Or the "variable" is actually a 40-page document. Or the service task needs to call an LLM with context assembled from five different sources. Or your security model requires that PII never leaves your own database.&lt;/p&gt;

&lt;p&gt;Suddenly the engine is not a neutral orchestrator. It has become a data store you did not ask for, a security boundary you have to manage, and an integration point that does not understand the shape of your actual domain.&lt;/p&gt;

&lt;p&gt;We built in-concert after running into exactly these problems. The solution was radical simplicity: &lt;strong&gt;the engine does not store your data, period&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Touch Points — and Why They Are All Fuzzy
&lt;/h2&gt;

&lt;p&gt;In any BPMN process, there are three places where execution intersects with the outside world. In a traditional engine, these are handled by scripting, expression languages, and built-in connectors. In in-concert, they are handled by your code — deliberately, explicitly, and with full access to everything you know.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Service Tasks
&lt;/h3&gt;

&lt;p&gt;A service task means "call something external and continue." In a classic engine, you write a connector or a script that runs inside the engine's JVM or Node.js process. The engine manages the call, handles the result, and stores output variables.&lt;/p&gt;

&lt;p&gt;In in-concert, the engine calls your handler and waits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;onServiceCall&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Full control. Assemble context from anywhere.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;myDataStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContextFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;myLLM&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;extensions&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;toolId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;completeExternalTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workItemId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The handler receives the &lt;code&gt;instanceId&lt;/code&gt; and whatever metadata you put on the BPMN node (&lt;code&gt;tri:toolId&lt;/code&gt;, &lt;code&gt;tri:toolType&lt;/code&gt;, custom extensions). It completes when it is done — whether that is 50ms or 50 minutes later, whether via a direct response, a message queue reply, or a webhook. The engine waits. It does not care how long.&lt;/p&gt;

&lt;p&gt;But "call an LLM" understates what the handler can actually do. Consider what happens in a real agentic workflow:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The LLM as context mapper.&lt;/strong&gt; Your process node carries a &lt;code&gt;tri:toolId&lt;/code&gt; that identifies an MCP tool — say, &lt;code&gt;search-crm&lt;/code&gt; or &lt;code&gt;generate-proposal&lt;/code&gt;. The tool has a defined input schema. Your application data has its own shape. The LLM's job here is not to answer a question — it is to map your domain objects into the tool's input format, invoke the tool, and map the structured output back into whatever your process needs next.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;onServiceCall&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toolId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;extensions&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;toolId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// e.g. "search-crm"&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mcpClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;toolId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;           &lt;span class="c1"&gt;// get tool + schema&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;myDataStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContextFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// LLM maps context → tool input schema&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toolInput&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mapToToolInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Invoke the MCP tool&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toolOutput&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;mcpClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;toolId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;toolInput&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// LLM maps tool output → domain result&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mapFromToolOutput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outputSchema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;toolOutput&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;completeExternalTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workItemId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pattern — &lt;strong&gt;LLM as the mapping and reasoning layer around structured tool calls&lt;/strong&gt; — is where BPMN and agentic AI (i.e. #agenticbpm) meet most naturally. The process definition models the flow and the intent. The BPMN node identifies which tool to use. The LLM handles the fuzzy work of bridging between your data model and the tool's contract. And because all of this happens in your code, you can swap models, adjust prompts, and iterate on the mapping logic without touching the process definition at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The handler is also where long-running integration lives.&lt;/strong&gt; Publish a job to a queue, return immediately, and complete the task when the consumer acknowledges. Poll an external system until it is ready. Wait for a webhook. None of this requires anything special from the engine — it simply waits until your handler calls &lt;code&gt;completeExternalTask&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Transition Conditions — the XOR Gateway
&lt;/h3&gt;

&lt;p&gt;This is where the fuzziness gets interesting. In a BPMN XOR gateway, one of several outgoing flows is selected based on a condition. In a traditional engine, you write an expression: &lt;code&gt;${amount &amp;gt; 1000}&lt;/code&gt;, or a FEEL expression, or a Groovy script. The engine evaluates it against stored variables.&lt;/p&gt;

&lt;p&gt;But what if the condition is not a clean boolean expression? What if it is "does this application look fraudulent?" or "is this document complete enough to proceed?" or "based on the conversation so far, which department should handle this?"&lt;/p&gt;

&lt;p&gt;These are not expressions. They are judgements — and judgements require context, and often require an LLM.&lt;/p&gt;

&lt;p&gt;In in-concert, gateway decisions are routed to your handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;onDecision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// payload.transitions is the list of outgoing flows with names and conditions&lt;/span&gt;
    &lt;span class="c1"&gt;// You evaluate — using your data, your rules, your LLM&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;myDataStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContextFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;selected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;myRouter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transitions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submitDecision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;decisionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;selectedFlowIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;selected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;flowId&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The engine gives you the transition options. You choose. The evaluation logic — however simple or sophisticated — belongs to you. You can use a simple &lt;code&gt;if/else&lt;/code&gt;. You can call an LLM with the full application context. You can run a rules engine. The engine does not prescribe how you decide; it only records that you did.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Human Tasks — the Worklist
&lt;/h3&gt;

&lt;p&gt;User interaction is the most obviously fuzzy of the three. A human task is not deterministic. The user brings judgement, context, domain knowledge, and occasionally the wrong answer. The task might be "review this contract," "approve this expense," or "assess whether this customer qualifies."&lt;/p&gt;

&lt;p&gt;In in-concert, human tasks are projected to a queryable worklist. Your UI queries it, filtered by role, by claimed status, by instance. The user sees the task, opens your application where the full document and context live, makes a decision, and your code calls &lt;code&gt;completeUserTask()&lt;/code&gt; with the result.&lt;/p&gt;

&lt;p&gt;The engine never renders a form. It never stores the contract. It never knows what the user saw. It only knows that a human task at a given node in a given process instance was completed with a given result — and it advances accordingly.&lt;/p&gt;

&lt;p&gt;This lets you build any interaction model: cherry-picking worklists, supervisor assignment, AI-assisted pre-screening before human review. The engine is the backbone, not the bottleneck.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Unlocks for Agentic BPM
&lt;/h2&gt;

&lt;p&gt;Here is the part that we find genuinely exciting.&lt;/p&gt;

&lt;p&gt;AI agents need orchestration. A single LLM call is not a workflow — it is a function. Useful, but limited. Real agentic systems involve sequences of steps, parallel branches, human checkpoints, error handling, retries, long-running waits. They need state across time. They need the ability to hand off between AI and human. They need audit trails.&lt;/p&gt;

&lt;p&gt;BPMN is a remarkably good fit for this. It has been modelling complex, long-running processes for decades. It handles parallelism, subprocesses, boundary events, timers, and message correlation out of the box. And it is visual — a BPMN diagram is something a business analyst and a developer can read together.&lt;/p&gt;

&lt;p&gt;in-concert brings BPMN to agentic systems with a clean separation: &lt;strong&gt;the engine handles the orchestration; your code handles the intelligence&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Service tasks become LLM invocations. Gateway decisions become LLM evaluations against your domain context. Human tasks become the checkpoints where a person reviews or overrides what the AI decided. And because all data and logic live outside the engine, you can iterate on your prompts, your models, and your routing logic without touching the process definition.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;instanceId&lt;/code&gt; is the thread that holds it together. Every LLM call, every database query, every human task can be correlated to a specific process instance. You know exactly where in the process you are, what decisions were made, and what the audit trail looks like — because in-concert records all of that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;in-concert is open source, MIT-licensed (with attribution), and published on npm:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @the-real-insight/in-concert
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SDK works in two modes. For microservice deployments, run the engine as a standalone service and connect via REST and WebSocket. For embedded or test use, run it directly in-process against MongoDB — same API, no server needed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BpmnEngineClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@the-real-insight/in-concert/sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// REST mode&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BpmnEngineClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rest&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:3000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Local / embedded mode&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BpmnEngineClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;local&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full quick start, API reference, and BPMN conformance matrix are in the &lt;a href="https://github.com/The-Real-Insight/in-concert" rel="noopener noreferrer"&gt;GitHub repository&lt;/a&gt;. The README documents every SDK method with accurate type signatures pulled directly from the package.&lt;/p&gt;

&lt;h2&gt;
  
  
  Come Build With Us
&lt;/h2&gt;

&lt;p&gt;in-concert is early. The BPMN subset is intentionally focused — we implement what production workflows actually need, and we fail loudly on anything we do not support yet. There is meaningful work to be done on the conformance surface, the developer experience, and the agentic integration patterns.&lt;/p&gt;

&lt;p&gt;If this resonates with you — if you have built on BPM engines and felt the friction of data-coupled orchestration, or if you are thinking about how to bring structure to agentic AI workflows — we would love to have you involved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Star the repo.&lt;/strong&gt; Try it on a real process. Open an issue. Submit a PR. The contribution guide is in &lt;code&gt;docs/contributing.md&lt;/code&gt; and there are &lt;code&gt;good first issue&lt;/code&gt; labels for anyone who wants to start small.&lt;/p&gt;

&lt;p&gt;We are The Real Insight GmbH, and we are building the engine layer for #agenticbpm. This is just the beginning.&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://github.com/The-Real-Insight/in-concert" rel="noopener noreferrer"&gt;github.com/The-Real-Insight/in-concert&lt;/a&gt;&lt;br&gt;
→ &lt;a href="https://www.npmjs.com/package/@the-real-insight/in-concert" rel="noopener noreferrer"&gt;npmjs.com/package/@the-real-insight/in-concert&lt;/a&gt;&lt;br&gt;
→ &lt;a href="https://the-real-insight.com" rel="noopener noreferrer"&gt;the-real-insight.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Powered by The Real Insight GmbH BPMN Engine — &lt;a href="https://the-real-insight.com" rel="noopener noreferrer"&gt;the-real-insight.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>bpmn</category>
      <category>aiagents</category>
      <category>node</category>
      <category>agenticbpm</category>
    </item>
  </channel>
</rss>
