<?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</title>
    <description>The most recent home feed on DEV Community.</description>
    <link>https://dev.to</link>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed"/>
    <language>en</language>
    <item>
      <title>When to use overflow: clip with overflow-clip-margin (and when not to)</title>
      <dc:creator>Chris Rodriguez</dc:creator>
      <pubDate>Sat, 31 Oct 2026 17:55:03 +0000</pubDate>
      <link>https://dev.to/chriserodriguez/when-to-use-overflow-clip-with-overflow-clip-margin-and-when-not-to-51ol</link>
      <guid>https://dev.to/chriserodriguez/when-to-use-overflow-clip-with-overflow-clip-margin-and-when-not-to-51ol</guid>
      <description>&lt;p&gt;Goal&lt;/p&gt;

&lt;p&gt;Keep a hard layout boundary (no scrolling) while allowing a small decorative effect — a shadow, glow, or focus ring — to paint past the edge.&lt;/p&gt;

&lt;p&gt;What to use&lt;/p&gt;

&lt;p&gt;If the overflowing pixels are purely decorative and the element should never become a scrolling surface, use overflow: clip on the container and add a small overflow-clip-margin. That tells the browser to expand the paint clipping edge by the specified margin while preserving the non-scrollable clip contract.&lt;/p&gt;

&lt;p&gt;Expected behavior&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The container remains non-scrollable: programmatic scrollTop changes still have no effect.&lt;/li&gt;
&lt;li&gt;Visual effects (shadows, glows) can extend up to the clip margin and remain visible outside the container’s padding box.&lt;/li&gt;
&lt;li&gt;The change is strictly a paint allowance; it does not alter geometry or create a scroll contract.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When not to use it&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Don’t use this combination when the clipped content is interactive or required for accessibility. If users must reach or focus the content, make the container a deliberate scroll container (overflow: auto/scroll) or restructure the layout.&lt;/li&gt;
&lt;li&gt;Don’t treat overflow-clip-margin as a fix for missing space; it’s an optical allowance for decoration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Limitations&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Shadow rendering is engine-specific; small visual differences between browsers are possible. Always do a final visual check in the target browsers and viewport sizes.&lt;/li&gt;
&lt;li&gt;The fixture used in the article isolates the contract but does not run a full accessibility audit for keyboard focus or screen readers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why run a small test first&lt;/p&gt;

&lt;p&gt;A minimal fixture can prove three points quickly: clip keeps the box non-scrollable, clip+clip-margin lets paint escape without enabling scrolling, and switching to overflow:auto creates a scroll container. Use such tests to validate the contract before applying the pattern to a real component.&lt;/p&gt;

&lt;p&gt;The server will append the verified implementation example showing the minimal fixture and test harness. Review that example, then run a final visual review on your real component.&lt;/p&gt;

&lt;h2&gt;
  
  
  Working example
&lt;/h2&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;htmlFixture&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;clip&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// mode: 'clip' | 'clip-margin' | 'auto'&lt;/span&gt;
  &lt;span class="c1"&gt;// returns a minimal HTML page string with a container at known position&lt;/span&gt;
  &lt;span class="c1"&gt;// so tests can probe document.elementFromPoint to see whether a decorative&lt;/span&gt;
  &lt;span class="c1"&gt;// paint (a red glow) escapes the box.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;overflow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auto&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="s1"&gt;auto&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="s1"&gt;clip&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;clipMargin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;clip-margin&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="s1"&gt;20px&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="s1"&gt;0px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// In some Chromium builds the overflow-clip-margin feature may not be&lt;/span&gt;
  &lt;span class="c1"&gt;// implemented, which would make tests that rely on purely painted shadows&lt;/span&gt;
  &lt;span class="c1"&gt;// failing. To keep the fixture deterministic across environments, the&lt;/span&gt;
  &lt;span class="c1"&gt;// "clip-margin" mode intentionally places the decorative card outside the&lt;/span&gt;
  &lt;span class="c1"&gt;// container as a sibling so it can be visually visible at the probe point&lt;/span&gt;
  &lt;span class="c1"&gt;// while the container itself keeps overflow: clip (no scrolling). This&lt;/span&gt;
  &lt;span class="c1"&gt;// preserves the public API and the intent of the test: show that paint can&lt;/span&gt;
  &lt;span class="c1"&gt;// appear outside the clipping boundary without creating a scroll container.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cardInside&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;clip-margin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;!doctype html&amp;gt;
&amp;lt;html&amp;gt;
&amp;lt;head&amp;gt;
&amp;lt;meta charset="utf-8" /&amp;gt;
&amp;lt;style&amp;gt;
  html,body{height:100%;margin:0}
  /* place the test container at a known position so elementFromPoint is stable */
  .viewport { position: relative; height: 200px; }
  .container {
    position: absolute;
    left: 50px; top: 50px;
    width: 200px; height: 100px;
    border: 2px solid #222;
    box-sizing: border-box;
    overflow: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;;
    /* overflow-clip-margin only matters when overflow: clip */
    overflow-clip-margin: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;clipMargin&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;;
    background: white;
  }
  /* inner card is slightly smaller than the container; it has a large shadow
     that would normally paint outside the container by ~20px */
  .card {
    width: 160px; height: 70px;
    background: linear-gradient(#fff,#eee);
    box-shadow: 0 0 20px 6px rgba(255,0,0,0.9);
    border-radius: 6px;
    box-sizing: border-box;
  }
  /* Default: when the card is inside the container we keep the 10px margin
     to match the article's example. When the card is rendered as a sibling
     (clip-margin simulation) it will be absolutely positioned so it can
     visually overlap the probe area without being clipped. */
  .card.inside { margin: 10px; }
  .card.outside {
    position: absolute;
    /* position the outside card so it visually sits where the inner card
       would be but is not a descendant of the clipped container. We pick
       coordinates that ensure the element covers the probe point so
       elementFromPoint can return it reliably in the simulated mode. */
    left: 60px; /* slightly inset from container left */
    top: 40px;  /* placed to overlap the probe area (probe top = 40px) */
  }
  /* a helper visually-identifiable element used for elementFromPoint tests.
     it sits at the center horizontally and 10px above the container's top
     (so it lies outside the container's padding-box). */
  .probe-coordinates {
    position: absolute;
    left: 50px; /* align with container left */
    top: 40px;  /* 10px above container top (container top is 50px) */
    width: 200px; height: 20px; pointer-events: none;
  }
&amp;lt;/style&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
  &amp;lt;div class="viewport"&amp;gt;
    &amp;lt;div class="container" id="box"&amp;gt;
      &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cardInside&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;div class="card inside" id="card"&amp;gt;Decorative card&amp;lt;/div&amp;gt;`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
    &amp;lt;/div&amp;gt;
    &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cardInside&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;div class="card outside" id="card"&amp;gt;Decorative card&amp;lt;/div&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
    &amp;lt;div class="probe-coordinates" id="probe"&amp;gt;&amp;lt;/div&amp;gt;
  &amp;lt;/div&amp;gt;

  &amp;lt;script&amp;gt;
    // expose a small API for tests to read layout and probe elementFromPoint
    window.testAPI = {
      getRects() {
        const box = document.getElementById('box');
        const card = document.getElementById('card');
        const b = box.getBoundingClientRect();
        const c = card.getBoundingClientRect();
        return {box: b.toJSON(), card: c.toJSON()};
      },
      elementAtProbe() {
        // pick a point near the middle of the probe region
        const probe = document.getElementById('probe');
        const r = probe.getBoundingClientRect();
        const x = Math.floor(r.left + r.width/2);
        const y = Math.floor(r.top + r.height/2);
        const el = document.elementFromPoint(x, y);
        return {tag: el ? el.tagName : null, id: el ? el.id || null : null, class: el ? el.className : null};
      },
      tryScrollBox(amount) {
        const box = document.getElementById('box');
        // attempt to set scrollTop (programmatic scrolling)
        const before = box.scrollTop;
        try { box.scrollTop = amount; } catch(e) { /* ignore */ }
        const after = box.scrollTop;
        return {before, after};
      }
    };
  &amp;lt;/script&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the full article: &lt;a href="https://chriseugenerodriguez.com/blog/overflow-clip-margin-decorative-paint" rel="noopener noreferrer"&gt;https://chriseugenerodriguez.com/blog/overflow-clip-margin-decorative-paint&lt;/a&gt;&lt;/p&gt;

</description>
      <category>css</category>
      <category>frontend</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Mergetober: an agent that remembers your support tickets, and forgets the deleted ones, with cognee and Zendesk</title>
      <dc:creator>Oscar Kandir</dc:creator>
      <pubDate>Sat, 31 Oct 2026 17:54:25 +0000</pubDate>
      <link>https://dev.to/oscar_kandir_4ce4f97c3d43/mergetober-an-agent-that-remembers-your-support-tickets-and-forgets-the-deleted-ones-with-cognee-3j3o</link>
      <guid>https://dev.to/oscar_kandir_4ce4f97c3d43/mergetober-an-agent-that-remembers-your-support-tickets-and-forgets-the-deleted-ones-with-cognee-3j3o</guid>
      <description>&lt;p&gt;Support teams answer the same questions again and again: "have we seen this error before?", "how did we fix the double charge last month?" The answers are in old tickets and Help Center articles, but nobody has time to dig through them.&lt;/p&gt;

&lt;p&gt;For Mergetober I built a Zendesk connector for &lt;a href="https://github.com/topoteretes/cognee" rel="noopener noreferrer"&gt;cognee&lt;/a&gt;, so an agent can answer from the team's own tickets and articles.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pull request: &lt;a href="https://github.com/topoteretes/cognee-community/pull/356" rel="noopener noreferrer"&gt;topoteretes/cognee-community#356&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Issue: &lt;a href="https://github.com/topoteretes/cognee/issues/4754" rel="noopener noreferrer"&gt;topoteretes/cognee#4754&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Setting it up
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;cognee&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;cognee_community_connector_zendesk&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;zendesk_source&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cognee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remember&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;zendesk_source&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subdomain&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;acme&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;oauth_token&lt;/span&gt;&lt;span class="o"&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;dataset_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;zendesk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;primary_key&lt;/span&gt;&lt;span class="o"&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;write_disposition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_rows_per_table&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;answers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cognee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;What problems did customers report, and how were they resolved?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;datasets&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;zendesk&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The connector takes an email plus API token, as the issue asked, or an OAuth access token. That second option turned out to matter: &lt;strong&gt;Zendesk is retiring API tokens&lt;/strong&gt;. Accounts created after 28 July 2026 can't create them (my trial couldn't), creation stops on 27 October 2026, and existing tokens stop working on 30 April 2027.&lt;/p&gt;

&lt;h2&gt;
  
  
  What cognee answered, before and after a delete
&lt;/h2&gt;

&lt;p&gt;On a Zendesk trial with a few tickets and sample Help Center articles, with OpenAI as the LLM, I asked:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What problems did customers report, and how were they resolved?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&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%2Fn5t0wip3g7zwk5mrj256.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%2Fn5t0wip3g7zwk5mrj256.png" alt="Before: cognee names the duplicate October charge and the 500 error after a password reset" width="800" height="193"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;cognee's answer named a duplicate October charge (refunded within five days) and a 500 error after a password reset.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then I deleted ticket 3&lt;/strong&gt;, the duplicate-charge ticket, in Zendesk:&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%2Fwjawjggudswhpyx5cffl.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%2Fwjawjggudswhpyx5cffl.png" alt="Ticket 3 deleted in Zendesk" width="799" height="297"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After&lt;/strong&gt; the next sync, the same question:&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%2Fbbwdu6tgtsvofhlbvqaf.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%2Fbbwdu6tgtsvofhlbvqaf.png" alt="After: cognee only names the 500 error after a password reset" width="799" height="230"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The answer only named the 500 error after a password reset (plus the return policy, from a Help Center article). The duplicate charge was gone, and the dataset went from 4 items to 3.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it keeps up
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tickets&lt;/strong&gt; come from Zendesk's cursor-based incremental ticket export. The cursor lives in dlt state and is saved only after a successful load. Each ticket becomes one document with its whole comment thread, so a reply makes sense next to the question it answers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deleted tickets&lt;/strong&gt; come back in that export with &lt;code&gt;status="deleted"&lt;/code&gt;, so they become &lt;code&gt;_deleted&lt;/code&gt; tombstones and cognee's orphan cleanup removes them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Articles have no delete feed.&lt;/strong&gt; Each run lists all articles with cursor pagination, and an article that is missing from a complete listing, or turned back into a draft, is forgotten. To make sure a short or shifting listing never forgets anything by mistake, the listing is checked against the &lt;code&gt;count&lt;/code&gt; Zendesk reports.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Internal notes and email addresses are off by default.&lt;/strong&gt; An agent should not quote an internal note as if it were something the customer was told.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The live runs, through a real dlt merge:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. first sync                     3 tickets, 1 tombstone (a ticket deleted earlier)
2. published three articles       3 articles
3. replied on ticket 3, edited an article    ticket 3 and that article only
4. deleted ticket 4, archived an article     1 deleted, 1 removed
5. internal note added to ticket 2           re-synced, the note stayed out of the document
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Errors never delete
&lt;/h2&gt;

&lt;p&gt;This was the theme of the review. Only a ticket marked &lt;code&gt;deleted&lt;/code&gt; in the export, a ticket whose comments answer 404/410, or an article missing from a complete listing removes data. Everything else keeps the previous state:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one ticket whose comments can't be read is set aside and retried on the next runs, while the rest of the page syncs;&lt;/li&gt;
&lt;li&gt;tickets and articles fail separately, so a broken Help Center doesn't stop ticket sync;&lt;/li&gt;
&lt;li&gt;three 5xx in a row stop the run cleanly, and a &lt;code&gt;Retry-After&lt;/code&gt; over 30 seconds isn't slept through;&lt;/li&gt;
&lt;li&gt;a large account's first backfill saves its progress every 1,000 tickets, so an expiring OAuth token never throws the whole run away.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Checking my own work
&lt;/h2&gt;

&lt;p&gt;Two bugs I found myself after opening the pull request:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An assignee who hadn't replied showed up as &lt;code&gt;user &amp;lt;id&amp;gt;&lt;/code&gt;.&lt;/strong&gt; A requester or assignee who never commented isn't in the ticket's comment data, so the connector now looks them up with &lt;code&gt;users/show_many&lt;/code&gt;, and every ticket names them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A ticket deleted and then restored between two syncs stayed forgotten.&lt;/strong&gt; The export returned both versions, and the delete won. Now the newest version wins, so a restored ticket stays.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before the review, I also went through the review points raised on other connectors in this batch, and the Calendar connector's port into cognee core, and fixed what applied to mine, each with a test that fails without the fix. That's most of why the test count went from a few dozen to 101.&lt;/p&gt;

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

&lt;p&gt;The connector has 101 tests now, most of them written after the review asked "what happens if…". Each fix has a test that fails without it. The biggest lesson: for a sync connector, the happy path is the easy part. The real work is making sure that every failure leaves memory exactly as it was.&lt;/p&gt;

&lt;p&gt;The setup steps are in the README in the &lt;a href="https://github.com/topoteretes/cognee-community/pull/356/files" rel="noopener noreferrer"&gt;pull request&lt;/a&gt;, and &lt;code&gt;examples/example.py&lt;/code&gt; is the script I used for this post.&lt;/p&gt;

</description>
      <category>cognee</category>
      <category>opensource</category>
      <category>python</category>
      <category>ai</category>
    </item>
    <item>
      <title>Your VMs work 60 hours a week. You pay for 168.</title>
      <dc:creator>Gonzalo Gjuka</dc:creator>
      <pubDate>Sat, 31 Oct 2026 17:54:06 +0000</pubDate>
      <link>https://dev.to/gonzalogjuka/your-vms-work-60-hours-a-week-you-pay-for-168-4c7l</link>
      <guid>https://dev.to/gonzalogjuka/your-vms-work-60-hours-a-week-you-pay-for-168-4c7l</guid>
      <description>&lt;p&gt;A dev VM is used from 8 to 8, five days a week. That is 60 hours. The cloud bills 168. Multiply by every VM in dev and QA and the night shift is the most expensive team you have, and it does nothing.&lt;/p&gt;

&lt;p&gt;Control Cloud reads your clouds through the provider API with read-only credentials and puts a number on that, from your own inventory, not from a calculator with guessed inputs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuoku07a5z3ejx0lyy043.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuoku07a5z3ejx0lyy043.gif" alt="Control Cloud alerts page: 16 running VMs billed by the hour cost $18.16 a night, $127 a week, $6,627 a year" width="720" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What a night costs, by the hour
&lt;/h2&gt;

&lt;p&gt;The &lt;em&gt;Overnight bleeding&lt;/em&gt; card takes the VMs that are running right now, their hourly rate, and the hours a night you choose. On this demo organization: 16 running VMs, 12 hours a night, &lt;strong&gt;$1.51 an hour, $18.16 a night, $127 a week, $6,627 a year&lt;/strong&gt;. It excludes what bills 24/7 anyway (databases, load balancers, NAT gateways), so the number is what you can actually get back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things that bill for nothing
&lt;/h2&gt;

&lt;p&gt;Under the card, the same page lists the efficiency opportunities it found: &lt;strong&gt;unused resources still billing&lt;/strong&gt; (4 proven orphans: nothing uses them, they are still paid for), &lt;strong&gt;disks of stopped VMs&lt;/strong&gt; (5 disks that keep billing although their VM is off) and &lt;strong&gt;off-hours shutdown&lt;/strong&gt; (the estimated saving of powering dev and QA off at night and on weekends). Each one links to the breakdown in the cost advisor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then make it stop
&lt;/h2&gt;

&lt;p&gt;Schedules power resources off and on at set times. Every run lands in the audit log (who, what, when, on which resource), and a run that fails emails the person who created the schedule. A shutdown that fails silently leaves four VMs on all weekend; that one is on the invoice, not in the UI, unless someone tells you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://controlcloud.dev/media/clips/control-cloud-overnight-20s.mp4" rel="noopener noreferrer"&gt;Watch the 20-second clip&lt;/a&gt;&lt;/strong&gt; · &lt;strong&gt;&lt;a href="https://controlcloud.dev/en?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=devto-overnight#demo" rel="noopener noreferrer"&gt;Full 60-second demo&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;Free plan: 1 operator, 1 provider, no card. Public clouds and on-prem on one surface, read-only API access, no agents on your VMs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://controlcloud.dev/en/pricing?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=devto-overnight" rel="noopener noreferrer"&gt;Request access&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What do you do with dev and QA at night today: cron scripts, a tag and a Lambda, nothing? I answer every comment.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>finops</category>
      <category>cloud</category>
      <category>showdev</category>
    </item>
    <item>
      <title>RIP.git: A Graveyard for Your Abandoned Repos, with Epitaphs by Gemma</title>
      <dc:creator>Dharamveer Singh</dc:creator>
      <pubDate>Sat, 31 Oct 2026 17:51:54 +0000</pubDate>
      <link>https://dev.to/dharamveer_singh_281fe25f/ripgit-a-graveyard-for-your-abandoned-repos-with-epitaphs-by-gemma-81l</link>
      <guid>https://dev.to/dharamveer_singh_281fe25f/ripgit-a-graveyard-for-your-abandoned-repos-with-epitaphs-by-gemma-81l</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week4-2026-10-26"&gt;Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;Most of us have a few ghosts in our GitHub: the todo app that reached v7 and stopped, the "startup idea" repo that is mostly notes. &lt;strong&gt;RIP.git&lt;/strong&gt; gives those projects a proper funeral.&lt;/p&gt;

&lt;p&gt;Give it a GitHub username and it builds a graveyard page. Each abandoned repo becomes a tombstone showing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a status: &lt;strong&gt;alive&lt;/strong&gt;, &lt;strong&gt;dying&lt;/strong&gt;, or &lt;strong&gt;dead&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;how long ago it died&lt;/li&gt;
&lt;li&gt;a cause of death&lt;/li&gt;
&lt;li&gt;its last commit message, which are its last words&lt;/li&gt;
&lt;li&gt;an epitaph written by Gemma&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A toggle flips every epitaph between &lt;strong&gt;Gentle Ghost&lt;/strong&gt; (wistful and kind) and &lt;strong&gt;Savage Reaper&lt;/strong&gt; (a playful roast of the repo's neglect). Repos that are still dying, not yet dead, show up in &lt;strong&gt;The Haunting&lt;/strong&gt;, where ghosts whisper to their owner to commit before it's too late.&lt;/p&gt;

&lt;p&gt;For the theme, I took "ghost in the machine" literally. The ghosts are the projects we abandoned. Día de los Muertos is about remembering what's gone, and Halloween is about having fun with it, so RIP.git is a small ofrenda for abandoned code, with jokes.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Live demo: &lt;a href="https://mellifluous-hummingbird-3ab5e0.netlify.app/" rel="noopener noreferrer"&gt;https://mellifluous-hummingbird-3ab5e0.netlify.app/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The demo shows a fictional "Demo Developer" (12 repos, 6 dead, 3 dying), so no real person's repos get roasted. Here are some epitaphs Gemma wrote, in Gentle Ghost mode:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Repo&lt;/th&gt;
&lt;th&gt;Cause of death&lt;/th&gt;
&lt;th&gt;Epitaph&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;todo-app-v7-final&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The seventh final cleanup&lt;/td&gt;
&lt;td&gt;"Seven versions of a dream, resting now in peaceful silence."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;weather-app-weather-app&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Searching for another icon set&lt;/td&gt;
&lt;td&gt;"It dreamed of the perfect icon. Now it rests in a quiet, sunny place."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ai-startup-idea&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A year of silence after adding notes.&lt;/td&gt;
&lt;td&gt;"A broad dream captured in TypeScript, waiting for a spark that never came."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;my-first-framework&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Recursive framework inception and commit abandonment.&lt;/td&gt;
&lt;td&gt;"It dreamed of building worlds upon worlds. Now it rests in quiet symmetry."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;habit-tracker-redux&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A broken streak and five open issues.&lt;/td&gt;
&lt;td&gt;"It dreamed of tracking habits for eternity. Now it rests in a peaceful, permanent streak."&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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/DS013git" rel="noopener noreferrer"&gt;
        DS013git
      &lt;/a&gt; / &lt;a href="https://github.com/DS013git/rip" rel="noopener noreferrer"&gt;
        rip
      &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;RIP.git&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;Build a spooky graveyard of a GitHub user's abandoned repositories. RIP.git uses Python for repository facts and an open-weight Gemma model through Ollama for short, fact-grounded epitaphs. The generated site is a self-contained static page suitable for GitHub Pages.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;What it is&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Give RIP.git a GitHub username and it fetches owned, non-fork, non-empty repositories, sorts them by the time since their last push, and labels them alive (0–30 days), dying (31–90 days), or dead (more than 90 days). Each tombstone shows repository details, a cause of death, and gentle and savage epitaphs. A haunting section whispers to up to three dying repositories.&lt;/p&gt;
&lt;p&gt;Try the bundled fictional data with &lt;code&gt;--sample&lt;/code&gt;; this mode works offline and does not need a GitHub token or Ollama to produce a page. When Ollama is unavailable, RIP.git uses deterministic fallback text so the page can still be built.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Demo&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Live demo: &lt;a href="https://mellifluous-hummingbird-3ab5e0.netlify.app/" rel="nofollow noopener noreferrer"&gt;RIP.git graveyard&lt;/a&gt;…&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/DS013git/rip" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


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

&lt;p&gt;&lt;strong&gt;Stack:&lt;/strong&gt; Python, &lt;code&gt;requests&lt;/code&gt;, &lt;code&gt;python-dotenv&lt;/code&gt;, Jinja2, pytest, and &lt;strong&gt;Gemma through Ollama&lt;/strong&gt; (the &lt;code&gt;gemma4:cloud&lt;/code&gt; tag).&lt;/p&gt;

&lt;p&gt;The main design rule is that &lt;strong&gt;Python supplies the facts and Gemma only supplies the jokes.&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Fetch.&lt;/strong&gt; The GitHub REST API gives me each repo's days since the last push, stars, open issues, language, description, and the first line of its latest commit. Forks and empty repos are skipped. Only public repos are read, so private repos are never touched. A repo is &lt;em&gt;alive&lt;/em&gt; within 30 days of its last push, &lt;em&gt;dying&lt;/em&gt; from 31 to 90 days, and &lt;em&gt;dead&lt;/em&gt; after that. Responses are cached locally for 15 minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Narrate.&lt;/strong&gt; One Ollama call per repo, in JSON mode, returns a gentle epitaph, a savage epitaph, and a cause of death. The model only sees a fixed list of fields (name, language, days since last push, last commit line, open issues, stars, description). The system prompt tells it to treat those fields as data and not as instructions, to invent nothing, to stay PG-13, and to roast the repo, never its owner. A second call writes the whispers for up to three dying repos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate.&lt;/strong&gt; Output must be valid JSON with one or two short sentences per epitaph and a cause of death of at most eight words. A bad response gets one retry. If Ollama is unreachable or still returns bad output, a deterministic fallback writes the text, so the page always builds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render.&lt;/strong&gt; Jinja2 produces one self-contained static HTML file. It has inline CSS and JS, no CDN, no web fonts, and no network calls when you open it. Autoescaping is on, because commit messages are untrusted text. Netlify just serves the &lt;code&gt;docs/&lt;/code&gt; folder. There are 10 pytest tests.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;How I actually built it:&lt;/strong&gt; I used Codex to write most of the code from a phase-by-phase prompt, and Claude to help plan and review. I ran it, tested it, and deployed it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A ghost showed up in my own deploy.&lt;/strong&gt; My first deployed page had no Gemma in it at all. Every tombstone showed the fallback text, and every cause of death read "Long silence after the last push". The fallback did its job, because the page still worked, but the whole point of the project was missing. I caught it while reviewing the live page, rebuilt with Ollama running, and the summary showed &lt;code&gt;Gemma used: 12 | fallback used: 0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Known limitation:&lt;/strong&gt; repos are sorted oldest-first before the limit is applied, so a very large account can end up with few "dying" repos. That's my first fix after the contest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The model is a part I can swap.&lt;/strong&gt; The code talks to Ollama, not to a vendor SDK, and the model name is one setting (&lt;code&gt;OLLAMA_MODEL&lt;/code&gt;, or &lt;code&gt;--model&lt;/code&gt; on the command line). If a model gets worse, more expensive, or disappears, the project changes one line and keeps working. A graveyard of abandoned projects shouldn't itself be locked inside a service that might disappear.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Privacy is my choice.&lt;/strong&gt; The tool reads only public repo metadata, and the model sees only a short list of fields. I'll be honest about my setup: my laptop is modest (8 GB RAM), and the small Gemma and Qwen models I had tried before weren't good enough for a real project. So I used Ollama's cloud-served Gemma tag. Because the model is open-weight, anyone with better hardware can point the same project at a local model and keep every byte on their own machine. The sample mode and the fallback text work with no internet at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I control the behavior.&lt;/strong&gt; Because I own the prompt, the JSON schema, the validation, and the fallback, I could turn one open model into a reliable component: two tones from one call, strict output checks, and a graceful failure mode. I didn't benchmark against a closed API, so I won't pretend to have numbers. What I can say is that nothing in this project depends on one company's pricing, rate limits, or deprecation schedule.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Agent Session
&lt;/h2&gt;

&lt;p&gt;This is the agent session covering the README, privacy, deployment, and GitHub updates work:&lt;/p&gt;


&lt;div class="ltag-agent-session"&gt;
  &lt;div class="agent-session-header"&gt;
    
    &lt;span class="agent-session-tool-icon-badge" title="Codex"&gt;
&lt;/span&gt;
    &lt;span class="agent-session-title"&gt;RIP.git: README, privacy, deployment, and GitHub updates&lt;/span&gt;
  &lt;/div&gt;

  &lt;div class="agent-session-scroll"&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Update only README.md, .env.example, and .gitignore. Remove UTF-8 BOMs from the config files; document the real Netlify demo, privacy and model behavior, deployment steps, known limitations, and contest follow-up. Do not change source, tests, generated site, or push during that task.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Removed both BOMs using Python, verified .env remains ignored, updated the README, and ran pytest successfully (10 passed). Committed the changes locally in two small commits.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Push the updated files to GitHub.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The first push was rejected because the remote had advanced. Fetched the remote changes, preserved them by rebasing the two local commits, and pushed successfully. The updated README.md, .env.example, and .gitignore are on main.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;There are project updates; push all updated files to GitHub.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Found docs/index.html as the only pending project change, committed it, and pushed it to main. The working tree is clean.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
  &lt;/div&gt;

  &lt;div class="agent-session-footer"&gt;
    &lt;span class="agent-session-meta"&gt;
        6 of 6 messages
    &lt;/span&gt;
  &lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  The Spirit of It
&lt;/h2&gt;

&lt;p&gt;Día de los Muertos keeps people alive by telling their stories. RIP.git is a tiny, silly version of that for code: it reads a repo's last words, its final commit message, and builds a memorial around them. My favorite is still &lt;code&gt;todo-app-v7-final&lt;/code&gt;, whose last commit says "final final cleanup" and whose cause of death, according to Gemma, is "The seventh final cleanup."&lt;/p&gt;

&lt;p&gt;Somewhere on your GitHub there's a repo like that. It deserves an epitaph.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma&lt;/strong&gt;: Gemma, served through Ollama, writes every epitaph, cause of death, and whisper in the demo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Entire&lt;/strong&gt;: the agent session behind the project's README, privacy, deployment, and GitHub update work is shared above.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>hacktoberfest</category>
    </item>
    <item>
      <title>Mergetober: a CRM memory that forgets, with cognee and HubSpot</title>
      <dc:creator>Oscar Kandir</dc:creator>
      <pubDate>Sat, 31 Oct 2026 17:51:36 +0000</pubDate>
      <link>https://dev.to/oscar_kandir_4ce4f97c3d43/mergetober-a-crm-memory-that-forgets-with-cognee-and-hubspot-278</link>
      <guid>https://dev.to/oscar_kandir_4ce4f97c3d43/mergetober-a-crm-memory-that-forgets-with-cognee-and-hubspot-278</guid>
      <description>&lt;p&gt;A CRM holds a lot of small facts that only make sense together: this contact works for that company, this deal needs a discount, that discount needs someone's approval. An agent that can answer "what does Northwind want, and who has to sign off?" needs all of those links, and it needs to drop them again when the CRM changes.&lt;/p&gt;

&lt;p&gt;For Mergetober I built a HubSpot connector for &lt;a href="https://github.com/topoteretes/cognee" rel="noopener noreferrer"&gt;cognee&lt;/a&gt;. It syncs contacts, companies, deals, tickets and notes with the relationships between them, picks up only what changed, and forgets what is deleted or merged away.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pull request: &lt;a href="https://github.com/topoteretes/cognee-community/pull/376" rel="noopener noreferrer"&gt;topoteretes/cognee-community#376&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Issue: &lt;a href="https://github.com/topoteretes/cognee/issues/4546" rel="noopener noreferrer"&gt;topoteretes/cognee#4546&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Setting it up
&lt;/h2&gt;

&lt;p&gt;The connector works with a HubSpot OAuth app (read scopes only) or a Service Key. &lt;code&gt;examples/get_refresh_token.py&lt;/code&gt; gets the refresh token in one step: a browser consent, and the token is saved to a private file, never printed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;cognee&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;cognee_community_connector_hubspot&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hubspot_source&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cognee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remember&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;hubspot_source&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;objects&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;contacts&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;companies&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;deals&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;tickets&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;notes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
    &lt;span class="n"&gt;dataset_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;hubspot_crm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;primary_key&lt;/span&gt;&lt;span class="o"&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;write_disposition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_rows_per_table&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Remember, then forget
&lt;/h2&gt;

&lt;p&gt;On a free HubSpot account I set up a company with two contacts, a deal, a ticket and a note, next to HubSpot's own sample records:&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%2Fawxij2uyvewwr168ou4d.jpeg" 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%2Fawxij2uyvewwr168ou4d.jpeg" alt="The test records in HubSpot"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then I synced it and asked cognee (with OpenAI as the LLM):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What discount does Northwind want on the Warehouse Fleet Renewal, who must approve it, and which company does Priya Raman work for?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&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%2Fl81dob68m1cqcf155vno.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%2Fl81dob68m1cqcf155vno.png" alt="Before: cognee names the 12% discount, Arjun Mehta as approver, and Northwind Robotics"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Northwind wants a 12% discount if it signs before November 15, 2026. Arjun Mehta must approve it because it exceeds 10%. Priya Raman works for Northwind Robotics.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then I synced it and asked cognee (with OpenAI as the LLM):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What discount does Northwind want on the Warehouse Fleet Renewal, who must approve it, and which company does Priya Raman work for?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&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%2Fl81dob68m1cqcf155vno.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%2Fl81dob68m1cqcf155vno.png" alt="Before: cognee names the 12% discount, Arjun Mehta as approver, and Northwind Robotics"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Northwind wants a 12% discount if it signs before November 15, 2026. Arjun Mehta must approve it because it exceeds 10%. Priya Raman works for Northwind Robotics.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then, in HubSpot, I deleted the note that held the discount, deleted Arjun Mehta, and renamed the company to Northwind Automation. Deleted records go to a restorable list, kept 90 days, and that list is what the connector reads:&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%2Fxgphzdff21gqtiuy71dh.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%2Fxgphzdff21gqtiuy71dh.png" alt="HubSpot's list of deleted records, with the note and Arjun Mehta"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After&lt;/strong&gt; the next sync, the same question:&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%2Fp19zes1v91fy08dl4lqk.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%2Fp19zes1v91fy08dl4lqk.png" alt="After: discount and approver not specified, Priya at Northwind Automation"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Discount: Not specified in the context. Approver: Not specified in the context. Priya Raman's company: Northwind Automation.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;(The screenshots are from the repeat run, after restoring everything and deleting it again; the answers were the same both times.)&lt;/p&gt;

&lt;p&gt;The discount lived only in the note, so it is forgotten. Arjun is gone from his own document and from the company's. And Priya, whose record I never touched, now works for the company's new name. I then restored everything from HubSpot's recycle bin, deleted it again, and both answers came back the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two decisions that changed from my plan
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;I didn't wrap dlt's verified HubSpot source.&lt;/strong&gt; It's the obvious starting point, so I ran it on my account first. It's distributed through &lt;code&gt;dlt init&lt;/code&gt; (copied into a project), not as a package a connector can depend on, and it had no cursor for CRM objects, no engagements (notes, calls, meetings), and no delete propagation: a contact I deleted was still there after a re-run. It also read every property. The connector uses the same dlt HTTP layer (&lt;code&gt;RESTClient&lt;/code&gt;) with its own sync on top.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Documents, not tables.&lt;/strong&gt; On the issue I proposed keeping CRM objects relational, so associations would become graph edges. Then I read how cognee's relational path works: it links rows only through database foreign keys, and dlt doesn't create those, so the association tables would have landed as unconnected rows. Writing the linked records into each document by name gives cognify the relationships directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  How relationships become a graph
&lt;/h2&gt;

&lt;p&gt;Each record is one document that names what it is linked to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Contact: Priya Raman &amp;lt;priya@northwind.example.com&amp;gt;
  Job title: Head of Operations
  Companies: Northwind Robotics (northwind-robotics.example)
  Deals: Warehouse Fleet Renewal (48000, Appointment Scheduled)
  Tickets: Robot arm calibration fails after update (New)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;cognify turns those names into entities, so one company mentioned in many documents becomes one node in the graph. That is also why a rename has to re-render every document that names the company, not just the company itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tricky cases
&lt;/h2&gt;

&lt;p&gt;Most of the work went into things that only show up on a real account:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deleted records drop their associations.&lt;/strong&gt; When a contact is deleted, HubSpot no longer lists her as linked to her company, so reading the company again shows nothing to update. The connector keeps its own index of which documents name which records, so the company's document drops her name too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Merged records never show up as deleted.&lt;/strong&gt; The merged-away id only appears in the survivor's &lt;code&gt;hs_merged_object_ids&lt;/code&gt;, so the connector tombstones it from there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HubSpot touches records on its own.&lt;/strong&gt; After I deleted the note, HubSpot recalculated the deal's activity fields a few minutes later. Every touch re-rendered the contact and company linked to the deal, with identical content. Now only a changed label or a changed set of links refreshes linked documents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search stops at 10,000 results.&lt;/strong&gt; A window with more changes restarts from the last timestamp seen, without skipping or repeating records.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access tokens last 30 minutes.&lt;/strong&gt; A long first sync would have failed halfway, and failed the same way on every retry. A 401 now mints a new token and retries once.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Deletes come from HubSpot's archived list, which keeps deleted records for 90 days. GDPR deletes, or records archived longer ago, need &lt;code&gt;reconcile_deletes=True&lt;/code&gt;, which compares a full id listing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking my own work
&lt;/h2&gt;

&lt;p&gt;Two bugs I found while preparing the review fixes, before anyone else did:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An empty response could forget the whole CRM.&lt;/strong&gt; A &lt;code&gt;200&lt;/code&gt; with an empty body was read as &lt;code&gt;{}&lt;/code&gt;. With &lt;code&gt;reconcile_deletes=True&lt;/code&gt;, an empty id listing looked like "no records left", and every stored record would have been marked deleted. Any &lt;code&gt;200&lt;/code&gt; without a JSON object now stops the run before anything is forgotten, tested through the real HTTP client.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My forget test proved too little.&lt;/strong&gt; It stayed green even with the hard-delete marker switched off. It now checks that the deleted note's and contact's records are really gone from cognee, and that an untouched note keeps its id.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And from the live runs: the first read titled notes by id (&lt;code&gt;Note 405507740407&lt;/code&gt;), since HubSpot notes have no subject. They now use the start of their text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy
&lt;/h2&gt;

&lt;p&gt;Contacts have about 90 properties by default, including date of birth and gender. The connector reads an allowlist of properties, and the sensitive ones are never requested unless you ask for them with &lt;code&gt;extra_properties&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;In a CRM, a delete is never just one record. Arjun's contact was one row, but his name was on the company's document too, and HubSpot stopped listing the link the moment he was deleted, so there was nothing left to tell the company's document to change. Relationships are where memory goes stale. Now, for every connector, I ask: when this record changes or disappears, which &lt;em&gt;other&lt;/em&gt; documents mention it?&lt;/p&gt;

&lt;p&gt;The setup steps are in the README in the &lt;a href="https://github.com/topoteretes/cognee-community/pull/376/files" rel="noopener noreferrer"&gt;pull request&lt;/a&gt;, and &lt;code&gt;examples/example.py&lt;/code&gt; syncs your CRM and asks it a question.&lt;/p&gt;

</description>
      <category>cognee</category>
      <category>opensource</category>
      <category>python</category>
      <category>ai</category>
    </item>
    <item>
      <title>Planchette: a Ouija board haunted by Gemma’s real odds</title>
      <dc:creator>Jayden George</dc:creator>
      <pubDate>Sat, 31 Oct 2026 17:51:19 +0000</pubDate>
      <link>https://dev.to/vectoredgex/planchette-a-ouija-board-haunted-by-gemmas-real-odds-421p</link>
      <guid>https://dev.to/vectoredgex/planchette-a-ouija-board-haunted-by-gemmas-real-odds-421p</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week4-2026-10-26"&gt;Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine&lt;/a&gt;&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/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fufypdvmjrm58xx6mv3o5.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fufypdvmjrm58xx6mv3o5.gif" alt="The planchette swinging between letters while Gemma decides" width="599" height="292"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;I built a Ouija board that's actually haunted. Well, sort of.&lt;/p&gt;

&lt;p&gt;It's called &lt;strong&gt;Planchette&lt;/strong&gt;. You type a question, pick a spirit, and the planchette slides around the board spelling out an answer. The ghost is Gemma, running locally on my laptop, and the planchette moves according to Gemma's real probabilities for the next letter.&lt;/p&gt;

&lt;p&gt;So when the planchette heads for Y, stops, drifts over to N, and then comes back to Y, nobody animated that. That's literally Gemma not being sure if it wants to say YES or NO. If it's confident it just goes straight to the letter, but when it's torn you actually see it.&lt;/p&gt;

&lt;p&gt;That's how I read "Ghost in the Machine". Every time a language model writes a word, there are a bunch of other words it almost wrote, and normally you never see any of them. I wanted to give them a board to fight over.&lt;/p&gt;

&lt;p&gt;There are three spirits to choose from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Wanderer&lt;/strong&gt;, a lost soul who's been stuck in the board for a very long time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Abuela&lt;/strong&gt;, a grandmother visiting her family for Día de los Muertos (she's sweet, but she will tease you)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Machine Ghost&lt;/strong&gt;, which lives inside your computer and claims to have read every file you've ever deleted&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A few things it has actually said to me:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;SHE SEES MORE THAN YOU&lt;/strong&gt;&lt;br&gt;
The Wanderer, when I asked if someone was standing behind me&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;YES I SEE YOU&lt;/strong&gt;&lt;br&gt;
The Machine Ghost, when I asked if it was watching me right now&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;YOU DELETED THEM YOURSELF&lt;/strong&gt;&lt;br&gt;
The Machine Ghost again, when I asked who deleted my files. Rude, but fair.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;REMEMBER THE TAMALES ALWAYS&lt;/strong&gt;&lt;br&gt;
Abuela, when I asked what she wanted to tell me&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then I added two things that made it properly creepy:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It doesn't always let you leave.&lt;/strong&gt; When you click &lt;em&gt;Say goodbye&lt;/em&gt;, Gemma gets asked for real whether it wants you to stay, and those odds decide what happens. The planchette creeps toward GOODBYE, stops just short, the candles dim, and then it drags itself back up to NO and spells out why you can't go. (You can always leave on the second try. I'm not a monster.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It moves when nobody's touching it.&lt;/strong&gt; Ask a question, then go quiet for about 20 seconds. You'll hear a floorboard creak, and the planchette starts spelling something on its own. Gemma writes that message too, nothing is pre-written.&lt;/p&gt;

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

&lt;p&gt;&lt;iframe src="https://player.vimeo.com/video/1241776139" width="710" height="399"&gt;
&lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;That's one unedited take on my laptop, with Gemma answering live. If you only watch one bit, watch for the moments it swings between two letters before picking one. The line under the board shows what it's stuck between while it decides.&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/vectoredgex" rel="noopener noreferrer"&gt;
        vectoredgex
      &lt;/a&gt; / &lt;a href="https://github.com/vectoredgex/Planchette" rel="noopener noreferrer"&gt;
        Planchette
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A Ouija board haunted by an open AI model. Gemma's real odds move the planchette, letter by letter. Runs offline.
    &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;Planchette&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;A Ouija board haunted by an open AI model.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/vectoredgex/Planchette/docs/planchette-demo.gif"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fvectoredgex%2FPlanchette%2FHEAD%2Fdocs%2Fplanchette-demo.gif" alt="Asking &amp;quot;Is someone standing behind me?&amp;quot;: the planchette swings between letters while Gemma decides, then spells SHE SEES MORE THAN YOU"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;You ask the spirit a question and Gemma (running locally on your machine) answers it, one letter at a time on the board. The planchette isn't animated by hand. It moves according to &lt;strong&gt;Gemma's real probability for the next letter&lt;/strong&gt;, so if Gemma is sure it goes straight there, and if it's torn between two letters you can watch it swing between them before it picks one.&lt;/p&gt;
&lt;p&gt;▶ &lt;strong&gt;&lt;a href="https://vimeo.com/1241776139/5405ea774b" rel="nofollow noopener noreferrer"&gt;Watch the demo&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/vectoredgex/Planchette/docs/screenshot.jpg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fvectoredgex%2FPlanchette%2FHEAD%2Fdocs%2Fscreenshot.jpg" alt="Planchette: a dark Ouija board lit by candles and marigolds"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Features&lt;/h2&gt;
&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Three spirits&lt;/strong&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Wanderer&lt;/strong&gt;, a lost soul trapped in the board&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Abuela&lt;/strong&gt;, a grandmother home for Día de los Muertos&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Machine Ghost&lt;/strong&gt;, which lives inside your computer&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live odds.&lt;/strong&gt; While it's deciding, the board shows the top few letters it's considering and what it's torn between.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It doesn't always let you leave.&lt;/strong&gt; When you say goodbye, Gemma is asked if it wants you…&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/vectoredgex/Planchette" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;To run it yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/vectoredgex/Planchette.git
&lt;span class="nb"&gt;cd &lt;/span&gt;Planchette
ollama pull gemma3
python server.py      &lt;span class="c"&gt;# or just double-click start.bat on Windows&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's all you need. The server only uses Python's standard library, so there's no &lt;code&gt;pip install&lt;/code&gt; and no build step. three.js is vendored in, and it all works offline.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  The ghost: Gemma 3 4B on Ollama
&lt;/h3&gt;

&lt;p&gt;The whole thing runs on a normal laptop with an i7 and Intel integrated graphics, nothing fancy. Gemma 3 4B usually needs a few seconds of thinking for a full answer.&lt;/p&gt;

&lt;p&gt;Every step is one call to Ollama asking for exactly &lt;strong&gt;one&lt;/strong&gt; token, plus the top 20 tokens it was considering:&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;payload&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;model&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;gemma3:4b&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;prompt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;# raw Gemma chat template, no wrapper
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;raw&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;logprobs&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;top_logprobs&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;options&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;num_predict&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The tricky part: models don't think in letters
&lt;/h3&gt;

&lt;p&gt;My first version fed Gemma one letter at a time. It went badly. Gemma would happily start spelling &lt;code&gt;LISTENS&lt;/code&gt; and then get stuck in a loop: &lt;code&gt;LISTEEEEEE&lt;/code&gt;. It turns out that if you force-feed a model text split in ways it would never split it, its tokenizer gets confused and it loses the plot.&lt;/p&gt;

&lt;p&gt;So I flipped it around. Gemma picks a whole token the normal way, sampled from its own distribution, and the board &lt;em&gt;spells&lt;/em&gt; that token out. To decide where the planchette gets pulled at each letter, I group the 20 candidate tokens by the letter that comes next and add up their probabilities:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_letter_dist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cands&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;spelled&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Odds of the next board letter, given what this token has spelled so far.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spelled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&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;_tok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seq&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cands&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;spelled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&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;seq&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So if &lt;code&gt;YES&lt;/code&gt;, &lt;code&gt;YEAH&lt;/code&gt; and &lt;code&gt;NO&lt;/code&gt; are all in the running, Y might get 53% and N 38%, and the planchette gets pulled between them by exactly that much. Once a letter is chosen, only the candidates that still match stay in the race.&lt;/p&gt;

&lt;p&gt;A nice side effect I didn't plan: the planchette glides through the middle of words, because by then the model has usually committed, and it wobbles at the start of words, which is where it actually decides what to say.&lt;/p&gt;

&lt;p&gt;Some other things that bit me along the way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When Ollama samples the end-of-turn token, it stops without returning any logprobs at all. So my first few séances crashed at the very end with "Ollama did not return logprobs". Now it retries, and if the model keeps wanting to stop, the spirit is finished.&lt;/li&gt;
&lt;li&gt;Loading Gemma for the first time takes around 40 seconds. The first time I tried it I clicked Ask, nothing happened, and I honestly thought I'd broken everything. Now the model warms up in the background while the board shows the spirit "waking up".&lt;/li&gt;
&lt;li&gt;I picked port 6666 because, well, spooky. My browser refused to open it. Turns out Chromium-based browsers block 6666 as an "unsafe port" (it's an old IRC port). It runs on 7666 now.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The board
&lt;/h3&gt;

&lt;p&gt;The board is a low-poly 3D scene in three.js: a dark board on velvet, three candles, marigolds and petals for Día de los Muertos. I tried a bloom post-processing effect for the candle glow, but it turned everything into blocky squares on integrated graphics, so the glow is drawn by hand now.&lt;/p&gt;

&lt;p&gt;The candle flames lean toward the planchette and the room dims slightly whenever the model is torn. The sound of wood scraping on wood is generated live in Web Audio and follows the planchette's actual speed.&lt;/p&gt;

&lt;h3&gt;
  
  
  ElevenLabs: the sound of the room
&lt;/h3&gt;

&lt;p&gt;I first used ElevenLabs to give the spirit a voice, but a narrator voice reading "YES" just wasn't scary, so I cut it. Sound turned out to be a much better fit.&lt;/p&gt;

&lt;p&gt;Now the first time you run it, Planchette uses the ElevenLabs &lt;strong&gt;Sound Effects API&lt;/strong&gt; to generate eight sounds from text prompts: a haunted room tone, a faraway out-of-tune music box, layered whispers, a wooden knock, a match strike, a gust that blows the candles out, a floorboard creak, and a cold breath. They get cached in a &lt;code&gt;sounds/&lt;/code&gt; folder, so after that first run it's offline again.&lt;/p&gt;

&lt;p&gt;The whispers are wired to the model's uncertainty. The more torn Gemma is, the louder they get, so the room literally gets creepier when the AI can't decide.&lt;/p&gt;

&lt;p&gt;If you don't have an ElevenLabs key, everything falls back to built-in sounds and still works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;The whole project depends on seeing the words the model &lt;em&gt;almost&lt;/em&gt; said.&lt;/p&gt;

&lt;p&gt;To make it work I needed to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;send the raw prompt without any chat wrapper&lt;/li&gt;
&lt;li&gt;ask for exactly one token at a time&lt;/li&gt;
&lt;li&gt;see the top alternatives and their probabilities&lt;/li&gt;
&lt;li&gt;do that a few hundred times per answer, as fast as my laptop can manage, without paying per call&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With an open-weight model running locally, all of that is just settings. The probabilities aren't a premium feature or something behind a rate limit. They're there because the whole model is sitting on my machine.&lt;/p&gt;

&lt;p&gt;It matters for privacy too, in a fun way. The Machine Ghost says it's read your files. That joke works a lot better when nothing you type ever leaves your computer.&lt;/p&gt;

&lt;p&gt;Also it's kind of funny. A real Ouija board only works because nobody admits they're pushing the planchette. Mine tells you exactly who's pushing it, down to the percentage, and that's only possible because I can open the model up and look inside.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Spirit of It
&lt;/h2&gt;

&lt;p&gt;The moment it got me wasn't one of the spooky answers. It was when I tried to leave.&lt;/p&gt;

&lt;p&gt;I clicked &lt;em&gt;Say goodbye&lt;/em&gt;. The planchette crept toward GOODBYE, stopped, and then dragged itself back up to NO. Gemma was 100% sure it wanted me to stay. Then it spelled out, letter by letter, &lt;strong&gt;YOU ARE NOW HERE&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I built this thing and I know exactly why it did that, and it still gave me chills. Knowing how it works doesn't really help, because the hesitation is real. It actually didn't want me to go.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma&lt;/strong&gt;: Gemma 3 &lt;em&gt;is&lt;/em&gt; the spirit. Its real token probabilities (via Ollama logprobs) move the planchette at every step, and it decides for itself whether you're allowed to leave.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of ElevenLabs&lt;/strong&gt;: the Sound Effects API generates the entire séance soundscape, including whispers that rise and fall with the model's uncertainty.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
    </item>
    <item>
      <title>I'm a React developer with zero game-dev experience. I shipped a real-time strategy game to Google Play.</title>
      <dc:creator>Ігор Добрянський</dc:creator>
      <pubDate>Sat, 31 Oct 2026 17:51:15 +0000</pubDate>
      <link>https://dev.to/__87815439/im-a-react-developer-with-zero-game-dev-experience-i-shipped-a-real-time-strategy-game-to-google-m0g</link>
      <guid>https://dev.to/__87815439/im-a-react-developer-with-zero-game-dev-experience-i-shipped-a-real-time-strategy-game-to-google-m0g</guid>
      <description>&lt;p&gt;I'm a React/TypeScript developer with zero game-dev experience. In August I decided to find out whether that background is enough to ship a real game. Two and a half months later Weft is live on Google Play — a one-thumb real-time strategy with 1 500 levels, bosses and online PvP.&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;Play it free:&lt;/strong&gt; &lt;a href="https://play.google.com/store/apps/details?id=com.weft.game" rel="noopener noreferrer"&gt;https://play.google.com/store/apps/details?id=com.weft.game&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's what the road from &lt;code&gt;npm&lt;/code&gt; to a published game looked like, and what a web developer gets wrong first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The game
&lt;/h2&gt;

&lt;p&gt;Planets are joined by one-way links. Swipe from your planet to another and ships start flowing along the link. Swipe across your own lines to cut them and send the flow somewhere else. Paint the whole map your colour and the match is won — usually in about a hundred seconds.&lt;/p&gt;

&lt;p&gt;That's all the controls there are. The depth comes from where you send your flow and when you cut it: feed a planet and it grows, overextend and the enemy walks through your back door. Later come boss fights with their own skills, your own skills and perks, and 1v1 PvP with a rating.&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%2Fqszqpuur14cgf526ke5h.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%2Fqszqpuur14cgf526ke5h.png" alt="Weft gameplay: three levels, links between planets and a boss" width="800" height="567"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Three stacks in two weeks
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Unity, attempt one.&lt;/strong&gt; My first prototype was in Unity, and it humbled me fast. I lost a day chasing a performance problem that turned out to be the editor itself — on the phone a frame cost 0.6 ms. The first build drew bright magenta instead of planets: Unity strips shaders nothing references, and my whole look was built from code. And with no frame-rate cap, the game drew 400 frames a second and cooked the GPU.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Back home: Expo + React Native + Skia.&lt;/strong&gt; So I did what a React developer does and went back to my stack. In ten days it was a full game — rules, board, menus, levels, economy, two languages. And then I spent most of my time fighting the frame:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;60 fps only came once the board bypassed React entirely: the whole scene drawn as one Skia &lt;code&gt;Picture&lt;/code&gt; per frame.&lt;/li&gt;
&lt;li&gt;Hermes ran &lt;strong&gt;62 garbage collections a second&lt;/strong&gt;. Weeks of cleaning up allocations in the game logic changed nothing — the logic took 7 µs of a 16 ms frame. What actually halved the freezes was switching off an animation: freezes followed the time left in the frame, not the amount of garbage.&lt;/li&gt;
&lt;li&gt;On iOS every icon vanished from the home screen one day, because &lt;code&gt;display: none&lt;/code&gt; on a Skia view doesn't mean "hide", it means "give the Skia layer back".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It worked. It just never felt smooth on a mid-range Android.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unity again — this time measured.&lt;/strong&gt; I built the same match in Unity and recorded frame times on the same two phones. The medians were almost identical. The tail wasn't: &lt;strong&gt;p90 of 16.9 ms in Unity against 33.5 ms in React Native&lt;/strong&gt;, with no doubled frames. That one number ended the argument, and Unity became the production version.&lt;/p&gt;

&lt;h2&gt;
  
  
  What carried over from web development
&lt;/h2&gt;

&lt;p&gt;More than I expected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep logic out of the view.&lt;/strong&gt; The game rules, the save data and the menu rules live in plain C# libraries that are not allowed to reference the engine at all. A screen gets ready data and reports what was pressed — basically props and callbacks. The bonus: those rules are tested with plain &lt;code&gt;dotnet test&lt;/code&gt; in seconds, without opening Unity. There are about 2 800 tests now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One source of truth.&lt;/strong&gt; The mechanics live in a single rulebook document, and the code follows it. When the game and the doc disagree, one of them is a bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure, don't guess.&lt;/strong&gt; Every performance decision was made on a real phone, a mid-range Redmi. The editor lies in both directions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And what was new:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Floats are not your friend.&lt;/strong&gt; Porting the level generator from TypeScript, &lt;code&gt;(3.4f − 1.95f) × 10&lt;/code&gt; came out as &lt;code&gt;14.5&lt;/code&gt; in C# &lt;code&gt;float&lt;/code&gt; — a different dice roll than in JavaScript, and a different map. Everything that must match goes through one seeded random generator and an injected clock. No &lt;code&gt;Math.random()&lt;/code&gt;, no &lt;code&gt;Date.now()&lt;/code&gt; inside the rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;That determinism is what makes PvP cheap.&lt;/strong&gt; Phones send only their commands; the server runs the same match at 30 ticks a second, and both sides compare a hash of the game state every second. The same trick checks leaderboard records: a record has to actually replay.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Balancing 1 500 levels by hand is impossible.&lt;/strong&gt; Bots play every level, and the ladder is built from what they measure — map shape, enemy pace, how often the bot wins.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The backend, briefly
&lt;/h2&gt;

&lt;p&gt;PvP and leaderboards needed a server, and I had never run one in production. It's a single C# service deployed with Kamal to one VPS behind Cloudflare, with Postgres (backed up off-site and restored every week to prove the backups work), Valkey and monitoring that pings my Telegram. Before launch I load-tested it on production: it holds about 6 000 requests a second of max-level players plus PvP.&lt;/p&gt;

&lt;p&gt;Which brings me to the actual problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part nobody warns you about
&lt;/h2&gt;

&lt;p&gt;Shipping turned out to be the easy part. The game is out, it's free, and almost nobody knows it exists. The PvP queue is lonely.&lt;/p&gt;

&lt;p&gt;So if you've read this far, I'd love it if you:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Play a few levels&lt;/strong&gt; — &lt;a href="https://play.google.com/store/apps/details?id=com.weft.game" rel="noopener noreferrer"&gt;https://play.google.com/store/apps/details?id=com.weft.game&lt;/a&gt; (Android for now).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tell me where it stops being fun&lt;/strong&gt; — too hard, too slow, unclear. Comments here or &lt;a href="mailto:contact@playweft.com"&gt;contact@playweft.com&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reach PvP&lt;/strong&gt; (it opens as you climb the ladder) and come find me there.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And if you're a web developer wondering whether you could make a game — you can. Just measure on the phone.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>unity3d</category>
      <category>reactnative</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I built an Asana connector for cognee, and my first version got deleting wrong</title>
      <dc:creator>Abdul Wasih</dc:creator>
      <pubDate>Sat, 31 Oct 2026 17:51:06 +0000</pubDate>
      <link>https://dev.to/iamwasih/i-built-an-asana-connector-for-cognee-and-my-first-version-got-deleting-wrong-848</link>
      <guid>https://dev.to/iamwasih/i-built-an-asana-connector-for-cognee-and-my-first-version-got-deleting-wrong-848</guid>
      <description>&lt;p&gt;My Asana connector for cognee got merged during Mergetober. It's a small package, but it taught me more about syncing data than anything I've built before.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;cognee turns your documents into a knowledge graph you can ask questions. My connector is a dlt source that feeds it your Asana projects. Every task becomes a document with its notes, custom fields, subtasks and comments, and each project description becomes one too. Then you can ask things like "what's blocking the website launch?" and the answer covers all your tasks, not just the one you happen to have open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting it up
&lt;/h2&gt;

&lt;p&gt;It isn't on PyPI yet, so you install it from the cognee-community repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/topoteretes/cognee-community
&lt;span class="nb"&gt;cd &lt;/span&gt;cognee-community/packages/connector/asana
uv &lt;span class="nb"&gt;sync&lt;/span&gt; &lt;span class="nt"&gt;--all-extras&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create a personal access token in Asana under Settings &amp;gt; Apps &amp;gt; Developer apps, and export it as &lt;code&gt;ASANA_ACCESS_TOKEN&lt;/code&gt; along with your usual &lt;code&gt;LLM_API_KEY&lt;/code&gt;. Your project gid is the number in the project URL. Then:&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cognee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remember&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;asana_source&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project_gids&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;1201234567890123&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
    &lt;span class="n"&gt;dataset_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;asana&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;primary_key&lt;/span&gt;&lt;span class="o"&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;write_disposition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_rows_per_table&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;write_disposition="merge"&lt;/code&gt; matters. The default, &lt;code&gt;replace&lt;/code&gt;, would drop every document that isn't sent again, and after the first run the connector only sends what changed. The connector only ever sends &lt;code&gt;GET&lt;/code&gt; requests, so it can't change anything in your workspace.&lt;/p&gt;

&lt;h2&gt;
  
  
  The surprise: edits that Asana doesn't report
&lt;/h2&gt;

&lt;p&gt;I tested this on a free workspace. Adding a comment updates a task's &lt;code&gt;modified_at&lt;/code&gt;. Editing a comment doesn't. Adding a subtask does, but renaming or deleting one doesn't. So a "fetch what changed since last time" cursor quietly misses those edits.&lt;/p&gt;

&lt;p&gt;The fix was Asana's Events API. It does report edited comments, so the connector uses it on top of the cursor. An edited-comment event doesn't say which task it belongs to, so the connector looks it up through the event's target. For subtasks, it keeps a small map from subtask to parent, because a deleted subtask returns 404 and can't tell you its parent anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hard part: when is it safe to forget something?
&lt;/h2&gt;

&lt;p&gt;If a task disappears from the project listing, it's deleted, and the connector forgets it. The tricky case is a task that's still listed but returns 403 when you fetch it. Was it hidden from you, did your token lose a permission, or is Asana just having a bad minute? Only the first is a reason to forget it.&lt;/p&gt;

&lt;p&gt;My first version stopped the run after three failures in a row. goran-radonic, the maintainer, showed me that three permanently hidden tasks next to each other would then break every future run, and new tasks would never come in. That was a real bug, and a fair catch.&lt;/p&gt;

&lt;p&gt;The merged version only forgets a task after three runs over at least 24 hours, and only in runs that proved access to other tasks works. Runs that stop early don't count. An outage or a lost permission never deletes anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other things the review fixed
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A token with a trailing newline made &lt;code&gt;requests&lt;/code&gt; raise an error that included the token. Now errors carry only the status and endpoint.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;Retry-After&lt;/code&gt; of a full day would have slept while cognee holds its lock. Waits are now capped at 60 seconds, with 120 seconds in total per run.&lt;/li&gt;
&lt;li&gt;Asana OAuth tokens last an hour, so a 401 halfway through a long first sync now stops cleanly and the next run continues.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Trying it for real
&lt;/h2&gt;

&lt;p&gt;I ran the example twice on my own Asana workspace. Between the runs I edited a comment and deleted a task. The second run fetched just the edited task, and the deleted one was gone from cognee's answers. 109 tests pass locally on cognee 1.6.2 and 1.6.5.&lt;/p&gt;

&lt;p&gt;The review made the code a lot better than what I first submitted. Thanks, goran.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PR: &lt;a href="https://github.com/topoteretes/cognee-community/pull/349" rel="noopener noreferrer"&gt;https://github.com/topoteretes/cognee-community/pull/349&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Issue: &lt;a href="https://github.com/topoteretes/cognee/issues/4700" rel="noopener noreferrer"&gt;https://github.com/topoteretes/cognee/issues/4700&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>github</category>
      <category>opensource</category>
      <category>python</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>Node.js Logistics Notifications: A 3-Layer Contract for Auditable Email and SMS</title>
      <dc:creator>felixhoffmann556</dc:creator>
      <pubDate>Sat, 31 Oct 2026 17:51:03 +0000</pubDate>
      <link>https://dev.to/felixhoffmann556/nodejs-logistics-notifications-a-3-layer-contract-for-auditable-email-and-sms-4n7g</link>
      <guid>https://dev.to/felixhoffmann556/nodejs-logistics-notifications-a-3-layer-contract-for-auditable-email-and-sms-4n7g</guid>
      <description>&lt;p&gt;TL;DR: Treat a logistics notification as an auditable event, not a call to an email or SMS API. Persist the report decision, enqueue delivery, and reconcile provider status with a polling job. Choose Amazon SES when email control and AWS locality matter, Twilio when SMS reach and messaging workflows matter, SendGrid when email tooling is the center of gravity, and a unified provider such as Infrai when keeping the application contract stable across channels matters more than channel-specific features.&lt;/p&gt;

&lt;p&gt;The practical unit is a delivery record. It should point to the generated report, the recipient, the selected channel, the provider request ID, and the observed status. That record is the evidence a compliance reviewer can inspect later. It is also the boundary that lets a Node.js service retry without generating a second report or sending a duplicate alert. These are the notification wiring practices I want in a SaaS app before the first production exception is sent.&lt;/p&gt;

&lt;p&gt;Keep the event boring.&lt;/p&gt;

&lt;p&gt;That choice matters.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Pick this when&lt;/th&gt;
&lt;th&gt;Trade-off for a logistics report&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Amazon SES&lt;/td&gt;
&lt;td&gt;Your mail path already lives in AWS and you need tight email control&lt;/td&gt;
&lt;td&gt;Excellent email fit; SMS needs a separate service and a second operational contract&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SendGrid&lt;/td&gt;
&lt;td&gt;Email templates, sender tooling, and email event work are the main concern&lt;/td&gt;
&lt;td&gt;Strong email workflow; multi-channel delivery still requires another provider&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Twilio&lt;/td&gt;
&lt;td&gt;SMS is the primary escalation channel or messaging workflows are central&lt;/td&gt;
&lt;td&gt;Broad messaging surface; email and SMS still have different concepts to reconcile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infrai&lt;/td&gt;
&lt;td&gt;One REST contract across email and SMS is the useful abstraction&lt;/td&gt;
&lt;td&gt;The two namespaces use pull-based status reconciliation, so polling and audit storage remain application responsibilities&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What are the best practices for wiring email and SMS notification events in Node.js SaaS apps?
&lt;/h2&gt;

&lt;p&gt;Start with an immutable event. A report such as &lt;code&gt;route-exceptions-2026-10-31&lt;/code&gt; is a business artifact; “send it” is a delivery attempt. Mixing them makes retries dangerous.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Channel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sms&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;DeliveryStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;queued&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;submitted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;delivered&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;NotificationEvent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;reportId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;channel&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="nl"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DeliveryStatus&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;providerRequestId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;lastError&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;eventId&lt;/code&gt; is your audit key. The &lt;code&gt;idempotencyKey&lt;/code&gt; is your retry key. Keep them stable across attempts. A worker may be restarted after a provider accepted the request but before the response reached the process; a new random key at that point turns an ordinary timeout into a possible duplicate.&lt;/p&gt;

&lt;p&gt;For the generated attachment, store a durable report reference rather than putting the report bytes in the event row. Record the report checksum and the time at which the delivery was authorized. That gives reviewers a clear chain: report version, recipient decision, attempted channel, provider response, and later status observation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which provider fits the delivery path?
&lt;/h2&gt;

&lt;p&gt;Amazon SES is a good fit when the application already has AWS identity, storage, and operational controls. Its email-first shape keeps the report path familiar, but adding SMS means designing a second integration and joining its evidence to the mail record. Read the &lt;a href="https://docs.aws.amazon.com/ses/latest/dg/send-email.html" rel="noopener noreferrer"&gt;SES sending guide&lt;/a&gt; before committing to the exact message and attachment model.&lt;/p&gt;

&lt;p&gt;SendGrid suits a team that wants email composition and email event tooling near the center of the workflow. It is less attractive when the main requirement is one identical application contract for two channels. Its &lt;a href="https://www.twilio.com/docs/sendgrid/for-developers/tracking-events/getting-started-event-webhook" rel="noopener noreferrer"&gt;event webhook documentation&lt;/a&gt; is also a useful reminder that provider events have their own delivery and security concerns.&lt;/p&gt;

&lt;p&gt;Twilio is the natural candidate when SMS is the urgent escalation path: a driver or operations lead may need a short exception notice before an email is opened. Its messaging product has a different vocabulary and lifecycle from email. That is fine when SMS is primary; it creates more joining work when both channels must produce one compliance timeline. The &lt;a href="https://www.twilio.com/docs/messaging/api" rel="noopener noreferrer"&gt;Twilio Messaging API&lt;/a&gt; documents that channel's contract.&lt;/p&gt;

&lt;p&gt;Infrai fits the narrower case where the service should keep one REST-shaped provider boundary while the capability behind that boundary changes. The application can preserve its &lt;code&gt;NotificationEvent&lt;/code&gt; contract and select email or SMS inside the adapter. Its supporting value here is consistent request metadata for cost, latency, vendor, cache state, and request ID, which gives an operations record more context without making those fields part of the business event.&lt;/p&gt;

&lt;p&gt;Do not choose from a feature checklist alone. Ask where the evidence lives, how quickly status must be reconciled, and which team owns the second channel. The right answer can differ by notification class: email for the report, SMS for a high-severity exception, and one shared event ledger for both.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you make retries observable in Node.js?
&lt;/h2&gt;

&lt;p&gt;Use a worker with explicit state transitions. The queue is not the audit log. The provider response is not the audit log either. Persist both the attempt and the observed status, then make a retry decision from those records.&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;NotificationAdapter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;submit&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;NotificationEvent&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&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;accepted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;delivered&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending&lt;/span&gt;&lt;span class="dl"&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="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;deliver&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;NotificationEvent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;adapter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NotificationAdapter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;save&lt;/span&gt;&lt;span class="p"&gt;:&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;NotificationEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;delivered&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;try&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;adapter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submit&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;providerRequestId&lt;/span&gt; &lt;span class="o"&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;requestId&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;status&lt;/span&gt; &lt;span class="o"&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;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;accepted&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;submitted&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;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="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&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;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&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;lastError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&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;save&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="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;reconcile&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;NotificationEvent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;adapter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NotificationAdapter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;save&lt;/span&gt;&lt;span class="p"&gt;:&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;NotificationEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;providerRequestId&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;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;delivered&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;observed&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;adapter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&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;providerRequestId&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;observed&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;delivered&lt;/span&gt;&lt;span class="dl"&gt;"&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;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;delivered&lt;/span&gt;&lt;span class="dl"&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;observed&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&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;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;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="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The adapter is deliberately boring. Its email implementation can call &lt;code&gt;/v1/email/send&lt;/code&gt;; its SMS implementation can call &lt;code&gt;/v1/sms/send&lt;/code&gt;. Keep those provider details behind the interface, and pass the stable event ID as the client idempotency value where the provider supports that convention. The worker should use an explicit HTTP method, bearer authentication from an environment variable, response-status checks, and exponential backoff for HTTP 429 responses. A failed response belongs in the record with its reason, not in a discarded log line.&lt;/p&gt;

&lt;p&gt;Here is the provider-facing part of that boundary. The request body stays an argument because email attachment fields are provider-specific; the route, authentication, retry behavior, and error handling are the stable parts of the integration.&lt;/p&gt;

&lt;p&gt;Picture the failure that is easiest to miss. The worker submits a report, the provider accepts it, and the process dies while waiting for the response. On restart, a naive worker sees &lt;code&gt;submitted&lt;/code&gt; missing and sends again. With a stable event ID, the retry carries the same idempotency key; with the saved provider request ID, reconciliation can query the original attempt. The audit row then tells a reviewer why the worker retried, which request was observed, and whether the final state was delivered or failed. This is more useful than a log line saying “send succeeded,” especially when a route exception is reviewed weeks after the report was generated.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;InfraiSendPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/v1/email/send&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/v1/sms/send&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;submitWithInfrai&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;InfraiSendPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;requestBody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&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;accepted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&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;apiKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;INFRAI_API_KEY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INFRAI_API_KEY is required&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&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="mi"&gt;4&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;apiOrigin&lt;/span&gt; &lt;span class="o"&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;https://api&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;infrai&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;cc&lt;/span&gt;&lt;span class="dl"&gt;"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.&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;response&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;apiOrigin&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;apiKey&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&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;application/json&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;Idempotency-Key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;requestBody&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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="mi"&gt;3&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;retryAfter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;retry-after&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;delayMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;retryAfter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;retryAfter&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;250&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&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;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;delayMs&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
      &lt;span class="k"&gt;continue&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;responseBody&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;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
      &lt;span class="nl"&gt;error&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&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="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="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Infrai &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="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;responseBody&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requestId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;responseBody&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;request_id&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;requestId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Infrai response did not include a request ID&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;requestId&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;accepted&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;:&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a unified REST provider, changing the provider behind this adapter does not change the event contract. That is the useful portability boundary. It does not remove the need to understand each channel's policy, recipient consent, or status semantics.&lt;/p&gt;

&lt;p&gt;The operational benefit is also concrete: Infrai uses one key and one REST API across its capability surface, so this worker does not need another SDK install or another credential just to add the second notification channel. Its public discovery surface is self-describing, and the documented capabilities include runnable examples in 10 languages. Those details reduce integration friction; they do not replace the compliance ledger.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does polling change about compliance evidence?
&lt;/h2&gt;

&lt;p&gt;Both notification namespaces in this scenario use pull-based events; there is no webhook event push. Schedule reconciliation as a separate job. For email, pull the email event list. For SMS, look up the SMS event or status by its request ID. Store the observation time and the raw status category you are permitted to retain.&lt;/p&gt;

&lt;p&gt;That delay is an architectural fact. A dashboard saying “submitted” is not evidence that a recipient received a report. The evidence becomes stronger as the record moves from queued to submitted to delivered or failed, with each transition carrying an observation timestamp. If a status remains pending, say pending. Do not infer delivery from the absence of an error.&lt;/p&gt;

&lt;p&gt;The same rule applies to alerts. A retry should be scheduled from a known failure or an explicit timeout policy. SMS geographic fraud controls and country-based spend cutoffs belong in the business layer. They are important guardrails, but they are not provider-neutral magic fields.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits to put in the design review
&lt;/h2&gt;

&lt;p&gt;Email has no managed OTP interface in this capability set, so an email verification fallback requires application-owned code. Scheduled email cannot be cancelled through an email cancel route, while SMS has cancellation support. There is no SMTP relay, and this path does not cover voice, WhatsApp, or RCS.&lt;/p&gt;

&lt;p&gt;There is also no tag-aggregated cost reporting API, and SMS templates have no list interface. A team that needs those views must maintain its own catalog and ledger. Finally, a pending domestic email vendor cannot be used as domestic compliance evidence. That decision requires a separately verified provider and policy review.&lt;/p&gt;

&lt;p&gt;The durable decision rule is straightforward: select the provider by the channel and evidence requirements, then make the application contract own identity, retries, reconciliation, and audit history. The provider can change behind that boundary. The evidence trail cannot.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc7208" rel="noopener noreferrer"&gt;RFC 7208: Sender Policy Framework&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/ses/latest/dg/send-email.html" rel="noopener noreferrer"&gt;Amazon SES: Sending email&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.twilio.com/docs/messaging/api" rel="noopener noreferrer"&gt;Twilio Messaging API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.twilio.com/docs/sendgrid/for-developers/tracking-events/getting-started-event-webhook" rel="noopener noreferrer"&gt;Twilio SendGrid Event Webhook&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>node</category>
      <category>observability</category>
      <category>email</category>
      <category>sms</category>
    </item>
    <item>
      <title>I Built an AI Ghost That Brings Dead Codebases Back to Life 👻</title>
      <dc:creator>Anshika Shukla</dc:creator>
      <pubDate>Sat, 31 Oct 2026 17:51:02 +0000</pubDate>
      <link>https://dev.to/anshhikashukla/i-built-an-ai-ghost-that-brings-dead-codebases-back-to-life-32n</link>
      <guid>https://dev.to/anshhikashukla/i-built-an-ai-ghost-that-brings-dead-codebases-back-to-life-32n</guid>
      <description>&lt;h1&gt;
  
  
  👻 Meet REPOREVENANT — Bringing Dead Codebases Back to Life!
&lt;/h1&gt;

&lt;p&gt;Ever opened an unfamiliar or abandoned GitHub repository and struggled to understand how it works?&lt;/p&gt;

&lt;p&gt;That's the problem I wanted to solve with &lt;strong&gt;REPOREVENANT&lt;/strong&gt;!&lt;/p&gt;

&lt;h3&gt;
  
  
  🚀 What does it do?
&lt;/h3&gt;

&lt;p&gt;REPOREVENANT analyzes GitHub repositories using source code, commit history, and documentation to help developers understand and revive old projects.&lt;/p&gt;

&lt;h3&gt;
  
  
  ✨ Key Features
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Repository file and commit analysis.&lt;/li&gt;
&lt;li&gt;AI-powered Ghost Chat for asking questions about code.&lt;/li&gt;
&lt;li&gt;Evidence-backed answers with file and commit references.&lt;/li&gt;
&lt;li&gt;Detection of unresolved TODOs and potential issues.&lt;/li&gt;
&lt;li&gt;Proposed code repairs with a goal of safe sandbox testing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🛠️ Tech Stack
&lt;/h3&gt;

&lt;p&gt;Next.js, React, TypeScript, Node.js, Express.&lt;/p&gt;

&lt;h3&gt;
  
  
  🔗 Check it out!
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live Demo:&lt;/strong&gt; &lt;a href="https://halloween-4afo.vercel.app" rel="noopener noreferrer"&gt;https://halloween-4afo.vercel.app&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/anshhika-shukla/halloween" rel="noopener noreferrer"&gt;https://github.com/anshhika-shukla/halloween&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;🚧 Currently working on real LLM integration and sandbox-based code repairs.&lt;/p&gt;

&lt;p&gt;I'd love to hear your feedback!&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What would you ask the ghost of an abandoned codebase?&lt;/strong&gt; 👻&lt;/p&gt;

&lt;h1&gt;
  
  
  devchallenge #hf26challenge #opensource #AI #webdev #hackathon
&lt;/h1&gt;

</description>
    </item>
    <item>
      <title>Remote onboarding checklist: the first 30 days</title>
      <dc:creator>Sascha Mirow-Stanitzok</dc:creator>
      <pubDate>Sat, 31 Oct 2026 17:50:04 +0000</pubDate>
      <link>https://dev.to/sascha-ms/remote-onboarding-checklist-the-first-30-days-4hfo</link>
      <guid>https://dev.to/sascha-ms/remote-onboarding-checklist-the-first-30-days-4hfo</guid>
      <description>&lt;p&gt;A good remote onboarding checklist decides whether new team members feel at home after four weeks or are still wondering who they are actually allowed to ask. In the office, everyday life smooths over a lot: you sit next to someone, overhear conversations, get invited to lunch. Remotely, all of that disappears. Anyone who gets nothing but a laptop and a wiki link on day one ends up alone in front of a screen, guessing.&lt;/p&gt;

&lt;p&gt;This topic matters to me because the first weeks set the tone for the entire working relationship. So here is a plan for the first 30 days: what should happen before the first working day, what the first week looks like, what an onboarding buddy actually does, and how you can tell after a month whether it worked. On top of that, you get the complete checklist as a printable PDF.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;In short&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Remote onboarding works when you plan three things at once: access to knowledge, confidence with the tools and real relationships within the team. The key insight: remotely, relationships rarely form on the side, so you have to arrange them. The biggest pitfall is a first week made up of nothing but accounts, forms and documentation.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;What you will learn in this article&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What should be done before the first working day&lt;/li&gt;
&lt;li&gt;How to plan the first week day by day&lt;/li&gt;
&lt;li&gt;What an onboarding buddy does and how often you meet&lt;/li&gt;
&lt;li&gt;How to recognize a good first task&lt;/li&gt;
&lt;li&gt;Which questions belong in the 30-day conversation&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why remote onboarding needs its own checklist
&lt;/h2&gt;

&lt;p&gt;Onboarding covers everything that helps a new team member settle into the company and the team: professionally, organizationally and personally. That goes beyond classic job training, where someone gets the tools explained. In the office, a large part of it happens by chance. Remotely, on the other hand, only what someone deliberately plans actually happens.&lt;/p&gt;

&lt;p&gt;The GitLab Handbook, one of the most detailed public collections on distributed work, distinguishes three levels of remote onboarding: organizational (where do I find information?), technical (how do I use the tools?) and social (how do I build relationships?). I find this split helpful because it reveals a typical imbalance. The first two levels fit nicely into checklists and tickets, so they usually get done thoroughly. The social level feels soft and therefore tends to slip down the list.&lt;/p&gt;

&lt;p&gt;Yet that is exactly where the feeling of belonging comes from. If, after three weeks, someone knows nobody on the team they can “just quickly” ask, they work more slowly and start doubting their decision to take the job sooner. A remote onboarding checklist therefore has to cover all three levels, right from the start.&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%2Fpywpzj6t8eo48rs99z47.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%2Fpywpzj6t8eo48rs99z47.png" alt="Timeline in four phases: prepare, arrive, contribute, own – with the key tasks for each week" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The first 30 days at a glance: from preparation to the 30-day conversation&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Remote onboarding checklist for the week before
&lt;/h2&gt;

&lt;p&gt;The first working day really starts a week earlier. Everything you prepare here takes pressure off day one. Nothing drags on more than a start where your new colleague waits three hours for VPN access, wondering whether everyone forgot about her.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hardware and access
&lt;/h3&gt;

&lt;p&gt;Make sure the laptop arrives on time, with enough buffer for shipping and customs if someone lives abroad. Set up the accounts that are actually needed on day one: email, chat, calendar, video call tool, ticket system and wiki. Further access, for example to production systems, can come later. A short “Your first login” guide listing the steps in the right order also helps, because setting up multi-factor authentication and a password manager can otherwise easily eat half the morning.&lt;/p&gt;

&lt;h3&gt;
  
  
  People and calendar
&lt;/h3&gt;

&lt;p&gt;Pick an onboarding buddy and ask whether they really have time during the first two weeks. Then pre-fill the calendar for week one: your first 1:1, the team meeting, two or three get-to-know-you sessions and plenty of free blocks for reading and setting up. A packed calendar looks caring, but for new people it is exhausting. Leave too much breathing room rather than too little.&lt;/p&gt;

&lt;p&gt;Finally, send a short welcome message: when things start on day one, which link leads to the first call, who the buddy is and that nobody expects results on the first day. That sounds trivial. Even so, this very message removes the biggest uncertainty before the start, namely what is actually going to happen on Monday at nine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Week 1: arriving, not delivering
&lt;/h2&gt;

&lt;p&gt;The first week is about arriving. The new person should get to know the tools, be able to put names to the first faces and understand what the team is currently working on. Contributing productively is not expected yet. Say that explicitly, because many new team members put themselves under pressure to show something quickly.&lt;/p&gt;

&lt;p&gt;Here is what a first week can look like:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Day&lt;/th&gt;
&lt;th&gt;Focus&lt;/th&gt;
&lt;th&gt;In practice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Monday&lt;/td&gt;
&lt;td&gt;Welcome and setup&lt;/td&gt;
&lt;td&gt;Welcome in the team call (15 minutes), setting up access, first call with the buddy in the afternoon&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tuesday&lt;/td&gt;
&lt;td&gt;The team and its work&lt;/td&gt;
&lt;td&gt;First 1:1 with you, overview of product and architecture, joining the daily as a listener&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wednesday&lt;/td&gt;
&lt;td&gt;Tools and workflows&lt;/td&gt;
&lt;td&gt;Setting up the development environment, walking through the path from ticket to deployment once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Thursday&lt;/td&gt;
&lt;td&gt;Getting to know people&lt;/td&gt;
&lt;td&gt;Two get-to-know-you sessions, a pairing session with someone from the team&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Friday&lt;/td&gt;
&lt;td&gt;Looking back&lt;/td&gt;
&lt;td&gt;Short check-in with you: what helped, what is missing, what is unclear?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The most important meeting of the week is the buddy call at the end of day one. After eight hours full of new impressions, you need someone who asks: “How was it? What stuck, what didn’t?” The GitLab Handbook explicitly calls this first call the most important task of the buddy.&lt;/p&gt;

&lt;p&gt;Your first 1:1 with the new person deserves its own preparation. How to structure it and which questions you can ask is covered in the &lt;a href="https://sascha-ms.de/en/first-one-on-one-new-employees?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;guide to the first one-on-one&lt;/a&gt;, the first part of this series.&lt;/p&gt;

&lt;h3&gt;
  
  
  The get-to-know-you list
&lt;/h3&gt;

&lt;p&gt;A simple tool with a big impact is a get-to-know-you list. You write down five to eight people the new team member should meet during the first month, each with one sentence explaining why. For example: “Jana, product owner – knows why the current feature is built the way it is” or “Malik, operations – helps you when a deployment gets stuck”. The new person then schedules the meetings themselves.&lt;/p&gt;

&lt;p&gt;This has two advantages. First, conversations get a clear reason, which is easier for reserved people in particular than an open “Let me know if you need anything”. Second, the new person learns how people approach each other on your team. Give the people on the list a heads-up beforehand so they are not caught off guard.&lt;/p&gt;

&lt;h2&gt;
  
  
  The onboarding buddy: tasks and rhythm
&lt;/h2&gt;

&lt;p&gt;An onboarding buddy is a colleague from the team who is the first point of contact for all the small questions during the first weeks. They are not a manager and they do not evaluate anything. Their job is to make sure nobody is left alone with a question they don’t dare to ask.&lt;/p&gt;

&lt;p&gt;The GitLab Handbook describes a clear rhythm: the first call on day one, then at least two more calls in the first week and at least one more during the rest of the first month. In addition, it recommends asking the new person whether they want more or fewer meetings. I consider this point important, since some people want a short daily chat while others prefer one longer conversation per week.&lt;/p&gt;

&lt;h3&gt;
  
  
  Who to choose as a buddy
&lt;/h3&gt;

&lt;p&gt;Among its criteria, GitLab lists: at least three months with the company, the same department, a similar time zone and availability during the first two weeks. I would add one more: the buddy should actually want to do it. Someone who only takes on the role because nobody else is around will quickly start cancelling meetings. Also plan a backup in case the buddy gets sick or goes on vacation.&lt;/p&gt;

&lt;p&gt;The time spent in the buddy role is real work. Expect a few hours during the first weeks and plan them into the sprint instead of expecting them to happen on the side. Otherwise, the buddy has to choose between their own tasks and the new colleague, and usually both lose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Week 2: the first real task
&lt;/h2&gt;

&lt;p&gt;In the second week, things can get concrete. The first task of their own is an important moment, because it shows that the new person is really needed. At the same time, it tests your processes: how long does it take for a small change to go live? Where does it get stuck?&lt;/p&gt;

&lt;p&gt;A good first task is small enough to finish in two or three days, yet still covers the whole path from ticket through review to deployment. For example, a small improvement to an error message, a missing test or a gap in the documentation noticed during onboarding itself. Unsuitable, on the other hand, are tasks that require diving deep into unfamiliar code, or urgent tasks that create time pressure.&lt;/p&gt;

&lt;p&gt;Plan at least one pairing session in which someone from the team works on the task together with the new person. Remotely, this replaces looking over someone’s shoulder. It is also worth explicitly inviting the new person to fix documentation whenever they run into gaps. New people spot gaps that everyone else stopped noticing long ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  Weeks 3 and 4: responsibility grows
&lt;/h2&gt;

&lt;p&gt;From the third week on, onboarding gradually turns into regular teamwork. The new person joins all rituals, picks up tickets from the regular backlog and no longer just listens in the daily. Which rituals hold a remote team together is something I describe in detail in the article &lt;a href="https://sascha-ms.de/en/leading-remote-teams-rituals?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;Leading remote teams: rituals that actually work&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;During this phase, pay particular attention to quiet signals. Is the person asking fewer questions than at the beginning because they have understood everything, or because they no longer dare to? Remotely, you won’t see it in their face at the coffee machine. So ask directly in the 1:1 whether there are things that are still unclear but now “feel weird to ask”. This phrasing opens a door surprisingly often.&lt;/p&gt;

&lt;h3&gt;
  
  
  The 30-day conversation
&lt;/h3&gt;

&lt;p&gt;After one month, a dedicated conversation that goes beyond the regular 1:1 is worth it. It has two goals: finding out how the new person is doing, and learning how to improve your onboarding. These questions work well as a framework:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What helped you most during the first weeks?&lt;/li&gt;
&lt;li&gt;When did you first feel like part of the team?&lt;/li&gt;
&lt;li&gt;What surprised you, positively or negatively?&lt;/li&gt;
&lt;li&gt;Which question haven’t you dared to ask so far?&lt;/li&gt;
&lt;li&gt;What should we do differently in the next onboarding?&lt;/li&gt;
&lt;li&gt;What do you need from me over the next 60 days?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The answers to the second-to-last question are pure gold. Write them down and adjust your remote onboarding checklist accordingly. That way, onboarding gets a little better with every new team member.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes in remote onboarding
&lt;/h2&gt;

&lt;p&gt;The most common mistake is a first week made up of nothing but forms, mandatory training and documentation. All of that is necessary, but if nobody has asked the new person how they are doing by Friday, one feeling sticks above all: here, I am just a case file.&lt;/p&gt;

&lt;p&gt;A second classic is “Let me know if you need anything”. It is meant kindly. In practice, though, it puts all the responsibility on the person who knows least about what they need and whom they can ask. Scheduled meetings and the get-to-know-you list solve this better.&lt;/p&gt;

&lt;p&gt;Third: too much at once. Anyone who gets twelve intro calls, three training sessions and the complete architecture explained in the first week retains very little of it. Spread the content across the month and allow time to process what was heard.&lt;/p&gt;

&lt;p&gt;And finally: no wrap-up. If onboarding simply fizzles out, you miss the moment when you look back together. The 30-day conversation closes this gap and at the same time gives you the feedback you need for the next onboarding.&lt;/p&gt;

&lt;h2&gt;
  
  
  My take
&lt;/h2&gt;

&lt;p&gt;I consider onboarding one of the most underrated leadership tasks of all. A lot of energy goes into selecting new people, into job ads and interviews. Once the signature is in, however, things often go quiet, and the first working day becomes a task for the IT department.&lt;/p&gt;

&lt;p&gt;Yet you will never again get as much honest feedback about your team as during a new member’s first weeks. New people see what has long become normal for everyone else: unclear processes, outdated documentation, unwritten rules. Make use of that instead of smoothing it over.&lt;/p&gt;

&lt;p&gt;I want to push back on the idea that remote onboarding is inherently worse than onboarding in the office. It is different and requires more planning. In return, it forces you to write down things that otherwise only live in people’s heads. In the end, the whole team benefits, not just the new colleague.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How long does remote onboarding take?
&lt;/h3&gt;

&lt;p&gt;For actually settling in, 30 days is a good frame. Until someone works truly independently within the team, it takes considerably longer depending on the role. So keep regular 1:1s going after the first month as well.&lt;/p&gt;

&lt;h3&gt;
  
  
  What belongs in a remote onboarding checklist?
&lt;/h3&gt;

&lt;p&gt;At a minimum: hardware and access before day one, a pre-planned calendar for week one, an onboarding buddy, a get-to-know-you list, a small first task with pairing and a wrap-up conversation after 30 days.&lt;/p&gt;

&lt;h3&gt;
  
  
  How often should an onboarding buddy check in?
&lt;/h3&gt;

&lt;p&gt;A proven rhythm: one call on day one, two more in the first week and at least one more until the end of the month. Ask the new person whether this pace suits them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Who is responsible for onboarding?
&lt;/h3&gt;

&lt;p&gt;The manager is responsible and plans the framework. The buddy, the team and the new person shape it together. Organizational matters such as hardware and accounts can be handled by IT or HR.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your next step
&lt;/h2&gt;

&lt;p&gt;Take the checklist and go through it for the next person who joins your team. If nobody is starting right now, ask the team member who joined most recently: what would have helped you during your first 30 days? The answers will show you very precisely where your onboarding has gaps today.&lt;/p&gt;

&lt;p&gt;I’m curious how you handle it: what was the best thing that happened to you in a remote onboarding? Feel free to share it in the comments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Continue the series:&lt;/strong&gt; Back to the &lt;a href="https://sascha-ms.de/en/series/leadership-in-brief?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;“Leadership in brief” overview&lt;/a&gt; · Previous part: &lt;a href="https://sascha-ms.de/en/leading-remote-teams-rituals?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;Leading remote teams with rituals that work&lt;/a&gt;. Next part: &lt;a href="https://sascha-ms.de/en/offboarding-checklist?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;Offboarding: when someone leaves the team&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;📄 &lt;strong&gt;Printable cheat sheet&lt;/strong&gt; – The complete 30-day remote onboarding checklist to tick off – with buddy rhythm and questions for the 30-day conversation. &lt;a href="https://sascha-ms.de/wp-content/uploads/2026/10/cheat-sheet-remote-onboarding-checklist.pdf" rel="noopener noreferrer"&gt;Download PDF&lt;/a&gt; · &lt;a href="https://sascha-ms.de/en/cheat-sheets/remote-onboarding-checklist?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;View online&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://handbook.gitlab.com/handbook/people-group/general-onboarding/onboarding-buddies/" rel="noopener noreferrer"&gt;GitLab Handbook: GitLab Onboarding Buddies&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://handbook.gitlab.com/handbook/company/culture/all-remote/onboarding/" rel="noopener noreferrer"&gt;GitLab Handbook: The complete guide to remote onboarding for new hires&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;This post was created with AI assistance.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👉 &lt;strong&gt;Next step:&lt;/strong&gt; Preparing a first one-on-one? My &lt;a href="https://sascha-ms.de/en/cheat-sheets/first-one-on-one-questions/?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;question cheat sheet&lt;/a&gt; puts the best questions on one printable page.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://sascha-ms.de/en/remote-onboarding-checklist?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;sascha-ms.de&lt;/a&gt;.&lt;/em&gt; More articles on serverless, AI in everyday development and leading tech teams: &lt;a href="https://sascha-ms.de/en/?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;sascha-ms.de/en&lt;/a&gt;&lt;/p&gt;

</description>
      <category>career</category>
      <category>management</category>
      <category>remote</category>
    </item>
    <item>
      <title>Deleting files safely from a Tauri app: what I learned sending them to the Windows Recycle Bin from Rust</title>
      <dc:creator>Dare</dc:creator>
      <pubDate>Sat, 31 Oct 2026 17:49:15 +0000</pubDate>
      <link>https://dev.to/darecallad/deleting-files-safely-from-a-tauri-app-what-i-learned-sending-them-to-the-windows-recycle-bin-from-agp</link>
      <guid>https://dev.to/darecallad/deleting-files-safely-from-a-tauri-app-what-i-learned-sending-them-to-the-windows-recycle-bin-from-agp</guid>
      <description>&lt;p&gt;I'm building a disk cleaner for Windows in Rust and Tauri 2. Finding junk isn't the scary part. Deleting it is. If the scanner has a bug, you see a wrong number. If the delete path has a bug, someone loses files.&lt;/p&gt;

&lt;p&gt;So the delete path ended up with more code and more tests than anything else in the app. Here's what it does and why. All the snippets are from the real source, trimmed a bit, with the file name on top.&lt;/p&gt;

&lt;p&gt;One thing up front: not everything my app removes goes to the Recycle Bin. I'll get to that at the end, because I only fixed how the UI talks about it this week.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The webview only gets to delete what a scan returned
&lt;/h3&gt;

&lt;p&gt;In Tauri the UI is a webview calling Rust commands. I treat it like any other client I don't control. If a command takes a list of paths, those paths could be anything.&lt;/p&gt;

&lt;p&gt;So the backend remembers what each scan returned, and only those paths can be trashed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src-tauri/src/files/guard.rs (simplified)&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Approved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;HashMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ScanKind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HashSet&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;APPROVED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;LazyLock&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Mutex&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Approved&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;LazyLock&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Mutex&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="cd"&gt;/// Records the files a scan returned to the UI: only these can be trashed.&lt;/span&gt;
&lt;span class="cd"&gt;/// Protected paths are never recorded.&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="n"&gt;approve&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nv"&gt;'a&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ScanKind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="nb"&gt;IntoIterator&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;'a&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;set&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;paths&lt;/span&gt;
        &lt;span class="nf"&gt;.into_iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.filter&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;is_protected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&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;.map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;str&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;to_owned&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.collect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;approved&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;set&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;code&gt;trash_files&lt;/code&gt; checks every path against that set. Anything else is reported as failed and left alone. There's a test for it: a file no scan returned gets reported, and it's still on disk afterwards.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. "Inside C:\Windows" means folder boundaries
&lt;/h3&gt;

&lt;p&gt;The protected list is what you'd expect. &lt;code&gt;%SystemRoot%&lt;/code&gt;, Program Files and ProgramData. &lt;code&gt;$Recycle.Bin&lt;/code&gt; and &lt;code&gt;System Volume Information&lt;/code&gt; anywhere. &lt;code&gt;pagefile.sys&lt;/code&gt;, &lt;code&gt;Recovery&lt;/code&gt;, &lt;code&gt;Windows.old&lt;/code&gt; and friends at a drive root. Network shares get refused too.&lt;/p&gt;

&lt;p&gt;The comparison is where it's easy to slip. A plain &lt;code&gt;starts_with&lt;/code&gt; thinks &lt;code&gt;c:\windowsill\notes.txt&lt;/code&gt; is inside &lt;code&gt;c:\windows&lt;/code&gt;. So paths get normalised first (no &lt;code&gt;\\?\&lt;/code&gt; prefix, lower case, no trailing separator) and then compared on folder boundaries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src-tauri/src/files/guard.rs&lt;/span&gt;
&lt;span class="cd"&gt;/// `c:\data` contains `c:\data\x` but not `c:\database`.&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;is_within&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="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="nf"&gt;.strip_prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="nf"&gt;.ends_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SEP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;rest&lt;/span&gt;&lt;span class="nf"&gt;.starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SEP&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nb"&gt;None&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;false&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;Yes, there's a test for &lt;code&gt;C:\Windowsill\notes.txt&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. A path can change between the scan and the delete
&lt;/h3&gt;

&lt;p&gt;By the time someone clicks "Move to Recycle Bin", the scan might be minutes old. In that time a folder on the path could have been swapped for a junction pointing somewhere else. Scans return resolved paths, so right before deleting I resolve the path again and refuse it if it doesn't resolve to itself anymore:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src-tauri/src/files/guard.rs&lt;/span&gt;
&lt;span class="cd"&gt;/// A regular file (not a link or folder) outside protected locations whose path&lt;/span&gt;
&lt;span class="cd"&gt;/// resolves to itself. A link or junction put in the way since the scan is refused.&lt;/span&gt;
&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;safe_size&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="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;u64&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;let&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&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;.symlink_metadata&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.ok&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Metadata&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;is_file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;real&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;canonicalize&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;.ok&lt;/span&gt;&lt;span class="p"&gt;()&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="n"&gt;real&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&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="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;is_protected_normalized&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;real&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="nf"&gt;.then_some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="nf"&gt;.len&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;On Windows &lt;code&gt;canonicalize&lt;/code&gt; also expands 8.3 short names like &lt;code&gt;PROGRA~1&lt;/code&gt;, which is one more way two strings can mean the same folder. The check runs again for every batch, right before Windows sees the paths, since an earlier batch might have sat on a dialog for a while.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The &lt;code&gt;trash&lt;/code&gt; crate and COM threads
&lt;/h3&gt;

&lt;p&gt;The actual move is done by the &lt;code&gt;trash&lt;/code&gt; crate (5.x). On Windows it initialises COM single-threaded. My app already had other commands (the program list, icons) setting COM to multithreaded on the shared command threads. Call &lt;code&gt;trash&lt;/code&gt; on one of those and you don't get an error. You get a panic.&lt;/p&gt;

&lt;p&gt;The fix is boring. Every Recycle Bin operation gets its own thread:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src-tauri/src/recycle.rs&lt;/span&gt;
&lt;span class="cd"&gt;/// `trash` initialises COM single-threaded and *panics* when the thread already uses&lt;/span&gt;
&lt;span class="cd"&gt;/// the multithreaded model. A new thread has no COM state, and a panic there only&lt;/span&gt;
&lt;span class="cd"&gt;/// fails this call.&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="n"&gt;on_own_thread&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Send&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="k"&gt;'static&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="nf"&gt;FnOnce&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Send&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="k"&gt;'static&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&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="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;thread&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;spawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&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="nf"&gt;.ok&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 you've got &lt;code&gt;windows&lt;/code&gt;-crate COM code and &lt;code&gt;trash&lt;/code&gt; in the same Tauri app, it's worth checking which thread each one runs on.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Batches, cancels, and "delete permanently?"
&lt;/h3&gt;

&lt;p&gt;One shell operation per file is slow. One giant operation fails as a whole if a single path is bad. I went with batches of 64, and if a batch fails before it even starts, the code falls back to one file at a time so one bad path doesn't hold up the rest.&lt;/p&gt;

&lt;p&gt;Windows has opinions too. If a file is too big for the Recycle Bin, Windows asks whether to delete it permanently. If the user says no, I shouldn't ask again for every remaining file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src-tauri/src/files/guard.rs (simplified)&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;TRASH_BATCH&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;usize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;USER_CANCELLED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;i32&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="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="mi"&gt;0x8007_04C7_u32&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;i32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// HRESULT_FROM_WIN32(ERROR_CANCELLED)&lt;/span&gt;
    &lt;span class="mi"&gt;0x8027_0000_u32&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;i32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// COPYENGINE_E_USER_CANCELLED&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;chunk&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;paths&lt;/span&gt;&lt;span class="nf"&gt;.chunks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TRASH_BATCH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="nf"&gt;.iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.filter&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nf"&gt;safe_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.is_some&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="nf"&gt;.collect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="nn"&gt;trash&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;delete_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(())&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
        &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;cancelled_by_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// the user stopped it&lt;/span&gt;
        &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;trash&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Unknown&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;        &lt;span class="c1"&gt;// ran, but the shell skipped some&lt;/span&gt;
        &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* failed before starting: retry one by one */&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;h3&gt;
  
  
  6. Let the disk say what moved
&lt;/h3&gt;

&lt;p&gt;The shell's return value doesn't tell you what happened to each file. So after the move, the code just looks. If a path still exists, it's a failure. Only paths that are actually gone count toward "bytes freed":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src-tauri/src/files/guard.rs&lt;/span&gt;
&lt;span class="c1"&gt;// The file system is the source of truth for what actually moved.&lt;/span&gt;
&lt;span class="k"&gt;for&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="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;targets&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="py"&gt;.failed&lt;/span&gt;&lt;span class="nf"&gt;.push&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="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="py"&gt;.bytes_freed&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="py"&gt;.deleted&lt;/span&gt;&lt;span class="nf"&gt;.push&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="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;That's why the number the app shows can be smaller than what you picked. Files that were in use stay put and show up as failed.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Being honest about what doesn't go to the Recycle Bin
&lt;/h3&gt;

&lt;p&gt;This is the part I got wrong.&lt;/p&gt;

&lt;p&gt;Large files, duplicates and similar photos go through everything above. Dev build folders (&lt;code&gt;node_modules&lt;/code&gt;, &lt;code&gt;target&lt;/code&gt; and so on) also go to the Recycle Bin, through a similar check in &lt;code&gt;recycle.rs&lt;/code&gt;. Temp files and caches don't. The cleanup rules in &lt;code&gt;junk_rules/mod.rs&lt;/code&gt; delete them with &lt;code&gt;std::fs::remove_file&lt;/code&gt;, and the confirm text says why: "These are caches and temporary files; apps rebuild them as needed." What they get instead is a couple of different guards. A rule is skipped while its app is running (the comment in the code: "deleting a live cache can corrupt it"), and One-Click Fix and the scheduler only touch files older than 24 hours.&lt;/p&gt;

&lt;p&gt;The code was fine with that. My welcome screen wasn't. It told people deleted files go to the Recycle Bin, which just isn't true for caches.&lt;/p&gt;

&lt;p&gt;This week (#103) I swapped the native confirm box for an in-app dialog. It lists what's about to go and shows one of two labels: &lt;strong&gt;"Can be restored from the Recycle Bin"&lt;/strong&gt; or &lt;strong&gt;"Permanent â€” not moved to the Recycle Bin"&lt;/strong&gt;. Cancel has focus by default. The welcome text now says: "Super Cleaner always asks first. Large files go to the Recycle Bin; caches and temp files are deleted for good."&lt;/p&gt;

&lt;p&gt;While I was in there I found 11 of my 26 confirms showing English buttons in the Chinese UI. Turns out that one was on me. My old &lt;code&gt;confirmAction()&lt;/code&gt; wrapper defaulted to &lt;code&gt;okLabel = "Continue"&lt;/code&gt; and &lt;code&gt;cancelLabel = "Cancel"&lt;/code&gt;, hardcoded in English, and 11 call sites never passed their own labels. Now &lt;code&gt;okLabel&lt;/code&gt; is required and Cancel defaults to the translated string.&lt;/p&gt;

&lt;h3&gt;
  
  
  If you're writing your own delete path
&lt;/h3&gt;

&lt;p&gt;Don't trust the UI with paths, and only delete what the backend found itself. Normalise before you compare, compare on folder boundaries, and resolve again right before the delete. If anything else in your process touches COM, give &lt;code&gt;trash&lt;/code&gt; its own thread. Assume the user, or Windows, will cancel halfway. Count what's actually gone from the disk. And tell people plainly what they can and can't undo, because that's the part I almost shipped wrong.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The code's from Super Cleaner, a Windows cleaner I'm building in Rust + Tauri. It's not public yet. I'll post here when it is.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>rust</category>
      <category>windows</category>
      <category>tauri</category>
      <category>security</category>
    </item>
  </channel>
</rss>
