<?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 Duiker</title>
    <description>The latest articles on DEV Community by Marc Duiker (@marcduiker).</description>
    <link>https://dev.to/marcduiker</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F220922%2Fb486eebe-5f4a-4d1d-8239-693aababb16a.png</url>
      <title>DEV Community: Marc Duiker</title>
      <link>https://dev.to/marcduiker</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/marcduiker"/>
    <language>en</language>
    <item>
      <title>The Three Failures Every Fault Tolerant AI Agent Must Handle</title>
      <dc:creator>Marc Duiker</dc:creator>
      <pubDate>Wed, 14 Oct 2026 13:32:46 +0000</pubDate>
      <link>https://dev.to/diagrid/the-three-failures-every-fault-tolerant-ai-agent-must-handle-1e1g</link>
      <guid>https://dev.to/diagrid/the-three-failures-every-fault-tolerant-ai-agent-must-handle-1e1g</guid>
      <description>&lt;p&gt;"Make the agent fault tolerant" sounds like one task. It is three. Your agent can fail loudly, with an error you can see and retry. It can fail silently, when a side effect fires and the process dies before anything records it. And it can fail by waiting, when an approval takes two days and the process holding that wait gets restarted on day one. Each class needs a different mechanism, and most agent frameworks only ship the first. That is why demos look fault tolerant and production agents send the same email twice or lose an approval.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why fault tolerance is three assignments
&lt;/h2&gt;

&lt;p&gt;When you move an agent from prototype to production, "make it fault tolerant" lands in the backlog as one ticket. You handle the failures you've seen in testing, and those are almost always the loud ones: a tool returns a 500, the model API times out, the LLM sends back malformed JSON.&lt;/p&gt;

&lt;p&gt;The other failures rarely show up on your laptop. They show up during a deploy, when the orchestrator kills a pod for using too much memory, when the cloud provider reclaims a spot instance, or when a run sits waiting for a human for three days. Fault tolerance for an agent covers three distinct classes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Visible failures, where something errors and you know about it.&lt;/li&gt;
&lt;li&gt;Invisible failures, where work happens but the record of it is lost.&lt;/li&gt;
&lt;li&gt;Long waits, where the agent has to outlive the process running it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Visible failures and the retry toolbox
&lt;/h2&gt;

&lt;p&gt;A visible failure announces itself. The tool call returns an error, the API times out with a clear exception, the model refuses the request or returns output your parser rejects.&lt;/p&gt;

&lt;p&gt;The toolbox for this class is well known:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Retry with exponential backoff, so a rate-limited or briefly unavailable service gets time to recover.&lt;/li&gt;
&lt;li&gt;Cap the number of attempts, so a permanent error doesn't turn into an infinite loop that burns tokens.&lt;/li&gt;
&lt;li&gt;Dead-letter the requests that keep failing, so a person or a separate process can look at them later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Almost every agent framework and HTTP client ships some version of this, so it's the part of fault tolerance most teams already have.&lt;/p&gt;

&lt;p&gt;Retries have one built-in assumption, though: the failed attempt had no effect. A retry repeats a step; it doesn't know whether the previous attempt already sent the email or charged the card before the error came back. The next class is where it breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Invisible failures and why checkpointing falls short
&lt;/h2&gt;

&lt;p&gt;An invisible failure happens in the gap between doing something and recording that you did it. Your agent calls the payment API and the charge goes through. Before the agent writes "payment done" anywhere, the process disappears. A rolling deploy replaced the pod, the kernel's out-of-memory killer picked your container, or the spot instance was reclaimed. No exception is raised and no retry policy fires, because the code that would catch the error is gone too.&lt;/p&gt;

&lt;p&gt;Many agent frameworks answer this with checkpointing. It saves a snapshot of the agent's state after each step, and a restarted agent loads the latest one. If the process dies during a step or right after it, that snapshot is from before the side effect. The restored agent has no idea the charge happened, and it charges again.&lt;/p&gt;

&lt;p&gt;Durable execution closes most of that gap by making the record part of execution itself. The engine writes "scheduled" to a journal before a step runs, and "completed" with the result once it returns. On restart it replays the journal, skips every step marked completed, uses the recorded results instead of calling the tools again, and resumes at the first unfinished step. This is the class of failure that separates a demo agent from a production one.&lt;/p&gt;

&lt;p&gt;There's still a small window left: the side effect can succeed just before the process dies and before "completed" is written. In that case the engine reruns the step, which is why durable execution engines guarantee that a step runs at least once rather than exactly once. The journal gets you back to the right step. Making the side effect happen once is the job of the step itself: pass an idempotency key to the downstream service so it recognizes the repeat and returns the original result. Dapr Workflow gives every scheduled task a task execution ID that stays the same across retries, so you can use it as that key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Long waits and durable timers
&lt;/h2&gt;

&lt;p&gt;Agents spend a lot of their lives waiting. A refund over a certain amount needs a manager's approval, which might come in two hours or two days. A procurement agent can't confirm delivery until the container ships on Tuesday.&lt;/p&gt;

&lt;p&gt;Processes don't live that long. Over a two-day approval window your agent will probably go through several deploys, a node upgrade or two, and maybe an autoscaler scaling it down overnight. A wait held in process memory, like a sleep call, an in-memory promise or a loop polling an inbox, disappears with the process. Any wait held in memory is a failure scheduled in advance; you just don't know the exact time yet.&lt;/p&gt;

&lt;p&gt;Durable execution handles this by moving the wait into the journal as well. A durable timer records "wake me at this time," and an external event records "resume when this approval arrives." While the agent waits, nothing needs to run: the process can stop, restart or move to another node. When the timer fires or the event comes in, the engine loads the journal and continues from the waiting step. Pair the approval event with a timer so that if nobody responds within three days the agent escalates or cancels instead of waiting forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  The crash test that checks all three
&lt;/h2&gt;

&lt;p&gt;You can check your agent against both of the harder classes with two tests. Both use a force-kill, meaning the process ends right away with no graceful shutdown and no cleanup handlers. For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In a container, use &lt;code&gt;docker kill&lt;/code&gt;, or delete the pod with a zero grace period in Kubernetes.&lt;/li&gt;
&lt;li&gt;On Windows, end the process from Task Manager or with &lt;code&gt;taskkill /F&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;On macOS, use Force Quit in Activity Monitor or &lt;code&gt;kill -9&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;On Linux, use &lt;code&gt;kill -9&lt;/code&gt; with the process ID.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the first test, start a run that makes a side-effecting call, such as sending an email or creating a record in another system. Force-kill the process right after that call succeeds, restart it, and let the run finish. Then count the effects in the downstream system. One is the only right answer. If you see two, the step needs an idempotency key.&lt;/p&gt;

&lt;p&gt;For the second test, start a run that waits for an approval or a timer. Force-kill the process during the wait, restart it, then send the approval. The agent should pick up the event and continue.&lt;/p&gt;

&lt;p&gt;Most teams skip these tests because they tend to fail the first time. That is exactly why you should run them before production does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs and considerations
&lt;/h2&gt;

&lt;p&gt;Durable execution isn't free. Every step writes to the journal before and after it runs, which adds a little latency and storage per step. For steps with side effects or long waits, that cost is almost always worth paying; for a quick in-memory computation it usually isn't.&lt;/p&gt;

&lt;p&gt;The orchestration logic also has to be deterministic, because the engine replays it to rebuild state. Keep LLM calls, tool calls and anything else non-deterministic inside activities, and keep the orchestration code limited to deciding what runs next.&lt;/p&gt;

&lt;p&gt;Not every agent needs all three layers either. A stateless chat helper that only reads data is fine with the retry toolbox alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Fault tolerance for AI agents is three separate problems. Retries with backoff and dead-lettering handle visible failures. Invisible failures need more than checkpointing: durable execution writes each step to a journal before and after it runs, so recovery resumes at the right step, and an idempotency key makes sure the side effect lands once. Long waits belong in the journal too, as durable timers and external events, so a restart can't erase them. The two force-kill tests tell you which of these your agent already handles. To see these ideas in a small working example, read &lt;a href="https://www.diagrid.io/blog/the-tiniest-durable-agent" rel="noopener noreferrer"&gt;The Tiniest Durable Agent&lt;/a&gt;. To learn durable timers, external events, and recovery hands-on, take the free &lt;a href="https://www.diagrid.io/university/workflow" rel="noopener noreferrer"&gt;Dapr Workflow course on Dapr University&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>architecture</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Understanding At-Least-Once Execution and Why It Matters for Workflow &amp; Agent Developers</title>
      <dc:creator>Marc Duiker</dc:creator>
      <pubDate>Thu, 08 Oct 2026 12:51:24 +0000</pubDate>
      <link>https://dev.to/diagrid/understanding-at-least-once-execution-and-why-it-matters-for-workflow-developers-3nen</link>
      <guid>https://dev.to/diagrid/understanding-at-least-once-execution-and-why-it-matters-for-workflow-developers-3nen</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Durable execution guarantees that your workflow finishes. It does not guarantee that each step runs only once. Picture a workflow that charges a credit card for 49 EUR. The payment provider accepts the charge, and then the pod is evicted before the workflow records the result. On restart, the workflow sees an unfinished step and charges the card again. Your customer now pays 98 EUR.&lt;/p&gt;

&lt;p&gt;If you are new to durable execution, this can feel like a bug in the engine. It is not. Durable execution engines, including Dapr Workflow, run activities at least once, and making a side effect happen exactly once is the job of your activity code. In this post, you'll see why the engine behaves this way, how to spot the steps that need protection, and how to use the task execution ID in a C# activity as an idempotency key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Durable Execution Guarantees Completion, Not Single Execution
&lt;/h2&gt;

&lt;p&gt;A Dapr Workflow app has two kinds of code. The workflow code decides what happens next: call this activity, wait for that event, branch on a result. The activity code does the actual work that touches the outside world, such as calling a payment provider, writing to a database, or sending an email. The workflow engine persists every step, so when your app crashes it can pick up where it left off instead of starting over. If you want a primer on this model, watch &lt;a href="https://www.diagrid.io/videos/failure-is-not-an-option-durable-execution-dapr-2" rel="noopener noreferrer"&gt;this session on durable execution with Dapr&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;There is a catch in "pick up where it left off". The engine can only store that a step happened after the step has happened. Between the moment your activity charges the card and the moment the engine writes down the result, there is always a window. If the process dies inside that window, the engine has no record of the charge, so the only safe choice is to run the activity again.&lt;/p&gt;

&lt;p&gt;That is why durable execution gives you completion, not exactly-once execution. Exactly-once side effects come from making each activity idempotent: running it twice with the same input has the same effect as running it once. This is not specific to Dapr. Temporal and Azure Durable Functions make the same trade-off and give the same advice.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Dapr Workflow Records Progress
&lt;/h2&gt;

&lt;p&gt;Here is the order workflow from the intro, written with the Dapr .NET SDK:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderWorkflow&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Workflow&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;RunAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WorkflowContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PaymentResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
            &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ChargeCardActivity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ChargeRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CustomerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Amount&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ShipOrderActivity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PaymentId&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 workflow code does not call &lt;code&gt;ChargeCardActivity&lt;/code&gt; directly. The &lt;a href="https://docs.dapr.io/contributing/protocol-reference/workflow-protocol/workflow-protocol-orchestration-lifecycle/" rel="noopener noreferrer"&gt;orchestration lifecycle&lt;/a&gt; describes what happens instead. The Dapr sidecar sends your app an &lt;code&gt;OrchestratorWorkItem&lt;/code&gt; over a long-lived &lt;code&gt;GetWorkItems&lt;/code&gt; gRPC stream. That work item contains the full history of the workflow instance. The SDK replays your &lt;code&gt;RunAsync&lt;/code&gt; method from the top against that history. When it reaches &lt;code&gt;CallActivityAsync&lt;/code&gt;, it looks for a recorded result. If there is one, it returns the recorded result without running the activity. If there isn't, it emits a &lt;code&gt;ScheduleTask&lt;/code&gt; action and suspends the workflow.&lt;/p&gt;

&lt;p&gt;The engine then appends a &lt;code&gt;TaskScheduled&lt;/code&gt; event to the history and dispatches the activity. When the activity reports back, the engine records a &lt;code&gt;TaskCompleted&lt;/code&gt; (or &lt;code&gt;TaskFailed&lt;/code&gt;) event, and the next replay picks up the result.&lt;/p&gt;

&lt;p&gt;According to the &lt;a href="https://docs.dapr.io/contributing/protocol-reference/workflow-protocol/workflow-protocol-state-and-history/" rel="noopener noreferrer"&gt;state and history&lt;/a&gt; docs, this history is the source of truth. It is stored in your configured state store as a list of serialized events, with keys like &lt;code&gt;wf-history-&amp;lt;instance_id&amp;gt;-&amp;lt;index&amp;gt;&lt;/code&gt;. For the order workflow, the history at the moment of the pod eviction looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ExecutionStarted   OrderWorkflow
TaskScheduled      ChargeCardActivity
                   &amp;lt;- pod evicted here, card already charged
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no &lt;code&gt;TaskCompleted&lt;/code&gt; for &lt;code&gt;ChargeCardActivity&lt;/code&gt;. As far as the engine knows, the charge never finished.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Activities Run at Least Once
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://docs.dapr.io/contributing/protocol-reference/workflow-protocol/workflow-protocol-activity-lifecycle/" rel="noopener noreferrer"&gt;activity lifecycle&lt;/a&gt; shows where the window sits. When the engine dispatches an activity, the sidecar sends an &lt;code&gt;ActivityWorkItem&lt;/code&gt; over the same &lt;code&gt;GetWorkItems&lt;/code&gt; stream. The work item carries the activity &lt;code&gt;name&lt;/code&gt;, its &lt;code&gt;input&lt;/code&gt;, the workflow &lt;code&gt;instance_id&lt;/code&gt;, a &lt;code&gt;task_id&lt;/code&gt;, a &lt;code&gt;task_execution_id&lt;/code&gt;, and a &lt;code&gt;completion_token&lt;/code&gt;. The SDK runs your activity code and then calls &lt;code&gt;CompleteActivityTask&lt;/code&gt; with the &lt;code&gt;result&lt;/code&gt; (or &lt;code&gt;failure_details&lt;/code&gt;) and the &lt;code&gt;completion_token&lt;/code&gt;, as defined in the &lt;a href="https://docs.dapr.io/contributing/protocol-reference/workflow-protocol/workflow-protocol-execution-api/" rel="noopener noreferrer"&gt;execution API&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Your side effect happens while the activity runs. The engine learns about it only when &lt;code&gt;CompleteActivityTask&lt;/code&gt; arrives. Anything that breaks the chain between those two points, such as a pod eviction, an out-of-memory kill, a node failure, or a dropped network connection, leaves a &lt;code&gt;TaskScheduled&lt;/code&gt; event without a matching &lt;code&gt;TaskCompleted&lt;/code&gt;. The activity lifecycle doc names this case directly: activities "might be executed more than once (e.g., if the worker crashes after execution but before reporting completion)".&lt;/p&gt;

&lt;p&gt;Retry policies are a second source of repeats. If an activity fails and the workflow has a retry policy, the engine schedules the activity again with the same name and input. A failure from your activity's point of view is not always a failure on the other side. If the HTTP call to the payment provider times out, your activity throws, yet the provider may have processed the charge anyway.&lt;/p&gt;

&lt;p&gt;The third case is a slow worker. If a worker takes too long and the task is handed to another worker, the original one can still finish. The sidecar uses the &lt;code&gt;completion_token&lt;/code&gt; to ignore the late response, but by then the side effect has already happened.&lt;/p&gt;

&lt;p&gt;In all three cases, the engine protects its own bookkeeping, not your side effects. That is why at-least-once is the norm for durable execution engines.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Spot the Steps That Need Idempotency
&lt;/h2&gt;

&lt;p&gt;Not every activity needs extra work. For each activity in your workflow, ask one question: if this runs twice with the same input, is the outcome different from running it once?&lt;/p&gt;

&lt;p&gt;Activities that usually need protection:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Charging, refunding, or transferring money&lt;/li&gt;
&lt;li&gt;Sending emails, text messages, or push notifications&lt;/li&gt;
&lt;li&gt;Inserting records with generated IDs, where a second insert creates a duplicate row&lt;/li&gt;
&lt;li&gt;Publishing messages to a broker or event stream&lt;/li&gt;
&lt;li&gt;Incrementing counters or adjusting stock levels&lt;/li&gt;
&lt;li&gt;Calling an LLM tool or external API that acts on another system, such as creating a ticket&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Activities that are usually safe already:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pure computation on the input&lt;/li&gt;
&lt;li&gt;Reads, such as GET requests and database queries&lt;/li&gt;
&lt;li&gt;Upserts keyed on a natural ID, such as the order ID&lt;/li&gt;
&lt;li&gt;Setting a value to a fixed state, such as "set order status to Shipped"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Don't forget compensation activities. If your workflow uses the saga pattern, the refund that undoes a charge can run twice as well, and refunding a customer twice is just as wrong as charging them twice.&lt;/p&gt;

&lt;p&gt;Keep this separate from workflow determinism. Determinism is about your workflow code producing the same decisions on every replay, so no &lt;code&gt;DateTime.UtcNow&lt;/code&gt;, random numbers, or direct I/O in &lt;code&gt;RunAsync&lt;/code&gt;. Idempotency is about your activity code having the same effect when it runs more than once. You need both, but they solve different problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using the Task Execution ID as an Idempotency Key
&lt;/h2&gt;

&lt;p&gt;The standard fix is an idempotency key. You send a key with the request, and the receiving service remembers which keys it has already processed. When the same key arrives a second time, the service returns the original result instead of doing the work again. Most payment providers support this through an &lt;code&gt;Idempotency-Key&lt;/code&gt; HTTP header.&lt;/p&gt;

&lt;p&gt;The key has to be the same every time the activity runs for the same step, and different for every other step. Since Dapr .NET SDK v1.17, &lt;code&gt;WorkflowActivityContext&lt;/code&gt; exposes a &lt;code&gt;TaskExecutionId&lt;/code&gt; property that fits this. The ID is created when the workflow schedules the activity and is stored with the &lt;code&gt;TaskScheduled&lt;/code&gt; event in the history. When the engine dispatches that scheduled task again after a crash, a pod eviction, or a worker timeout, your activity receives the same ID. Treat it as an opaque string and don't parse it.&lt;/p&gt;

&lt;p&gt;Here is the payment activity with the task execution ID passed as the idempotency key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ChargeCardActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;IHttpClientFactory&lt;/span&gt; &lt;span class="n"&gt;httpClientFactory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ILogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ChargeCardActivity&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;WorkflowActivity&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ChargeRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PaymentResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PaymentResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;RunAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;WorkflowActivityContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ChargeRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;idempotencyKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskExecutionId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"Charging {Amount} EUR for {CustomerId} with idempotency key {Key}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CustomerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;httpClient&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpClientFactory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"payments"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;HttpRequestMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HttpMethod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"/v1/payments"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;Content&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;JsonContent&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="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;
        &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Idempotency-Key"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;httpClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SendAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EnsureSuccessStatusCode&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadFromJsonAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PaymentResult&lt;/span&gt;&lt;span class="p"&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;Replay the eviction scenario with this version:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The engine dispatches &lt;code&gt;ChargeCardActivity&lt;/code&gt; with task execution ID &lt;code&gt;a1b2...&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The provider charges 49 EUR and stores the payment under key &lt;code&gt;a1b2...&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The pod is evicted before &lt;code&gt;CompleteActivityTask&lt;/code&gt; reaches the sidecar.&lt;/li&gt;
&lt;li&gt;The workflow restarts, and the engine dispatches the same scheduled task again with task execution ID &lt;code&gt;a1b2...&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The provider recognizes the key and returns the original payment without charging the card.&lt;/li&gt;
&lt;li&gt;The engine records &lt;code&gt;TaskCompleted&lt;/code&gt;, and the workflow moves on to shipping.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Your customer pays 49 EUR. The same approach works for any service that accepts a deduplication key, such as email providers, message brokers that support message deduplication IDs, and your own APIs.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the Idempotency Key Expires
&lt;/h2&gt;

&lt;p&gt;An idempotency key only helps while the service still remembers it. Many providers clear keys after a fixed period. Stripe, for example, can remove keys once they are at least 24 hours old. Check the documentation of every service you call for its retention period.&lt;/p&gt;

&lt;p&gt;Durable workflows can run for days or weeks, and a second dispatch doesn't always follow the first one quickly. An outage, a long retry backoff, or a suspended workflow that resumes later can all push the second attempt past the retention period. At that point the provider treats the request as new. Some services don't support idempotency keys at all.&lt;/p&gt;

&lt;p&gt;In those cases you need a record you own: a dedupe table in your own database, keyed on the task execution ID. Before doing the work, the activity checks the table. After doing the work, it stores the result.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SendInvoiceActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;InvoiceDbContext&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IEmailSender&lt;/span&gt; &lt;span class="n"&gt;emailSender&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;WorkflowActivity&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;InvoiceRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;InvoiceResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;InvoiceResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;RunAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;WorkflowActivityContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;InvoiceRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskExecutionId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;processed&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProcessedTasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FindAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&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="n"&gt;processed&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InvoiceResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;processed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MessageId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;messageId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;emailSender&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SendAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;To&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProcessedTasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ProcessedTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveChangesAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InvoiceResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messageId&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;Make the task execution ID the primary key of &lt;code&gt;ProcessedTasks&lt;/code&gt;. A unique constraint stops two concurrent attempts, such as a slow worker and its replacement, from both recording a result. Keep rows for longer than your longest-running workflow, and clean up older rows with a scheduled job.&lt;/p&gt;

&lt;p&gt;This version still has a gap. If the process dies between &lt;code&gt;SendAsync&lt;/code&gt; and &lt;code&gt;SaveChangesAsync&lt;/code&gt;, the email goes out twice. To close it, insert a &lt;code&gt;Pending&lt;/code&gt; row before calling the external service and mark it &lt;code&gt;Completed&lt;/code&gt; afterwards. When an attempt finds a &lt;code&gt;Pending&lt;/code&gt; row, it knows a previous attempt may have done the work, and it can check with the provider before trying again. Where the provider supports keys, send the task execution ID to it as well: the provider's key covers the short window, and your table covers everything after the key expires. For more on choosing between keys and this kind of reconciliation, see the &lt;a href="https://www.diagrid.io/faq/durable-execution/should-i-use-idempotency-keys-or-reconciliation-logic-for-external-side-effects-in-agentic" rel="noopener noreferrer"&gt;Diagrid FAQ on idempotency keys and reconciliation logic&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs and Considerations
&lt;/h2&gt;

&lt;p&gt;Check how the task execution ID behaves with retry policies before you rely on it. The .NET SDK documentation describes it as stable across retries, while the activity lifecycle protocol doc says it changes every time the engine retries the activity. For the payment timeout case, that difference decides whether the provider sees one charge or two. Test it on your Dapr and SDK versions. If you need one key across all retry attempts, build it in the workflow from the instance ID and a step name, for example &lt;code&gt;$"{context.InstanceId}:charge-card"&lt;/code&gt;, and pass it as activity input. Use a different step name for every call, including calls inside a loop.&lt;/p&gt;

&lt;p&gt;Scope each key to one side effect. If an activity both charges a card and sends a receipt, it needs two keys, such as &lt;code&gt;{TaskExecutionId}:charge&lt;/code&gt; and &lt;code&gt;{TaskExecutionId}:receipt&lt;/code&gt;. Splitting the work into two activities is usually cleaner.&lt;/p&gt;

&lt;p&gt;Idempotency has a cost. A dedupe table adds a database round trip to every call and a cleanup job to your operations work. Spend that effort on the activities from the checklist above, not on every activity.&lt;/p&gt;

&lt;p&gt;Don't move side effects into the workflow code to avoid retries. Workflow code replays on every step, so a side effect there runs far more often, and it breaks determinism.&lt;/p&gt;

&lt;p&gt;Finally, log the key in every activity. Idempotency stops the duplicate from reaching your customer, and the logs tell you how often it happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Durable execution keeps your workflow moving through crashes and pod evictions, but it does so by running activities at least once. The Dapr workflow protocol makes the reason visible: the engine only knows an activity finished once a TaskCompleted event lands in the history, so any failure in between leads to a second dispatch. The fix belongs in your activities. Find the steps with side effects, pass the task execution ID as an idempotency key to the services that support it, and back it with a dedupe table when the downstream service forgets keys too soon or doesn't support them at all. Your 49 EUR charge then stays 49 EUR.&lt;/p&gt;

&lt;p&gt;To build a solid foundation in the Dapr Workflow programming model, including activity retries and error handling, take the free &lt;a href="https://www.diagrid.io/university/workflow" rel="noopener noreferrer"&gt;Dapr Workflow track on Dapr University&lt;/a&gt;. If you prefer video, &lt;a href="https://www.diagrid.io/videos/failure-is-not-an-option-durable-execution-dapr-2" rel="noopener noreferrer"&gt;this session on durable execution with Dapr&lt;/a&gt; covers how replay and history work.&lt;/p&gt;

</description>
      <category>dapr</category>
      <category>architecture</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>What's New in the Dapr Dev Dashboard for Local Workflow Debugging</title>
      <dc:creator>Marc Duiker</dc:creator>
      <pubDate>Mon, 05 Oct 2026 12:39:18 +0000</pubDate>
      <link>https://dev.to/diagrid/whats-new-in-the-dapr-dev-dashboard-for-local-workflow-debugging-2549</link>
      <guid>https://dev.to/diagrid/whats-new-in-the-dapr-dev-dashboard-for-local-workflow-debugging-2549</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;The Diagrid Dapr Dev Dashboard has shipped nine releases since the &lt;a href="https://dev.to/diagrid/a-free-open-source-dev-dashboard-for-local-dapr-development-11ip"&gt;first announcement&lt;/a&gt;, and if you build Dapr Workflow apps on your machine, several of them change how you debug. The Workflows page now loads thousands of instances in milliseconds instead of hanging. A new State page lets you browse, add, and delete the records in your state store. A context-aware Dapr CLI drawer gives you the exact &lt;code&gt;dapr workflow&lt;/code&gt; command for the instance you are looking at, with the IDs already filled in. On top of that, you can publish test messages to a topic, check component secret references, and keep the app list and logs tidy as your dev session grows. This post covers the highlights from v0.0.14 to v0.0.22 and shows how each one helps you find out why a workflow behaves the way it does. You can find an overview of the dashboard on the &lt;a href="https://www.diagrid.io/dev-dashboard" rel="noopener noreferrer"&gt;Dapr Dev Dashboard page&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;A Dapr Workflow is long-running and event-sourced. Its state lives in your state store as a history of events, so when an instance stalls or returns the wrong output on your machine, the answer is somewhere in that history. Finding it usually means querying the state store by hand, looking up the right CLI flags, or adding log statements and running the workflow again.&lt;/p&gt;

&lt;p&gt;The dashboard already took care of the basics. It finds the apps you start with &lt;code&gt;dapr run&lt;/code&gt;, .NET Aspire, Docker Compose, or Testcontainers, tails their sidecar and app logs, shows workflow event history, and generates component and resiliency YAML from a form. The recent releases go further into the questions you ask while debugging: what did the workflow write, which instances are stuck, and what do you run to fix them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's new
&lt;/h2&gt;

&lt;h3&gt;
  
  
  A Workflows page that stays fast on large state stores
&lt;/h3&gt;

&lt;p&gt;Run a load test or leave a fan-out workflow running overnight, and your local state store can easily hold thousands of workflow instances. Earlier versions of the Workflows page slowed to a crawl at that point, because every instance load scanned the store for that instance's keys: a full keyspace &lt;code&gt;SCAN&lt;/code&gt; on Redis, a &lt;code&gt;LIKE&lt;/code&gt; table scan on the SQL backends.&lt;/p&gt;

&lt;p&gt;The dashboard now loads instances the way Dapr loads its own state. It reads the instance's metadata record, builds the history keys from it, and fetches them in one bulk read. The status counts on the tabs are cached per workflow and only reload when that workflow changes, while the workflow detail page always reads fresh data. These are the numbers for 2,000 instances, before and after:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Backend&lt;/th&gt;
&lt;th&gt;List page&lt;/th&gt;
&lt;th&gt;Status counts, first load&lt;/th&gt;
&lt;th&gt;Status counts, refresh&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SQLite&lt;/td&gt;
&lt;td&gt;341 ms → 10 ms&lt;/td&gt;
&lt;td&gt;13.7 s → 0.20 s&lt;/td&gt;
&lt;td&gt;15.9 s → 0.03 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis&lt;/td&gt;
&lt;td&gt;702 ms → 39 ms&lt;/td&gt;
&lt;td&gt;28.6 s → 1.28 s&lt;/td&gt;
&lt;td&gt;29.4 s → 0.10 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;386 ms → 18 ms&lt;/td&gt;
&lt;td&gt;14.9 s → 0.17 s&lt;/td&gt;
&lt;td&gt;14.7 s → 0.02 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MongoDB&lt;/td&gt;
&lt;td&gt;200 ms → 7 ms&lt;/td&gt;
&lt;td&gt;8.1 s → 0.28 s&lt;/td&gt;
&lt;td&gt;8.1 s → 0.08 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The page also behaves better while it loads. When you change a status tab, search, or page, the current rows stay on screen with &lt;strong&gt;Updating…&lt;/strong&gt; in the pager until the new results arrive. The tabs show &lt;strong&gt;…&lt;/strong&gt; until their counts are in, instead of a misleading &lt;code&gt;0&lt;/code&gt;.&lt;/p&gt;

&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft9et8badznt1v958zg73.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft9et8badznt1v958zg73.png" alt="Workflows page with status tabs and a large list of instances" width="799" height="495"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Browse and edit state with the new State page
&lt;/h3&gt;

&lt;p&gt;Workflow activities often read and write application state, and when a workflow produces the wrong result, you want to see what is actually in the store. The new &lt;strong&gt;State&lt;/strong&gt; page lists the records in any connected state store, with the key, app prefix, value preview, size, version (etag), and TTL for each one. You can switch between stores from the page header and expand a row to see the full value, with a copy button. The dashboard opens Redis, PostgreSQL, SQLite, and MongoDB stores directly.&lt;/p&gt;

&lt;p&gt;To find a record, filter by app prefix or search by key. If Dapr stored a value base64-encoded, flip &lt;strong&gt;Decode base64&lt;/strong&gt; to read it. Workflow history and actor state share the keyspace with your app's records, so they are hidden by default. Turn on &lt;strong&gt;Show internal keys&lt;/strong&gt; when you want to look at the raw workflow keys.&lt;/p&gt;

&lt;p&gt;You can also change state. &lt;strong&gt;+ New record&lt;/strong&gt; writes a record under &lt;code&gt;&amp;lt;app-id&amp;gt;||&amp;lt;key&amp;gt;&lt;/code&gt;, the same key shape Dapr uses, which is handy for seeding data a workflow reads. An existing key is refused unless you tick &lt;strong&gt;Overwrite&lt;/strong&gt;. You can delete records one at a time or select several, with a confirmation step before anything is removed.&lt;/p&gt;

&lt;p&gt;Two limits are worth knowing. There is no "last modified" column, because Dapr state components do not expose a modification timestamp. And a store the dashboard cannot open directly, such as an in-memory store inside a Testcontainers app, cannot be browsed, since Dapr's state API has no way to list keys.&lt;/p&gt;

&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5b3r3xyyipo9saroxtj0.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5b3r3xyyipo9saroxtj0.png" alt="State page with an expanded record" width="800" height="538"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Get the right Dapr CLI command for every page
&lt;/h3&gt;

&lt;p&gt;The dashboard is great for looking around, but sometimes you want the terminal, for example to put a command in a script or rerun it after a restart. A collapsible &lt;strong&gt;Dapr CLI&lt;/strong&gt; drawer on the right edge of the dashboard now shows the Dapr CLI commands that match the page you are on.&lt;/p&gt;

&lt;p&gt;On the workflow views, that means &lt;code&gt;dapr workflow list&lt;/code&gt;, &lt;code&gt;history&lt;/code&gt;, &lt;code&gt;suspend&lt;/code&gt;, &lt;code&gt;resume&lt;/code&gt;, &lt;code&gt;terminate&lt;/code&gt;, and &lt;code&gt;purge&lt;/code&gt;, with the app ID and workflow instance ID from the current view already filled in. Every command has its own copy button, so you do not have to look up flags or paste IDs by hand.&lt;/p&gt;

&lt;p&gt;Other pages get their own set: &lt;code&gt;dapr list&lt;/code&gt; on the applications overview, &lt;code&gt;dapr stop --app-id &amp;lt;id&amp;gt;&lt;/code&gt; on an app's detail page, &lt;code&gt;dapr scheduler&lt;/code&gt; commands on actors, and &lt;code&gt;dapr publish&lt;/code&gt; on subscriptions. The commands target self-hosted mode, so you will not find the Kubernetes &lt;code&gt;-k&lt;/code&gt; variants here.&lt;/p&gt;

&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1hx912b3hjr1bh4xiy14.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1hx912b3hjr1bh4xiy14.png" alt="Workflow detail page with the Dapr CLI drawer open" width="800" height="548"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Publish test messages from the Subscriptions page
&lt;/h3&gt;

&lt;p&gt;If a message on a topic starts your workflow, testing it used to mean writing a small client or reaching for &lt;code&gt;curl&lt;/code&gt;. Each row on the &lt;strong&gt;Subscriptions&lt;/strong&gt; page now has a &lt;strong&gt;Publish&lt;/strong&gt; button that sends a real message to the topic through the app's own sidecar. You edit the JSON or text payload, choose a content type, and optionally set &lt;code&gt;ttlInSeconds&lt;/code&gt; or &lt;code&gt;rawPayload&lt;/code&gt; under &lt;strong&gt;Advanced&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When the publish succeeds, you get a direct link to the app's logs, so you can watch the message get handled. If daprd rejects it, for example because the pub/sub component is unknown, the error shows up inline in the dialog.&lt;/p&gt;

&lt;p&gt;The page also tells you more about each subscription. A &lt;strong&gt;Type&lt;/strong&gt; column shows whether it is declarative or programmatic, and subscriptions with several routing rules expand to show each rule's match expression and path.&lt;/p&gt;

&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd6nmn3bavf3jrqt5d8mr.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd6nmn3bavf3jrqt5d8mr.png" alt="Publish dialog on the Subscriptions page" width="799" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  See which component secrets resolve
&lt;/h3&gt;

&lt;p&gt;Your workflow state store is a Dapr component, and if its password comes from a secret store, one typo in a &lt;code&gt;secretKeyRef&lt;/code&gt; is enough to break it. Until now, a component with a missing secret looked exactly like a working one. The dashboard now resolves &lt;code&gt;secretKeyRef&lt;/code&gt; values against the &lt;code&gt;secretstores.local.file&lt;/code&gt; and &lt;code&gt;secretstores.local.env&lt;/code&gt; stores the same way daprd does, including nested keys and custom separators. &lt;code&gt;envRef&lt;/code&gt; is supported too.&lt;/p&gt;

&lt;p&gt;A component's detail page has a new &lt;strong&gt;Secret references&lt;/strong&gt; panel. For each field it shows the store, the key, and a status, and when resolution fails, the exact file path it tried. Values stay masked; &lt;strong&gt;Reveal&lt;/strong&gt; shows one field at a time and masks it again after 30 seconds. The Components list marks any component with an unresolved reference with a warning sign. When a state store cannot connect because of a secret, the State page names that secret instead of showing a vague connection error.&lt;/p&gt;

&lt;p&gt;Non-local secret stores, such as HashiCorp Vault or Azure Key Vault, are listed but never read.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cleaner logs and app list during long dev sessions
&lt;/h3&gt;

&lt;p&gt;On the &lt;strong&gt;Logs&lt;/strong&gt; page, choosing what to tail is now a single &lt;strong&gt;Target&lt;/strong&gt; dropdown, grouped into Applications and Control plane. When you view an app, a compact &lt;strong&gt;daprd | app&lt;/strong&gt; toggle lets you show the sidecar logs, the app logs, or both side by side.&lt;/p&gt;

&lt;p&gt;After a few rounds of starting and stopping apps, the Applications list fills up with stopped entries. &lt;strong&gt;Clear inactive&lt;/strong&gt; removes all fully stopped apps in one click, and each stopped row has its own &lt;strong&gt;Remove&lt;/strong&gt; action. Removing an app only hides it from the list; if it starts again, it reappears. &lt;strong&gt;Restart&lt;/strong&gt; and &lt;strong&gt;Start&lt;/strong&gt; now only appear for Docker Compose apps, where they work reliably. For &lt;code&gt;dapr run&lt;/code&gt; apps you get &lt;strong&gt;Stop&lt;/strong&gt;, with a hint to restart from your terminal.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to get started
&lt;/h2&gt;

&lt;p&gt;The dashboard is a single self-contained binary with no runtime dependencies, available for macOS and Linux (amd64 and arm64) and Windows (amd64). Install it with the one-liner for your OS.&lt;/p&gt;

&lt;p&gt;macOS or Linux:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sSL&lt;/span&gt; https://raw.githubusercontent.com/diagridio/dev-dashboard/main/scripts/install.sh | sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Windows (PowerShell):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;iwr&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-useb&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;https://raw.githubusercontent.com/diagridio/dev-dashboard/main/scripts/install.ps1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;iex&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start the dashboard next to your running Dapr apps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;diagrid-dev-dashboard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It serves the UI on &lt;code&gt;http://localhost:9090&lt;/code&gt; and opens your browser. If no Dapr apps are running yet, the Applications page points you to the &lt;a href="https://github.com/dapr/quickstarts" rel="noopener noreferrer"&gt;Dapr Quickstarts&lt;/a&gt; so you have something to try it on. The &lt;a href="https://github.com/diagridio/dev-dashboard" rel="noopener noreferrer"&gt;GitHub repo&lt;/a&gt; has the full README and the release notes for every version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Most of the recent work on the Dapr Dev Dashboard targets the questions you ask while debugging a workflow on your machine: what state did it write, which instances are stuck, and which command gets them moving again. A faster Workflows page, the new State page, and the Dapr CLI drawer answer those questions from your browser. You can also publish a test message from the subscription it targets, and spot a missing secret before it shows up as a vague connection error.&lt;/p&gt;

&lt;p&gt;Install the dashboard with the one-liner for your OS, &lt;code&gt;go install&lt;/code&gt;, or the container image from the &lt;a href="https://github.com/diagridio/dev-dashboard" rel="noopener noreferrer"&gt;GitHub repo&lt;/a&gt;, and run &lt;code&gt;diagrid-dev-dashboard&lt;/code&gt; next to your Dapr apps. For an overview of everything it can do, visit the &lt;a href="https://www.diagrid.io/dev-dashboard" rel="noopener noreferrer"&gt;Dapr Dev Dashboard page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Once the dashboard is open, try the Konami code: type ↑ ↑ ↓ ↓ ← → ← → B A in your browser and see what appears. There is a fresh challenge every day, and you can share your best run with an execution ID so others can watch it back.&lt;/p&gt;

</description>
      <category>dapr</category>
      <category>debugging</category>
      <category>programming</category>
    </item>
    <item>
      <title>The Tiniest Durable Agent I Can Write</title>
      <dc:creator>Marc Duiker</dc:creator>
      <pubDate>Mon, 28 Sep 2026 07:11:56 +0000</pubDate>
      <link>https://dev.to/diagrid/the-tiniest-durable-agent-i-can-write-jd6</link>
      <guid>https://dev.to/diagrid/the-tiniest-durable-agent-i-can-write-jd6</guid>
      <description>&lt;p&gt;"Durable execution" sounds like something you adopt after a six-week platform evaluation. It isn't. Here's an agent that survives being killed, in roughly twenty lines of Python.&lt;/p&gt;

&lt;p&gt;The trick is that durability isn't a feature you code – it's a property of where the agent's loop runs. With Dapr Agents, the loop runs as a durable workflow, so every LLM call and tool call is journaled automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dapr_agents&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;DurableAgent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;

&lt;span class="nd"&gt;@tool&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search_flights&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Search available flights.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;flights_api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@tool&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;book_flight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;flight_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Book a flight. Real money involved.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;flights_api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;book&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;flight_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DurableAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TravelBuddy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Travel planning assistant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Find options first. Confirm before booking.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;search_flights&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;book_flight&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole thing. Now the fun part – try to break it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Ask it to plan a trip. Watch it call search_flights.&lt;/li&gt;
&lt;li&gt;Kill the process while it's mid-task. Not gracefully, kill the entire process.&lt;/li&gt;
&lt;li&gt;Start it again.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The agent picks up the same task where it left off. &lt;br&gt;
The search results it already gathered come back from the workflow's execution history instead of being fetched (and paid for) again. And if the crash happened around book_flight, the journal knows whether that step confirmed – so recovery doesn't book a second seat. LLM calls are slow, costly, and non-deterministic; re-running everything after a crash burns money and can change the answer. Replaying from history does neither.&lt;/p&gt;

&lt;p&gt;Under the hood this is a durable workflow – the same durable execution machinery you'd use for order processing or payment flows, wrapped around an agent loop. No extra database to run: state goes to whatever Dapr state store you configure (Redis locally, Postgres or others in production).&lt;/p&gt;

&lt;p&gt;Honest caveats, as always: the agent's reasoning is only as repeatable as your model, tools with side effects still deserve idempotency keys for the truly paranoid path, and you should read up on workflow versioning before changing an agent that has long-running tasks in flight.&lt;/p&gt;

&lt;p&gt;The longer version of this, with the full runnable sample, lives in &lt;a href="https://www.diagrid.io/blog/the-tiniest-durable-agent" rel="noopener noreferrer"&gt;The Tiniest Durable Agent&lt;/a&gt;. If you want the structured path, the &lt;a href="https://www.diagrid.io/university/dapr-agents" rel="noopener noreferrer"&gt;Dapr Agents track&lt;/a&gt; at Dapr University is free, and the &lt;a href="https://docs.diagrid.io/getting-started/quickstarts/" rel="noopener noreferrer"&gt;quickstarts&lt;/a&gt; get you a running example in a few minutes.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>dapr</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Your LangGraph Agent Survived the Demo. Will It Survive a Deploy?</title>
      <dc:creator>Marc Duiker</dc:creator>
      <pubDate>Fri, 25 Sep 2026 05:54:28 +0000</pubDate>
      <link>https://dev.to/diagrid/your-langgraph-agent-survived-the-demo-will-it-survive-a-deploy-3m9p</link>
      <guid>https://dev.to/diagrid/your-langgraph-agent-survived-the-demo-will-it-survive-a-deploy-3m9p</guid>
      <description>&lt;p&gt;The LangGraph checkpointer is good. I want to say that first, because what follows reads like a complaint and it isn't one. Thread-level persistence, time travel, human-in-the-loop pauses: those work, and they work well.&lt;/p&gt;

&lt;p&gt;There's one failure mode it doesn't cover, and it's the one you hit in production. The process dies after a side effect but before the checkpoint that records it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's the difference between checkpointing and durable execution?
&lt;/h2&gt;

&lt;p&gt;A checkpoint is a snapshot of agent state, written once a step has finished. Durable execution keeps a journal of every step as it runs, and the runtime, not your code, decides what completed.&lt;/p&gt;

&lt;p&gt;Here is where that matters. A graph node calls an external API. It files a Jira ticket, or it charges a card. The call succeeds. Then the pod catches a SIGKILL during a rolling deploy, before the checkpoint write lands.&lt;/p&gt;

&lt;p&gt;A checkpointer restores the last checkpoint it has, which is the one from before the node ran. So the agent runs the node again, and now there are two tickets and two charges.&lt;/p&gt;

&lt;p&gt;Durable execution sees an activity in the journal that started and never confirmed, and it can tell you that's what it is. The steps that did confirm don't run twice. The workflow resumes at the one that didn't.&lt;/p&gt;

&lt;p&gt;That's the part of AI workflow orchestration nobody demos. Drawing the boxes and arrows is easy. The runtime underneath has to answer "what already happened?" at any point, and that is a harder thing to build than it looks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping LangGraph, adding durability
&lt;/h2&gt;

&lt;p&gt;You don't have to pick one. LangGraph stays the brain, and each side-effecting step moves inside a durable activity.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# before: the node does the side effect directly
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_ticket_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ticket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jira&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="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;   &lt;span class="c1"&gt;# unprotected side effect
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ticket_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# after: the node schedules a durable activity
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_ticket_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ticket_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call_activity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;create_ticket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summary&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ticket_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ticket_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same graph, same prompts. The difference is that every step now gets journaled, retried with backoff, and resumed after a crash rather than replayed from scratch. That applies to your LLM calls too, so a crash doesn't re-spend the tokens you already burned on steps one through six.&lt;/p&gt;

&lt;p&gt;Caveat where it's due, because durable execution asks for things too. Your workflow code has to be deterministic, which rules out random branching outside activities. And you need to think about versioning before you change a workflow that has instances in flight. So there's a cost. I'd pay it the moment an agent starts touching anything with an invoice attached.&lt;/p&gt;

&lt;p&gt;We've written up running &lt;a href="https://www.diagrid.io/solutions/langgraph-production" rel="noopener noreferrer"&gt;LangGraph in production with durable execution&lt;/a&gt; at more length, and there's a &lt;a href="https://www.diagrid.io/blog/checkpoints-are-not-durable-execution-why-langgraph-crewai-google-adk-and-others-fall-short-for-production-agent-workflows" rel="noopener noreferrer"&gt;longer argument for why checkpoints aren't durable execution&lt;/a&gt; if you want to know more of the details.&lt;/p&gt;

</description>
      <category>langgraph</category>
      <category>ai</category>
      <category>python</category>
      <category>devops</category>
    </item>
    <item>
      <title>Run the Test You'd Rather Skip: Kill Your Agent Mid-Payment</title>
      <dc:creator>Marc Duiker</dc:creator>
      <pubDate>Mon, 21 Sep 2026 08:37:29 +0000</pubDate>
      <link>https://dev.to/diagrid/run-the-test-youd-rather-skip-kill-your-agent-mid-payment-30h5</link>
      <guid>https://dev.to/diagrid/run-the-test-youd-rather-skip-kill-your-agent-mid-payment-30h5</guid>
      <description>&lt;p&gt;Your agent calls a payment API. The process dies right after the charge succeeds and before anything gets written down. What happens when it restarts?&lt;/p&gt;

&lt;p&gt;If the answer is "it retries from the top," you have a problem. "Retry the tool call" sounds harmless until the tool issues a refund, or a charge. Then you have billed a customer twice, and no amount of prompt engineering will explain that to them.&lt;/p&gt;

&lt;p&gt;Most agent code handles the happy path and the visible error path, the one where the API returns a 500 and you try again. The path nobody tests is the invisible one, where the side effect succeeded and then the process disappeared before recording it. Deployments cause this. So do OOM kills, and spot instances do it on a schedule.&lt;/p&gt;

&lt;h2&gt;
  
  
  What durable execution does
&lt;/h2&gt;

&lt;p&gt;Durable execution records every completed step in a durable log as the workflow runs. When the process comes back, the runtime replays the log: steps that already completed return their recorded results instantly, and execution resumes at the first step that never finished. Your charge happens once, no matter how many times the process dies.&lt;/p&gt;

&lt;p&gt;With Dapr Workflow in Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dapr.ext.workflow&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;wf&lt;/span&gt;

&lt;span class="n"&gt;wfr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;wf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;WorkflowRuntime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nd"&gt;@wfr.activity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;charge_card&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;charge_card&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;wf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WorkflowActivityContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# the side effect lives in an activity
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="nd"&gt;@wfr.activity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;send_receipt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send_receipt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;wf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WorkflowActivityContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;mailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;receipt_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="nd"&gt;@wfr.workflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_flow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;order_flow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;wf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DaprWorkflowContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;charge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call_activity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;charge_card&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;receipt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call_activity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;send_receipt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;receipt&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;wfr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;wf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DaprWorkflowClient&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;instance_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;schedule_new_workflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;order_flow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4200&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_for_workflow_completion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;instance_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;wfr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shutdown&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now run the test you would rather skip:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start the workflow.&lt;/li&gt;
&lt;li&gt;Kill the process the moment &lt;code&gt;charge_card&lt;/code&gt; completes. It has to be an unclean kill, so the app gets no chance to shut down gracefully: &lt;code&gt;kill -9 &amp;lt;pid&amp;gt;&lt;/code&gt; on macOS or Linux, &lt;code&gt;Stop-Process -Id &amp;lt;pid&amp;gt; -Force&lt;/code&gt; in PowerShell on Windows, or &lt;code&gt;docker kill &amp;lt;container&amp;gt;&lt;/code&gt; if you are running it in a container.&lt;/li&gt;
&lt;li&gt;Restart with only &lt;code&gt;wfr.start()&lt;/code&gt; running, and watch the workflow resume at &lt;code&gt;send_receipt&lt;/code&gt;, with the charge result restored from history instead of re-executed.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Durable execution needs idempotent tools
&lt;/h2&gt;

&lt;p&gt;One caveat, and it is the reason step 2 is interesting. The replay only skips a step once that step's completion has been written down. If the process dies in the gap between the charge returning and the completion being recorded, the activity runs again on recovery. So if your payment provider cannot tell two identical requests apart, pass an idempotency key too. Idempotency means a request can be repeated safely without repeating its effect, and that is the half durable execution cannot do for you.&lt;/p&gt;

&lt;p&gt;If you want to go deeper, read this post about &lt;a href="https://www.diagrid.io/blog/the-tiniest-durable-agent" rel="noopener noreferrer"&gt;The Tiniest Durable Agent&lt;/a&gt;, and there is a free, self-paced &lt;a href="https://www.diagrid.io/university/dapr-workflow" rel="noopener noreferrer"&gt;Dapr Workflow course at Dapr University&lt;/a&gt; that covers chaining, fan-out/fan-in, durable timers, and human-in-the-loop in depth.&lt;/p&gt;

&lt;p&gt;For a developer the benefit is code you don't have to write. The workflow above reads top to bottom like ordinary Python, and the state machine, checkpoint table and "did I already charge this card?" column that would normally sit behind it are abstracted away, because the runtime manages that state instead. Recovery stops being something you design per tool call. When a run does go wrong, the history shows which step executed and what it returned, so you debug from a record rather than a guess.&lt;/p&gt;

&lt;p&gt;So: fail the action at the worst possible moment, then check whether your system recovered and whether you can prove what it did. It takes about ten minutes, and you would rather learn the answer now than from the customer you charged twice.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>dapr</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>10 LangGraph Alternatives for Building Agentic Workflows in 2026</title>
      <dc:creator>Marc Duiker</dc:creator>
      <pubDate>Sat, 08 Aug 2026 11:00:00 +0000</pubDate>
      <link>https://dev.to/diagrid/10-langgraph-alternatives-for-building-agentic-workflows-in-2026-261k</link>
      <guid>https://dev.to/diagrid/10-langgraph-alternatives-for-building-agentic-workflows-in-2026-261k</guid>
      <description>&lt;p&gt;&lt;a href="https://www.langchain.com/langgraph" rel="noopener noreferrer"&gt;LangGraph&lt;/a&gt; is a good answer when your agent naturally looks like a graph. It gets harder to defend when your team does not want that graph to become the shape of the whole application.&lt;/p&gt;

&lt;p&gt;None of the alternatives below are LangGraph clones. Each one asks you to think about agents differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want a smaller abstraction
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The &lt;a href="https://developers.openai.com/api/docs/guides/agents" rel="noopener noreferrer"&gt;OpenAI Agents SDK&lt;/a&gt; gives you agents, tools, handoffs, guardrails, sessions, and tracing, without asking you to draw an explicit graph for every application.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pydantic.dev/docs/ai/overview/" rel="noopener noreferrer"&gt;Pydantic AI&lt;/a&gt; is worth a look if you write typed Python services. You get structured outputs, validation, and dependency injection.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://strandsagents.com/" rel="noopener noreferrer"&gt;Strands Agents&lt;/a&gt; runs a model-driven loop with tools and keeps the developer experience short.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  If you want multi-agent concepts first
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;a href="https://crewai.com/" rel="noopener noreferrer"&gt;CrewAI&lt;/a&gt; models role-based agents, tasks, and crews. When collaboration is the main thing you are describing, that can read better than wiring nodes together.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://learn.microsoft.com/en-us/agent-framework/overview/?pivots=programming-language-csharp" rel="noopener noreferrer"&gt;Microsoft Agent Framework&lt;/a&gt; deserves a place on the shortlist if your team is Microsoft-oriented and you are building agents and multi-agent workflows across .NET and Python.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://adk.dev/" rel="noopener noreferrer"&gt;Google ADK&lt;/a&gt; supports modular agents, tools, and composition, and it lines up with Google's models and cloud.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  If you want distributed-systems primitives
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;a href="https://docs.dapr.io/developing-ai/dapr-agents/" rel="noopener noreferrer"&gt;Dapr Agents&lt;/a&gt; puts agent development on top of Dapr's state, messaging, service invocation, and workflow foundation.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://restate.dev/" rel="noopener noreferrer"&gt;Restate&lt;/a&gt; is not only an agent framework. It is a durable application runtime that can wrap your agent logic and your SDKs. Look at it when resilient execution matters more to you than taking on another high-level agent abstraction.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  If you want long-horizon or provider-specific tooling
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;a href="https://docs.langchain.com/oss/python/deepagents/overview" rel="noopener noreferrer"&gt;Deep Agents&lt;/a&gt; aims at longer and more complex work through planning, sub-agents, and context-management patterns.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://code.claude.com/docs/en/agent-sdk/overview" rel="noopener noreferrer"&gt;The Claude Agent SDK&lt;/a&gt; fits teams building tool-rich agents around Claude's ecosystem and its coding-agent patterns.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Framework choice is not runtime choice
&lt;/h2&gt;

&lt;p&gt;Some of these change how you author the agent. Others change how the agent survives failure. The two decisions are related, but they are not the same decision, and it is easy to shop for one while thinking you are solving the other.&lt;/p&gt;

&lt;p&gt;If you like LangGraph's programming model and what you actually need is production durability, you may not have to replace LangGraph at all. &lt;a href="https://www.diagrid.io/catalyst" rel="noopener noreferrer"&gt;Diagrid Catalyst&lt;/a&gt;, for example, ships a LangGraph runner as part of its bring-your-own-framework approach, and other durable runtimes integrate with agent SDKs too.&lt;/p&gt;

&lt;p&gt;My short version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;stay on LangGraph when explicit state and graph control are the strengths you want&lt;/li&gt;
&lt;li&gt;pick CrewAI when roles and collaboration match the problem&lt;/li&gt;
&lt;li&gt;pick the OpenAI Agents SDK or Pydantic AI when you want fewer orchestration concepts&lt;/li&gt;
&lt;li&gt;pick an ecosystem-native framework when organizational alignment matters&lt;/li&gt;
&lt;li&gt;add a durable execution layer when the real problem is recovery rather than authoring&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pick the one whose mental model your team can still explain a few months after the demo has become a production service. That test tells you more than how new the framework is.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>langchain</category>
    </item>
    <item>
      <title>Inngest or Diagrid Catalyst: From a TypeScript AI Workflow to an Enterprise Agent Platform</title>
      <dc:creator>Marc Duiker</dc:creator>
      <pubDate>Fri, 07 Aug 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/diagrid/inngest-or-diagrid-catalyst-from-a-typescript-ai-workflow-to-an-enterprise-agent-platform-54p0</link>
      <guid>https://dev.to/diagrid/inngest-or-diagrid-catalyst-from-a-typescript-ai-workflow-to-an-enterprise-agent-platform-54p0</guid>
      <description>&lt;p&gt;A five-person team usually picks the infrastructure it never has to think about, and that is the right call. It stops being the right call on the day the first enterprise customer asks where the agent runs and who is allowed to authorize its tools.&lt;/p&gt;

&lt;p&gt;At prototype stage, Inngest is easy to understand. You write functions in TypeScript, Python, or Go, split durable work into steps, and let the platform deal with state, retries, flow control, and observability. Its current durable-agent model uses step-level memoization, so completed LLM calls and tool invocations are not repeated during a replay. It can also wait for events and invoke sub-agents.&lt;/p&gt;

&lt;p&gt;That suits a web team stretching background jobs into longer AI workflows. The agent stays next to the application code, and there is not much infrastructure to run.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the decision changes
&lt;/h2&gt;

&lt;p&gt;The architecture conversation usually shifts in stages.&lt;/p&gt;

&lt;p&gt;First you ship the feature. A TypeScript workflow calls an LLM, enriches some data, and writes a result. Developer experience decides the winner.&lt;/p&gt;

&lt;p&gt;Then you have to survive failure. Tool calls need to resume at the right step. Inngest and Catalyst both handle that, through different execution models.&lt;/p&gt;

&lt;p&gt;Then a second and a third agent stack show up. A Python team picks LangGraph, a .NET team picks Microsoft Agent Framework, and another group uses Google ADK. Catalyst's bring-your-own-framework model starts to matter here, because the platform standard can sit below those framework choices instead of competing with them.&lt;/p&gt;

&lt;p&gt;Then you go through enterprise review. Customers ask for workload identity, deny-by-default access, private deployment, MCP governance, and evidence of what each agent did. That is where Catalyst's wider governance model can justify running a larger platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would compare
&lt;/h2&gt;

&lt;p&gt;Retries and dashboards are the least interesting part of this comparison. I would look at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;which languages and agent frameworks are supported&lt;/li&gt;
&lt;li&gt;what happens to completed steps and side effects during recovery&lt;/li&gt;
&lt;li&gt;who operates the workers, the state, and the execution plane&lt;/li&gt;
&lt;li&gt;how agent-to-tool access is authenticated and authorized&lt;/li&gt;
&lt;li&gt;which self-hosted, private, or air-gapped topologies exist&lt;/li&gt;
&lt;li&gt;how several teams share one platform without sharing permissions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Inngest can stay the best choice for developer-first durable functions, background jobs, and AI workflows, as long as its language and deployment model fit the team. Catalyst fits better once the requirement has grown from "make this function durable" into "run and govern many kinds of agents across the enterprise."&lt;/p&gt;

&lt;p&gt;Both mistakes are common, and they are the same mistake pointed in opposite directions: buying the stage-four platform while you are still at stage one, and refusing to reopen a stage-one decision after the organization around it has changed. Let the architecture follow the risk you are actually carrying.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?utm_source=dev-to&amp;amp;utm_medium=article" rel="noopener noreferrer"&gt;Diagrid Catalyst Quickstarts&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.diagrid.io/concepts/ai-agents/?utm_source=dev-to&amp;amp;utm_medium=article" rel="noopener noreferrer"&gt;Diagrid agent docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.inngest.com/docs/learn/durable-agents" rel="noopener noreferrer"&gt;Inngest durable agents&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>agents</category>
    </item>
    <item>
      <title>Restate or Diagrid Catalyst for Durable Tool Calls?</title>
      <dc:creator>Marc Duiker</dc:creator>
      <pubDate>Thu, 06 Aug 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/diagrid/restate-or-diagrid-catalyst-for-durable-tool-calls-36b8</link>
      <guid>https://dev.to/diagrid/restate-or-diagrid-catalyst-for-durable-tool-calls-36b8</guid>
      <description>&lt;p&gt;"Retry the tool call" sounds harmless until the tool issues a refund. Then durability, identity, and evidence stop being three concerns owned by three different teams and turn into one incident.&lt;/p&gt;

&lt;p&gt;Take a support system built out of several agents. A triage agent classifies the ticket. An account agent retrieves customer state. A policy agent decides whether a refund is allowed. A human approves the recommendation. An action agent calls the refund tool. Then the process loses its connection before the response is recorded.&lt;/p&gt;

&lt;p&gt;Retrying is the easy part. What matters is whether the platform and your own code can work out what already happened, without sending a second refund.&lt;/p&gt;

&lt;h2&gt;
  
  
  Restate: durable code and journaled steps
&lt;/h2&gt;

&lt;p&gt;Restate is a runtime that sits in front of your services and records execution in a journal. Its AI documentation shows LLM calls and tool executions wrapped as durable steps. After a crash, completed results are replayed from the journal and execution continues from the first step that never finished. Restate supports services, virtual objects, and workflows, and runs through Restate Cloud or a server you host yourself.&lt;/p&gt;

&lt;p&gt;That is a good model if you want resilient functions and services with built-in state, reliable communication, and durable execution written into ordinary code. It also integrates with agent SDKs rather than asking you to adopt one agent framework.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catalyst: durable agents plus a shared trust layer
&lt;/h2&gt;

&lt;p&gt;Catalyst also persists workflow activity results and resumes failed executions. Around that it adds App ID-based workload identity, access policies, mTLS-backed communication, MCP governance, and a single operational surface for agents and applications. Diagrid positions verifiable execution, meaning history, lineage, signing, and attestation, as an additional trust layer.&lt;/p&gt;

&lt;p&gt;Once money is involved, a security review will want more than proof that the run recovered:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which workload identity invoked the refund tool?&lt;/li&gt;
&lt;li&gt;Which policy authorized the call?&lt;/li&gt;
&lt;li&gt;Can you show that the execution history was not altered?&lt;/li&gt;
&lt;li&gt;Can you revoke access without editing the agent prompt?&lt;/li&gt;
&lt;li&gt;Does the same control apply across cloud, on-premises, and isolated deployments?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where each one fits
&lt;/h2&gt;

&lt;p&gt;Restate is the better fit when what you need is a lightweight durable runtime for services, stateful entities, workflows, and agents, and you want to express resilience directly in normal application code.&lt;/p&gt;

&lt;p&gt;Catalyst is the better fit when durable execution has to arrive together with organization-wide agent identity, policy-controlled tool access, verifiable evidence, and governance across several frameworks and deployment environments.&lt;/p&gt;

&lt;p&gt;Neither one takes your application off the hook once the outcome is unknown. A payment or refund integration still needs idempotency keys, reconciliation, and explicit compensation logic. Durable execution preserves progress. It cannot tell you whether an external system committed a request when the response was lost.&lt;/p&gt;

&lt;p&gt;So run the test you would rather skip. Fail the action at the worst possible moment, then check whether it recovered, whether you can prove who did what, and whether the same refund can go out twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.restate.dev/ai/patterns/durable-agents" rel="noopener noreferrer"&gt;Restate durable agents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.restate.dev/use-cases/ai-agents" rel="noopener noreferrer"&gt;Restate AI agents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.diagrid.io/catalyst?utm_source=dev-to&amp;amp;utm_medium=article" rel="noopener noreferrer"&gt;Diagrid Catalyst&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.diagrid.io/concepts/ai-agents?utm_source=dev-to&amp;amp;utm_medium=article" rel="noopener noreferrer"&gt;Diagrid AI agents&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>agents</category>
    </item>
    <item>
      <title>Kestra or Diagrid Catalyst for AI Workflow Orchestration?</title>
      <dc:creator>Marc Duiker</dc:creator>
      <pubDate>Wed, 05 Aug 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/diagrid/kestra-or-diagrid-catalyst-for-ai-workflow-orchestration-nen</link>
      <guid>https://dev.to/diagrid/kestra-or-diagrid-catalyst-for-ai-workflow-orchestration-nen</guid>
      <description>&lt;p&gt;"One orchestrator for everything" sounds like a clean platform strategy. It also packs three different execution problems into one word. A data pipeline, an infrastructure rollout, and an AI agent all have steps in them. That does not mean they want the same authoring model, the same recovery semantics, or the same governance boundary.&lt;/p&gt;

&lt;p&gt;That gap is what to look at when you compare &lt;a href="https://kestra.io/" rel="noopener noreferrer"&gt;Kestra&lt;/a&gt; and &lt;a href="https://www.diagrid.io/?utm_source=dev-to&amp;amp;utm_medium=article" rel="noopener noreferrer"&gt;Diagrid Catalyst&lt;/a&gt;. Kestra is a declarative orchestration platform that covers data, infrastructure, business, and AI workflows. Catalyst is a durable execution and governance platform for applications, workflows, agents, and MCP servers, built on Dapr. They overlap, and they start from different places.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the shape of the workload
&lt;/h2&gt;

&lt;p&gt;For scheduled ETL, infrastructure automation, or a business flow that crosses several tools, a declarative control plane is an advantage rather than a tax. Kestra defines workflows in YAML, has a large plugin ecosystem, and manages flows through a UI and Git. Its documentation now also covers AI workflows and AI Agent tasks that can use memory and tools and pick actions at runtime.&lt;/p&gt;

&lt;p&gt;The calculation changes when the agent already exists. If it is written in LangGraph, CrewAI, Microsoft Agent Framework, Google ADK, or another supported framework, then rewriting it as a platform-specific flow is a rewrite nobody asked for. Catalyst's pitch is that you keep the framework and wrap durable workflow execution, identity, policy, and observability around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Does it support AI" is the wrong row in the table
&lt;/h2&gt;

&lt;p&gt;Both products support AI use cases, so the answer is yes twice so this question does not bring you closer to the perfect product fit. These questions separate them better:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is the agent authored inside the orchestration platform, or does it arrive from an external framework?&lt;/li&gt;
&lt;li&gt;What is the durable unit: a task, a workflow activity, an agent step, or a tool call?&lt;/li&gt;
&lt;li&gt;How does a dynamic decision get reconstructed after a failure?&lt;/li&gt;
&lt;li&gt;How are agent-to-tool permissions enforced?&lt;/li&gt;
&lt;li&gt;Who runs the control plane, the data plane, the workers, and the state stores?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those answers shape the developer experience and your Day-2 operations far more than an AI checkbox does.&lt;/p&gt;

&lt;h2&gt;
  
  
  One platform strategy, more than one abstraction
&lt;/h2&gt;

&lt;p&gt;Kestra is the better fit when teams want declarative orchestration across data, scripts, APIs, and infrastructure, with a central catalog of flows and plugins to look at.&lt;/p&gt;

&lt;p&gt;Catalyst is the better fit when the agent frameworks in use are a deliberate developer choice, and the platform team wants durability, identity, and governance to come from one layer without replacing those frameworks.&lt;/p&gt;

&lt;p&gt;Running both is a legitimate answer. A general-purpose orchestrator can trigger a durable agent as a bounded service, or a Catalyst workflow can call an external data pipeline. Draw that boundary where ownership and failure semantics put it, and ignore where a vendor would prefer it.&lt;/p&gt;

&lt;p&gt;The feature table does not decide this. Ask which execution model makes each of your workloads easier to build, recover, secure, and explain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.diagrid.io/concepts/ai-agents/?utm_source=dev-to&amp;amp;utm_medium=article" rel="noopener noreferrer"&gt;Diagrid AI agents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kestra.io/docs/ai-tools/ai-agents" rel="noopener noreferrer"&gt;Kestra AI agents&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>agents</category>
    </item>
    <item>
      <title>Orkes Conductor or Diagrid Catalyst for Multi-Agent Workflows?</title>
      <dc:creator>Marc Duiker</dc:creator>
      <pubDate>Tue, 04 Aug 2026 12:25:58 +0000</pubDate>
      <link>https://dev.to/diagrid/orkes-conductor-or-diagrid-catalyst-for-multi-agent-workflows-1gjc</link>
      <guid>https://dev.to/diagrid/orkes-conductor-or-diagrid-catalyst-for-multi-agent-workflows-1gjc</guid>
      <description>&lt;p&gt;A multi-agent support flow is still a distributed system. You can make it sound less scary by giving the components friendlier names but the timeouts and the duplicate calls remain part of the system, and so does the question of who owned the handoff that failed.&lt;/p&gt;

&lt;p&gt;For example; A triage agent reads a ticket, an account agent fetches customer context, a policy agent checks entitlements, and an action agent calls the order service. Somewhere between the policy decision and the refund API, a worker dies.&lt;/p&gt;

&lt;p&gt;At 2 a.m. I care about a couple of things: which step completed, whether the refund can safely repeat, who owned the failed handoff, what an operator can do next, and whether the agent was allowed to make that call at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Orkes Conductor's operating model
&lt;/h2&gt;

&lt;p&gt;Orkes Conductor coordinates tasks and workers through workflow definitions stored as JSON, and you can author those definitions in code, through the API, or in a visual builder. The documentation covers static and dynamic workflows, human tasks, event-driven flows, AI tasks, agentic orchestration, and MCP exposure. Orkes also positions Conductor as a platform where existing agents and workflows can invoke one another.&lt;/p&gt;

&lt;p&gt;If your platform is already organized around microservice workers and explicit task definitions, that model makes sense. The workflow is a visible coordinator, workers can stay polyglot, and retries and state are handled centrally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catalyst's operating model
&lt;/h2&gt;

&lt;p&gt;Catalyst builds on Dapr and treats workflows, agents, MCP servers, and services as workloads on one execution and governance plane. Agents can stay in the frameworks they already run in, while durable runners turn reasoning and tool operations into workflow activities. App identities and policies control which workloads may call other applications or MCP servers.&lt;/p&gt;

&lt;p&gt;The interesting part is not the diagram. It is what ends up inside a single runtime boundary: recovery, communication, identity, policy, and operational inspection work the same way for agent and non-agent components.&lt;/p&gt;

&lt;h2&gt;
  
  
  Walk the failure, not the feature list
&lt;/h2&gt;

&lt;p&gt;Run the same scenario on both platforms.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Complete triage and account lookup.&lt;/li&gt;
&lt;li&gt;Let the policy agent approve a refund.&lt;/li&gt;
&lt;li&gt;Kill the action worker during the refund call.&lt;/li&gt;
&lt;li&gt;Restore service and watch what recovers.&lt;/li&gt;
&lt;li&gt;Inspect the execution record and the authorization path.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then ask whether completed steps replay or re-execute, how the refund call is made idempotent, how an operator retries or terminates the run, and how credentials are scoped. A product page that says "durable" will not answer any of that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where each may fit
&lt;/h2&gt;

&lt;p&gt;Orkes is a strong candidate if you want Conductor's task-and-worker model stretched across microservices, APIs, humans, and AI, with workflows managed visually and as JSON.&lt;/p&gt;

&lt;p&gt;Catalyst is worth evaluating if you already run Dapr, or several agent frameworks, and you want workload identity, policy-controlled communication, durable execution, and deployment governance to come from one platform.&lt;/p&gt;

&lt;p&gt;Do not pick on the number of built-in tasks. Pick on the failure contract and the operating contract, because what saves you at 2 a.m. is an on-call engineer who can repair the run without guessing which agent did what.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.diagrid.io/concepts/architecture/?utm_source=dev-to&amp;amp;utm_medium=article" rel="noopener noreferrer"&gt;Diagrid Catalyst architecture&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.diagrid.io/concepts/ai-agents/?utm_source=dev-to&amp;amp;utm_medium=article" rel="noopener noreferrer"&gt;Diagrid Catalyst agents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://orkes.io/content/developer-guides/workflows" rel="noopener noreferrer"&gt;Orkes workflow overview&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://orkes.io/content/ai-orchestration" rel="noopener noreferrer"&gt;Orkes AI orchestration&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>agents</category>
    </item>
    <item>
      <title>Camunda or Diagrid Catalyst for AI Workflows With Human Approval?</title>
      <dc:creator>Marc Duiker</dc:creator>
      <pubDate>Mon, 03 Aug 2026 12:42:17 +0000</pubDate>
      <link>https://dev.to/diagrid/camunda-or-diagrid-catalyst-for-ai-workflows-with-human-approval-36j6</link>
      <guid>https://dev.to/diagrid/camunda-or-diagrid-catalyst-for-ai-workflows-with-human-approval-36j6</guid>
      <description>&lt;p&gt;In a regulated workflow, "the agent made a reasonable decision" does not count as an audit trail. Someone still has to show who authorized the action, which controls applied, and where a human could have stepped in.&lt;/p&gt;

&lt;p&gt;So Camunda and Diagrid Catalyst are not two versions of the same workflow product, and comparing them that way leads to the wrong choice. Camunda starts from end-to-end process orchestration and BPMN. Catalyst starts from durable, governed execution for applications, workflows, agents, and MCP servers. Both address AI now, but they enter the problem at different control boundaries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Different approaches
&lt;/h2&gt;

&lt;p&gt;Take an insurance claim. Deterministic steps validate coverage and fetch policy data. An AI agent summarizes the evidence and flags anomalies. A human approves or rejects the recommendation. Then a payment service does something that changes state.&lt;/p&gt;

&lt;p&gt;Camunda is strong at making that whole process explicit. Its agentic orchestration documentation describes AI agents inside BPMN workflows, next to human tasks, deterministic rules, and AI-driven decisions. That is worth a lot when business and technology teams need one process model they both work from.&lt;/p&gt;

&lt;p&gt;Catalyst treats agents as durable workflows and puts the weight on recovery, identity, policy, and observability in the execution layer. That fits better when the AI part is not one boxed task but an agent that may call tools, delegate to other agents, or run in a different environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four questions that decide the fit
&lt;/h2&gt;

&lt;p&gt;Who owns the process model? If business analysts and process owners have to see and change an end-to-end BPMN model, Camunda has the natural advantage. If developers own the agent code in frameworks they already use, a runtime that preserves those frameworks probably fits better.&lt;/p&gt;

&lt;p&gt;Where does the non-determinism live? A bounded AI task inside a stable process is a different design problem from an agent that chooses its next step at runtime. Settle that question before you pick the orchestration layer.&lt;/p&gt;

&lt;p&gt;What do you have to prove? An audit may want more than a diagram and some logs. It may want execution history, identity, the authorization decisions that were made, lineage, and evidence that the records were not altered afterwards. Diagrid's verifiable execution positioning is worth checking against the specific compliance requirement in front of you rather than accepting it as a generic checkbox.&lt;/p&gt;

&lt;p&gt;Where does it have to run? Camunda and Catalyst both offer deployment choices, but the real constraint is which topology your organization has already approved: SaaS, your own infrastructure, private connectivity, or a fully isolated environment. Confirm the exact edition and who is responsible for what.&lt;/p&gt;

&lt;h2&gt;
  
  
  You may not have to pick one
&lt;/h2&gt;

&lt;p&gt;An enterprise does not always need a single product to own every layer. Camunda can coordinate the formal business process while a durable agent runtime executes a complicated AI task behind a service boundary. It works the other way too. Catalyst workflows can model human approval without bringing BPMN into it, when the process is mostly owned by developers.&lt;/p&gt;

&lt;p&gt;Choose Camunda when BPMN, human work, and process transparency are the center of the design. Evaluate Catalyst when agent durability, framework choice, workload identity, tool policy, and governance across environments are what you spend your time on.&lt;/p&gt;

&lt;p&gt;Whichever you pick, the useful test is whether the control boundary is obvious afterwards: an auditor can find the evidence, an operator knows what can be resumed, a developer knows where side effects belong, and everyone knows the point at which the machine is allowed to act on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.camunda.io/docs/components/agentic-orchestration/agentic-orchestration-overview/" rel="noopener noreferrer"&gt;Camunda agentic orchestration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.diagrid.io/concepts/ai-agents/" rel="noopener noreferrer"&gt;Diagrid AI agents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.diagrid.io/operate/" rel="noopener noreferrer"&gt;Operating Catalyst&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>agents</category>
    </item>
  </channel>
</rss>
