<?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: Steven Wallace</title>
    <description>The latest articles on DEV Community by Steven Wallace (@stevenwallace).</description>
    <link>https://dev.to/stevenwallace</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3556322%2Fbca15260-890e-4add-b80f-87257b11d3b5.png</url>
      <title>DEV Community: Steven Wallace</title>
      <link>https://dev.to/stevenwallace</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/stevenwallace"/>
    <language>en</language>
    <item>
      <title>Cozy Jam Spring 2026 Retrospective</title>
      <dc:creator>Steven Wallace</dc:creator>
      <pubDate>Mon, 20 Apr 2026 07:06:55 +0000</pubDate>
      <link>https://dev.to/stevenwallace/cozy-jam-spring-2026-retrospective-38g6</link>
      <guid>https://dev.to/stevenwallace/cozy-jam-spring-2026-retrospective-38g6</guid>
      <description>&lt;p&gt;During the Cozy Jam Spring 2026, my team and I created Norwegian Spring, a cozy 3D bicycle game made in Unity. You ride through a peaceful and scenic Norwegian landscape with forests, piers, and Norwegian-style homes. Because this was a cozy game jam, we wanted to make something that feels calm and just engaging enough to keep the player interested.&lt;/p&gt;

&lt;p&gt;We were a four-person team: programmer/level designer, 3D artist, UI designer, and orchestrator. This is our retrospective.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzfvhdbakpo22zexmgnmy.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzfvhdbakpo22zexmgnmy.jpg" alt="Norwegian Spring Main Menu" width="800" height="485"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;The core loop is a bike on auto-pilot that follows a waypoint system through the level. The player can nudge left and right with the keyboard, press a speed boost button when it appears, ring the bell, and choose which path to take at branch points. Birds flock overhead, flags wave in the wind, and Norwegian-style houses line the roads in randomized color variants.&lt;/p&gt;

&lt;p&gt;The game jam had a limitation of making the game a seamless loop. The game needed to loop back to the start with no break in the gameplay.&lt;/p&gt;

&lt;h3&gt;
  
  
  Technical highlights
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Waypoint system with road-width detection so waypoints span the full road rather than a single point&lt;/li&gt;
&lt;li&gt;Seamless gameplay where the player continues along interconnected paths with no pause in the action&lt;/li&gt;
&lt;li&gt;Living Birds asset integration for ambient flocking&lt;/li&gt;
&lt;li&gt;Cloth simulation on flags for wind effect&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffsyoaimjxnqw012ou56y.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffsyoaimjxnqw012ou56y.jpg" alt="Norwegian Wood Instructions" width="800" height="486"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The Biggest Challenge: Cozy vs. Engaging
&lt;/h3&gt;

&lt;p&gt;Making a cozy game is harder than it sounds. Cozy means slow, calm, low-pressure, but that can easily cross into boring. We tried to balance the calmness with the speed boost mechanic and branching paths, giving players moments of agency without breaking the relaxed tone. &lt;/p&gt;

&lt;p&gt;In hindsight, the fast-moving player character was our biggest design mistake. When your player moves quickly through a 3D environment, they fly past everything before they can appreciate it. You need a lot more content to fill a level that the player travels through quickly. &lt;/p&gt;

&lt;p&gt;It helped me understand why we still don't have GTA 6.&lt;/p&gt;

&lt;p&gt;We could have just made the bike slower, but any slower and it would feel too slow paced. Without enough movement power, the bike would also not be able to make it up hills.&lt;/p&gt;

&lt;p&gt;For a jam with limited time, a slower-paced experience would have let us build a smaller, more polished world.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Went Well
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Shipping a full working 3D game with four people in two weeks is not easy, but we did it. Every team member contributed which helped us be able to combine programming, art, music, and design.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Our 3D artist delivered building meshes with full PBR texture sets (BaseColor, Normal, ARM maps) across multiple color variants.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Going into this jam, 3D level design was a weak point for me. I felt slow at it, but by the end I noticed that I had developed some muscle memory for things like placing terrain, implementing materials, and positioning assets.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;WebGL debugging. We hit a bug where the bike had no drive force at all despite the code looking correct. After systematic debugging by adding logs to trace verticalInput, checking for null references, and verifying script execution order, we found that a leftover BikeControlsExample script was overwriting the auto-controller's input every frame with keyboard input (which was zero). The script seemed to take priority when we switched the build to WebGL. It helped me learn best practices when debugging Unity. &lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3u1ln2go93186vu98nrx.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3u1ln2go93186vu98nrx.jpg" alt="Norwegian Wood Gameplay" width="800" height="489"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Lessons Learned
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;For a jam, slow players down. A fast player needs a bigger world. A slow player can appreciate a smaller, more detailed one.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;WebGL is not the same as a Windows/MacOS build. Physics, shaders, and asset sizes all behave differently. Budget time for platform-specific debugging if you're targeting WebGL.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;PBR texture sets are worth it. Even with basic lighting, proper BaseColor/Normal/ARM maps make assets look substantially better with minimal extra work in Unity.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The level designer and artist should have strong communication. I wasn't sure exactly what assets the 3D artist was going to make so it was tough to start designing. That sort of thing should be decided toward the start of the jam.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Play It
&lt;/h3&gt;

&lt;p&gt;Norwegian Spring is playable &lt;a href="https://iyasustudio.itch.io/norwegian-spring" rel="noopener noreferrer"&gt;here on itch.io&lt;/a&gt;. Give it a try, relax in the forest in Norway that we made, and let us know what you think!&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>unity3d</category>
      <category>csharp</category>
    </item>
    <item>
      <title>Building MIRROR: A Luxury AI Fashion Try-On App with Perfect Corp APIs</title>
      <dc:creator>Steven Wallace</dc:creator>
      <pubDate>Sat, 07 Mar 2026 04:31:20 +0000</pubDate>
      <link>https://dev.to/stevenwallace/building-mirror-a-luxury-ai-fashion-try-on-app-with-perfect-corp-apis-4o1n</link>
      <guid>https://dev.to/stevenwallace/building-mirror-a-luxury-ai-fashion-try-on-app-with-perfect-corp-apis-4o1n</guid>
      <description>&lt;h2&gt;
  
  
  Building MIRROR: A Luxury AI Fashion Try-On App
&lt;/h2&gt;

&lt;p&gt;I built MIRROR for DeveloperWeek 2026, which is a luxury fashion e-commerce prototype that lets users virtually try on clothing, shoes, bags, and earrings using AI. Here's how it works and what I learned building it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/swallace100/Virtual-Try-On-AI-Store-Mirror" rel="noopener noreferrer"&gt;View the repo here&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is MIRROR?
&lt;/h2&gt;

&lt;p&gt;MIRROR is a full-stack web app that combines a luxury-styled product browsing experience with AI-powered virtual try-on. A user can browse products, open a try-on modal, upload a photo of themselves, see an AI-generated preview of them wearing the item, and add it to their cart. All of this can be done in the same page flow.&lt;/p&gt;

&lt;p&gt;The app covers four product categories: clothing, shoes, bags, and earrings. &lt;/p&gt;

&lt;p&gt;Each category has its own AI endpoint and unique payload structure.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcx2ae6r524pf054qvxsu.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcx2ae6r524pf054qvxsu.jpg" alt="Mirror Home" width="800" height="422"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;MIRROR Home page (Photo by Cottonbro Studio)&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Tech Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Frontend:&lt;/strong&gt; React, React Router, Context API, custom CSS (no UI framework)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backend:&lt;/strong&gt; Node.js + Express&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI:&lt;/strong&gt; Perfect Corp S2S (Server-to-Server) APIs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Photo persistence:&lt;/strong&gt; localStorage (up to 10 saved user photos)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cart:&lt;/strong&gt; localStorage with quantity aggregation&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;The key architectural decision was keeping the frontend completely decoupled from Perfect Corp. The React app never calls Perfect Corp directly. All AI requests go through the Express backend.&lt;/p&gt;

&lt;p&gt;Client (React) → POST /api/tryon → Express Server → Perfect Corp S2S API&lt;/p&gt;

&lt;p&gt;This matters because Perfect Corp's APIs require publicly accessible URLs for both the user photo and the product reference image. The server handles saving the uploaded user photo to disk and constructing a public URL via ngrok.&lt;/p&gt;
&lt;h2&gt;
  
  
  Dynamic Endpoint Routing by Product Type
&lt;/h2&gt;

&lt;p&gt;One of the more interesting backend problems was that Perfect Corp has different endpoints for each product category, and each one expects a different payload shape. I solved this with a clean switch-based config lookup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getPerfectConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;productType&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;switch &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;productType&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cloth&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="na"&gt;startUrl&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://yce-api-01.makeupar.com/s2s/v2.0/task/cloth&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;earrings&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="na"&gt;startUrl&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://yce-api-01.makeupar.com/s2s/v2.0/task/2d-vto/earring&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;shoes&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="na"&gt;startUrl&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://yce-api-01.makeupar.com/s2s/v2.0/task/shoes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bag&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="na"&gt;startUrl&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://yce-api-01.makeupar.com/s2s/v2.0/task/bag&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;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;Then &lt;code&gt;buildPayload()&lt;/code&gt; constructs the right request body for each type. Clothing needs &lt;code&gt;garment_category&lt;/code&gt; and &lt;code&gt;change_shoes&lt;/code&gt;. Shoes and bags need a gender field. Earrings are the most complex. They require &lt;code&gt;ref_file_urls (array)&lt;/code&gt;, &lt;code&gt;source_info&lt;/code&gt;, and &lt;code&gt;object_infos&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp6zarsmvkh5co0asf4sa.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp6zarsmvkh5co0asf4sa.jpg" alt="Item page" width="800" height="423"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;MIRROR Item page (Photo by Cottonbro Studio)&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Task Polling Pattern
&lt;/h2&gt;

&lt;p&gt;The Perfect Corp API is asynchronous. You POST to start a task, get back a &lt;code&gt;task_id&lt;/code&gt;, then poll until &lt;code&gt;task_status&lt;/code&gt; === &lt;code&gt;success&lt;/code&gt;. I built a reusable poller:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;pollPerfect&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;pollBaseUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;intervalMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxAttempts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt; &lt;span class="p"&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;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&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="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;httpRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;"&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;pollBaseUrl&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;taskId&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="nx"&gt;headers&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;taskStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;task_status&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;taskStatus&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;success&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="nx"&gt;json&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;taskStatus&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Task 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;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;intervalMs&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Max attempts exceeded while polling&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;This polls every 2 seconds, up to 120 attempts (4 minutes). In practice, results come back in a few seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Photo Upload Flow
&lt;/h2&gt;

&lt;p&gt;On the frontend, the &lt;code&gt;TryOnModal&lt;/code&gt; component handles a few photo states:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;New upload: file input → &lt;code&gt;URL.createObjectURL()&lt;/code&gt; for instant preview&lt;/li&gt;
&lt;li&gt;Save to My Photos: &lt;code&gt;FileReader&lt;/code&gt; converts to &lt;code&gt;base64&lt;/code&gt; → saved to &lt;code&gt;localStorage&lt;/code&gt; (max 10 photos)&lt;/li&gt;
&lt;li&gt;Reuse saved photo: dropdown select from &lt;code&gt;localStorage&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;When the user clicks &lt;code&gt;Try It On&lt;/code&gt;, the selected photo's data URL gets sent to the backend as part of the POST body. The server decodes the &lt;code&gt;base64&lt;/code&gt;, writes it to &lt;code&gt;/uploads&lt;/code&gt;, and constructs a public URL for Perfect Corp to fetch.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;saveDataUrlToUploads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dataUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;filenameBase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&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;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dataUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^data:&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;image&lt;/span&gt;&lt;span class="se"&gt;\/[&lt;/span&gt;&lt;span class="sr"&gt;a-zA-Z0-9.+-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;;base64,&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;.+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ... parse mime + extension&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filepath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;makePublicUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/uploads/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;filename&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;req&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;
  
  
  Data-Driven Products
&lt;/h2&gt;

&lt;p&gt;Every product is defined in a single &lt;code&gt;products.json&lt;/code&gt; file. One reusable ProductPage component handles all of them. Each product carries the metadata the try-on flow needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bright Red Pumps"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;75&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"tag"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"New Season"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"image"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/images/products/red-pumps.jpg"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"shoes"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"gender"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"female"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"garment_category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"na"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"change_shoes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"na"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"sizes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Statement pumps in vivid red with crystal accents. A refined silhouette designed to elevate evening looks with effortless confidence."&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"details"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Red"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Crystal embellishments"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Buckle strap"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Leather sole"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding a new product means adding one JSON entry. No code changes needed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkc5mw4ul7txwsymlkhig.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkc5mw4ul7txwsymlkhig.jpg" alt="Virtual Try-on" width="800" height="428"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;MIRROR Virtual Try-On Modal (Photo by Donnie0102)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Cart System
&lt;/h2&gt;

&lt;p&gt;The cart uses &lt;code&gt;localStorage&lt;/code&gt; with a key of &lt;code&gt;mirror_cart&lt;/code&gt;. Adding the same product + size combination increments quantity rather than duplicating the entry. The cart page shows a subtotal, per-item quantity, and instant removal.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Public URL handling: ngrok works great for demos but you need to remember to update &lt;code&gt;PUBLIC_BASE_URL&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt; every time you restart the tunnel. A deployed backend would eliminate this friction.&lt;/li&gt;
&lt;li&gt;Photo storage: &lt;code&gt;localStorage&lt;/code&gt; caps out at ~5MB, so 10 photos is a practical limit. A proper backend photo store would be the next step.&lt;/li&gt;
&lt;li&gt;Polling on the client: Right now the server blocks while polling Perfect Corp. For production, I'd move to a job queue + WebSocket or SSE to push the result to the client when it's ready.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Running It Yourself
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;client &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;install
cd&lt;/span&gt; ../server &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;install&lt;/span&gt;

&lt;span class="c"&gt;# Configure&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"PERFECTCORP_BEARER_TOKEN=your_token_here"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; server/.env
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"PUBLIC_BASE_URL=https://your-ngrok-url.ngrok.app"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; server/.env

&lt;span class="c"&gt;# Expose server (Perfect Corp needs a public URL)&lt;/span&gt;
ngrok http 5000

&lt;span class="c"&gt;# Start backend + frontend&lt;/span&gt;
node server/index.js
&lt;span class="nb"&gt;cd &lt;/span&gt;client &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;MIRROR was a great way to explore AI-powered fashion tech in a hackathon format. The most interesting engineering challenge was juggling the different API contracts for each product category while keeping the frontend interface clean and consistent.&lt;/p&gt;

&lt;p&gt;If you're looking to integrate Perfect Corp's virtual try-on APIs, the biggest thing to know upfront is the public URL requirement. Plan your image hosting strategy before you start as well, and you'll save yourself a lot of debugging.&lt;/p&gt;

&lt;p&gt;The full source is on GitHub. Feel free to take a look or use it as a reference for your own try-on projects.&lt;/p&gt;

</description>
      <category>react</category>
      <category>javascript</category>
      <category>ai</category>
      <category>hackathon</category>
    </item>
    <item>
      <title>Building a Full-Stack Stats Utility App with Node, React, Rust, and Python</title>
      <dc:creator>Steven Wallace</dc:creator>
      <pubDate>Sun, 02 Nov 2025 08:54:10 +0000</pubDate>
      <link>https://dev.to/stevenwallace/building-a-full-stack-stats-utility-app-with-node-react-rust-and-python-2ii2</link>
      <guid>https://dev.to/stevenwallace/building-a-full-stack-stats-utility-app-with-node-react-rust-and-python-2ii2</guid>
      <description>&lt;p&gt;📊 A containerized statistics toolkit that runs Rust + Python microservices behind a Node backend and React frontend.&lt;br&gt;&lt;br&gt;
🧠 Built with TypeScript, Axum, FastAPI, and Docker Compose.&lt;br&gt;&lt;br&gt;
💾 Repo: &lt;a href="https://github.com/swallace100/stats-utility-app" rel="noopener noreferrer"&gt;https://github.com/swallace100/stats-utility-app&lt;/a&gt;  &lt;/p&gt;


&lt;h2&gt;
  
  
  Intro
&lt;/h2&gt;

&lt;p&gt;Data analysis usually involves juggling multiple tools, such as Pandas for stats, R for tests, and Matplotlib for plots.&lt;br&gt;&lt;br&gt;
I wanted something simpler, so I made a single, containerized app where I could upload a CSV with numeric data and get common summary stats with plots.&lt;/p&gt;

&lt;p&gt;The Stats Utility App is a lightweight, polyglot toolkit that runs four services:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;React (frontend)&lt;/li&gt;
&lt;li&gt;Node.js (backend)&lt;/li&gt;
&lt;li&gt;Rust (stats engine)&lt;/li&gt;
&lt;li&gt;Python (plot server)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The app runs completely in Docker and the backend orchestrates all cross-service communication.&lt;br&gt;&lt;br&gt;
In this post, I’ll show how it’s structured, how it runs, and what I learned while piecing four languages together.&lt;/p&gt;


&lt;h2&gt;
  
  
  Tech Stack
&lt;/h2&gt;

&lt;p&gt;• Frontend: React + Vite + Tailwind + shadcn/ui&lt;br&gt;&lt;br&gt;
• Backend: Node.js (Express + TypeScript)&lt;br&gt;&lt;br&gt;
• Rust Microservice: Axum + serde for high-performance numeric kernels&lt;br&gt;&lt;br&gt;
• Python Microservice: FastAPI + Matplotlib for rendering plots&lt;br&gt;&lt;br&gt;
• Orchestration: Docker + Docker Compose&lt;br&gt;&lt;br&gt;
• Validation: Zod + shared JSON schemas  &lt;/p&gt;

&lt;p&gt;Everything runs locally in containers and no database is required.&lt;br&gt;&lt;br&gt;
Jobs are stored in memory, keeping it simple and fast to rebuild or demo.&lt;/p&gt;


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


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;frontend/     # React + Tailwind + Vite UI
backend/      # Express API gateway
stats_rs/     # Rust microservice for stats
plots_py/     # Python microservice for plots
docker/       # Compose file + build config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Service flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;React → Node (Express) → Rust (Axum) → Python (FastAPI)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each service exposes its own &lt;code&gt;/health&lt;/code&gt; endpoint. Docker Compose ensures startup order and readiness before serving the frontend.&lt;/p&gt;




&lt;h2&gt;
  
  
  Backend Flow
&lt;/h2&gt;

&lt;p&gt;The backend acts as an orchestrator.&lt;/p&gt;

&lt;p&gt;When you upload a CSV file, it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Reads and validates metadata (Zod schema)&lt;/li&gt;
&lt;li&gt;Sends JSON { values: [..] } to the Rust service&lt;/li&gt;
&lt;li&gt;Waits for summary or distribution results&lt;/li&gt;
&lt;li&gt;Forwards the data to the Python plotter&lt;/li&gt;
&lt;li&gt;Serves JSON + images back to the frontend&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Example route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/analyze/summary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;textCsv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&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;csv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&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;out&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;fetchJSON&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;RUST_SVC_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api/v1/stats/summary`&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="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="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="na"&gt;values&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;csv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Number&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="nx"&gt;res&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="nx"&gt;out&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;
  
  
  Frontend
&lt;/h3&gt;

&lt;p&gt;The UI (React + Vite) lets users drag-and-drop a CSV file and instantly view:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Common summary stats (mean, median, sd, IQR, etc.)&lt;/li&gt;
&lt;li&gt;Distribution and ECDF plots&lt;/li&gt;
&lt;li&gt;QQ diagnostic plots&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It calls &lt;code&gt;/analyze/*&lt;/code&gt; and &lt;code&gt;/plot/*&lt;/code&gt; endpoints on the backend, showing a live “Analyzing…” state while the microservices process the request.&lt;/p&gt;

&lt;p&gt;Example Output&lt;br&gt;
✅ Summary statistics (mean, median, std, min/max)&lt;br&gt;
📈 Histogram + ECDF + QQ plots&lt;br&gt;
🧮 All computed in Rust and rendered with Matplotlib&lt;br&gt;
💡 Runs entirely in Docker, so setup takes minutes&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn7tdzp960rllvcwaa3ui.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn7tdzp960rllvcwaa3ui.jpg" alt="Stats Utility App screenshot" width="800" height="662"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  Environment Setup
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# build and run all services&lt;/span&gt;
make up
&lt;span class="c"&gt;# or manually:&lt;/span&gt;
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker/docker-compose.yml up &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;http://localhost:8085&lt;/code&gt; to access the app.&lt;/p&gt;

&lt;p&gt;Services:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Port&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;frontend&lt;/td&gt;
&lt;td&gt;8085&lt;/td&gt;
&lt;td&gt;React UI (served by Nginx)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;backend&lt;/td&gt;
&lt;td&gt;8080&lt;/td&gt;
&lt;td&gt;Express API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stats_rs&lt;/td&gt;
&lt;td&gt;9000&lt;/td&gt;
&lt;td&gt;Rust microservice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;plots_py&lt;/td&gt;
&lt;td&gt;7000&lt;/td&gt;
&lt;td&gt;Python microservice&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h3&gt;
  
  
  Lessons Learned
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Rust’s type safety and Axum’s ergonomics make it a good match for numeric microservices.&lt;/li&gt;
&lt;li&gt;FastAPI is ideal for plotting and quick JSON endpoints.&lt;/li&gt;
&lt;li&gt;Zod and Pydantic together make schema validation simple across languages.&lt;/li&gt;
&lt;li&gt;Docker and Docker Compose gave me the most issues out of all aspects of this project, but solving them gave me a much better understanding on how to work with added Docker-related complexity. &lt;/li&gt;
&lt;li&gt;Storing data in memory instead of a database is a quick option for calculations that don't need to be saved.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Repository + License
&lt;/h3&gt;

&lt;p&gt;📂 Full source: &lt;a href="https://github.com/swallace100/stats-utility-app" rel="noopener noreferrer"&gt;https://github.com/swallace100/stats-utility-app&lt;/a&gt;&lt;br&gt;
⚖️ License: MIT&lt;/p&gt;

</description>
      <category>rust</category>
      <category>docker</category>
      <category>go</category>
      <category>node</category>
    </item>
    <item>
      <title>Building a Season-Smart Ramen Chef Agent with Ruby + OpenAI</title>
      <dc:creator>Steven Wallace</dc:creator>
      <pubDate>Thu, 23 Oct 2025 09:49:22 +0000</pubDate>
      <link>https://dev.to/stevenwallace/building-a-season-smart-ramen-chef-agent-with-ruby-openai-43ei</link>
      <guid>https://dev.to/stevenwallace/building-a-season-smart-ramen-chef-agent-with-ruby-openai-43ei</guid>
      <description>&lt;p&gt;🍜 A tiny web app that generates a season-aware ramen recipe (style, broth, tare, noodles, toppings, garnish, steps, shopping list).&lt;br&gt;
🧠 Built with Ruby (Sinatra + Puma), JSON-schema validation, and the OpenAI Agents API.&lt;br&gt;
💾 Repo: &lt;a href="https://github.com/swallace100/ramen-chef-agent" rel="noopener noreferrer"&gt;https://github.com/swallace100/ramen-chef-agent&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Intro
&lt;/h2&gt;

&lt;p&gt;Many AI recipe demos just post unstructured text. I wanted something that was testable and that follows a set format. This "ramen chef” agent app knows that it’s October in Tokyo, suggests seasonal ingredients, and returns a clean JSON plan that the UI can render without guesswork.&lt;/p&gt;

&lt;p&gt;This app serves a single page (vanilla JS) and one API endpoint. It builds a small season context, asks the model to respond as strict JSON, validates that JSON against a schema, and renders a tidy plan in Japanese or English&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwinmigc7obak4fjcgzx6.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwinmigc7obak4fjcgzx6.jpg" alt="App UI" width="800" height="925"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Tech Stack
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Language: Ruby 3.4+&lt;/li&gt;
&lt;li&gt;Framework: Sinatra + Puma&lt;/li&gt;
&lt;li&gt;Libraries: ruby-openai, oj, json_schemer, dotenv, rack-cors&lt;/li&gt;
&lt;li&gt;Tools: Rake tasks (setup, dev, run, lint, test)&lt;/li&gt;
&lt;li&gt;Data: data/japanese_seasonal.yml (month → suggested ingredients)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything runs locally.&lt;/p&gt;
&lt;h3&gt;
  
  
  Architecture Overview
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.
├─ app.rb                   # Sinatra app: HTML UI + API routes
├─ services/
│  └─ ramen_agent.rb        # Agent: schema, prompt, OpenAI call, 
├─ public/
│  └─ app.js                # Fetch + render (safe, resilient)
├─ data/
│  └─ japanese_seasonal.yml # Month → seasonal ingredients
├─ puma.rb                  # Puma config
├─ Rakefile                 # setup/dev/run tasks
├─ resources/
│  └─.env.sample            # copy to .env with OPENAI_API_KEY
└─ config.ru                # rack entry
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  Flow:
&lt;/h3&gt;

&lt;p&gt;Browser → Sinatra (&lt;code&gt;/api/recommend&lt;/code&gt;) → RamenAgent → OpenAI (JSON) → JSON Schema validation → Rendered plan&lt;/p&gt;

&lt;p&gt;All requests are validated. If the model drifts, the app returns a graceful fallback instead of breaking the UI.&lt;/p&gt;
&lt;h3&gt;
  
  
  JSON-First Planning
&lt;/h3&gt;

&lt;p&gt;The agent enforces a schema so the UI never has to guess field names.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# services/ramen_agent.rb (excerpt)&lt;/span&gt;
&lt;span class="no"&gt;Schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="s2"&gt;"type"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;"properties"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s2"&gt;"season_context"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="s2"&gt;"type"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;"properties"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s2"&gt;"date"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"type"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"string"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="s2"&gt;"month"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"type"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"string"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="s2"&gt;"location"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"type"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"string"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="s2"&gt;"suggested_ingredients"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"type"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"array"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"items"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"type"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"string"&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="s2"&gt;"required"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="sx"&gt;%w[date month location suggested_ingredients]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;"additionalProperties"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kp"&gt;false&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="s2"&gt;"style"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"type"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"string"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="s2"&gt;"broth"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"type"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"string"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="s2"&gt;"tare"&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"type"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"string"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="s2"&gt;"noodles"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"type"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"string"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="s2"&gt;"toppings"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"type"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"array"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"items"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"type"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"string"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="s2"&gt;"garnish"&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"type"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"array"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"items"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"type"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"string"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="s2"&gt;"method_steps"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"type"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"array"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"items"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"type"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"string"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="s2"&gt;"shopping_list"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"type"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"array"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"items"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"type"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"string"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="s2"&gt;"serving_note"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"type"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"string"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="s2"&gt;"required"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="sx"&gt;%w[
    season_context style broth tare noodles toppings garnish method_steps shopping_list serving_note
  ]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;"additionalProperties"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kp"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Validation code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;schemer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;JSONSchemer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Schema&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Oj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model_json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;mode: :strict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="s2"&gt;"invalid"&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;schemer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;valid?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Frontend
&lt;/h3&gt;

&lt;p&gt;A minimal page with a &lt;em&gt;Generate Plan&lt;/em&gt; button. The JS shows a loading state, escapes strings (XSS-safe), renders lists, and displays a “Raw JSON” toggle for debugging.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// public/app.js (excerpt)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;$&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DOMContentLoaded&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;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;go&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;box&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&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;result&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;display&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;block&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Generating…&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;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;location&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&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;location&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&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;language&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&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;language&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ja-JP&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&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;notes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&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;resp&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/recommend&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;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="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="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;payload&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;data&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;resp&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="c1"&gt;// renderPlan(box, data) → outputs Style/Broth/Tare/Noodles/etc.&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Environment Setup
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Clone + env
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/yourname/seasonal-ramen-chef
&lt;span class="nb"&gt;cd &lt;/span&gt;seasonal-ramen-chef
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.sample .env     &lt;span class="c"&gt;# add your OPENAI_API_KEY=...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Install (project-local gems)
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bundle &lt;span class="nb"&gt;exec &lt;/span&gt;rake setup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Run (auto-reload)
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bundle &lt;span class="nb"&gt;exec &lt;/span&gt;rake dev
&lt;span class="c"&gt;# open http://localhost:4567&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Prefer plain run? &lt;code&gt;bundle exec rake run&lt;/code&gt;)&lt;/p&gt;

&lt;h3&gt;
  
  
  Example Request
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:4567/api/recommend &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"location":"Osaka","language":"en-US","notes":"no onions please"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Returns a JSON plan with style, broth, tare, noodles, toppings, garnish, method_steps, shopping_list, and serving_note, plus a season_context like { date, month, location, suggested_ingredients }.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lessons Learned
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Including a schema makes it so users get output with the same style every time and a missed field won't break the UI. &lt;/li&gt;
&lt;li&gt;Month + locale gives the agent a good baseline for ingredient suggestions without the need for more prompting.&lt;/li&gt;
&lt;li&gt;ja-JP vs en-US makes it useful in Japan and abroad.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Repository + License
&lt;/h3&gt;

&lt;p&gt;📂 Full source: &lt;a href="https://github.com/swallace100/ramen-chef-agent" rel="noopener noreferrer"&gt;https://github.com/swallace100/ramen-chef-agent&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;⚖️ License: MIT&lt;/p&gt;

&lt;h3&gt;
  
  
  Possible future features
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;A mode to get a recipe based on ingredients the user has on hand.&lt;/li&gt;
&lt;li&gt;Diet/allergen filters (vegetarian, pork-free, nut-free).&lt;/li&gt;
&lt;li&gt;Weekly planner export (PDF/Markdown).&lt;/li&gt;
&lt;li&gt;Budget/servings sliders and rough price hints per bowl.&lt;/li&gt;
&lt;li&gt;More food types beyond ramen.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you fork this and add a new feature, drop a link and share!&lt;/p&gt;

</description>
      <category>ruby</category>
      <category>programming</category>
      <category>ai</category>
    </item>
    <item>
      <title>Building a Twitch bot that lets chat interact with ChatGPT</title>
      <dc:creator>Steven Wallace</dc:creator>
      <pubDate>Tue, 14 Oct 2025 10:32:11 +0000</pubDate>
      <link>https://dev.to/stevenwallace/building-a-twitch-bot-that-lets-chat-interact-with-chatgpt-2nnj</link>
      <guid>https://dev.to/stevenwallace/building-a-twitch-bot-that-lets-chat-interact-with-chatgpt-2nnj</guid>
      <description>&lt;p&gt;🎮 A Twitch bot that allows users to send ChatGPT commands through Twich chat.&lt;br&gt;
🧠 Built with Python 3.13, Twitch EventSub WebSockets, and the OpenAI API.&lt;br&gt;
💾 Repo: &lt;a href="https://github.com/swallace100/ChatGPT-Powered-Twitch-Bot-With-Logging" rel="noopener noreferrer"&gt;https://github.com/swallace100/ChatGPT-Powered-Twitch-Bot-With-Logging&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Intro&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Many channels on Twitch have offline chats where users join and message with each other when the streamer is offline. I decided to make a chatbot designed for offline chats that allows users to send commands to ChatGPT through the Twitch chat.&lt;/p&gt;

&lt;p&gt;The app listens to messages through Twitch’s EventSub WebSocket API, uses the OpenAI API to generate creative responses, and logs everything per channel and day for review.&lt;/p&gt;

&lt;p&gt;With minor adjustments, it could be used when the streamer is online. I know many streamers try to keep bot messages to a minimum, though, so it defaults to offline only.&lt;/p&gt;

&lt;p&gt;In this post, I will walk you through how it works, how to set it up, and lessons learned.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tech Stack&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;• Language: Python 3.13&lt;br&gt;
• APIs: Twitch EventSub WebSocket, Helix REST, OpenAI API (GPT-4)&lt;br&gt;
• Libraries: asyncio, requests, websockets, python-dotenv, openai&lt;br&gt;
• Tools: Makefile, pre-commit, pytest, ruff&lt;br&gt;
• Persistence: Local logs&lt;/p&gt;

&lt;p&gt;Everything runs locally. No hosted backend is required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Architecture Overview&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bot/
  app.py               # main entrypoint
  eventsub_bot.py      # Twitch EventSub WebSocket handling
  commands/            # command registry and built-in handlers
  services/            # OpenAI + logging helpers
  handlers.py          # routes messages to command handlers
resources/appSettings.env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Message flow:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Twitch → EventSub WebSocket → Command Registry → OpenAI → Twitch Helix (chat message)&lt;/p&gt;

&lt;p&gt;All chat messages are timestamped and logged to disk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Command System&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The bot’s CommandRegistry routes messages by prefix ($ by default).&lt;/p&gt;

&lt;p&gt;Example: a Twitch user types &lt;code&gt;$joke&lt;/code&gt;, triggering:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;async def joke(self, ctx, arg):
    banlist = self._join_recent(self.recent_jokes)
    prompt = (
        "Tell one short, original, Twitch-friendly joke. "
        "Avoid repeating these recent ones:\n" + banlist
    )
    joke = self.ai.chat(prompt)
    if joke:
        self.recent_jokes.append(joke)
        return joke
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Built-in commands include &lt;code&gt;$about&lt;/code&gt;, &lt;code&gt;$inputs&lt;/code&gt;, &lt;code&gt;$story&lt;/code&gt;, &lt;code&gt;$trivia&lt;/code&gt;, &lt;code&gt;$touchgrass&lt;/code&gt;, and &lt;code&gt;$image &amp;lt;description&amp;gt;&lt;/code&gt; for AI-generated pictures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Twitch Integration&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Instead of the deprecated IRC interface, this bot uses EventSub WebSockets to listen for channel.chat.message events:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;r = requests.post(
    f"{HELIX}/eventsub/subscriptions",
    headers=self._headers,
    data=json.dumps(payload),
)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When Twitch sends a new message event, the bot processes it asynchronously, calls OpenAI for a response, and posts it back through Helix’s /chat/messages endpoint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Logging&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every message is saved under:&lt;br&gt;
&lt;code&gt;logs/&amp;lt;channel&amp;gt;/&amp;lt;YYYY-MM-DD&amp;gt;/&amp;lt;YYYY-MM-DD&amp;gt;.txt&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Example line:&lt;br&gt;
&lt;code&gt;2025-10-07 13:22:54 user123: $trivia space&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Logs make it easy to analyze chat trends or replay conversations later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Environment Setup&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Copy and edit your config:
&lt;code&gt;cp resources/appSettings.env_example resources/appSettings.env&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt; Fill in TWITCH_CLIENT_ID, then run:
&lt;code&gt;python get_tokens.py&lt;/code&gt;
This launches Twitch’s Device Flow to populate access tokens.&lt;/li&gt;
&lt;li&gt; Install and run:
&lt;code&gt;make install-dev
make run&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The bot connects automatically and starts logging messages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lessons Learned&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;• EventSub WebSocket is cleaner and future-proof compared to IRC.&lt;br&gt;
• Proper async design prevents dropped messages under high chat volume.&lt;br&gt;
• I had a problem where ChatGPT would repeat the same response over and over again. I implemented a response history so that ChatGPT would know which responses it already gave and to not repeat them.&lt;br&gt;
• A simple 10-second cooldown timer prevents spam effectively.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repository + License&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;📂 Full source: &lt;a href="https://github.com/swallace100/ChatGPT-Powered-Twitch-Bot-With-Logging" rel="noopener noreferrer"&gt;https://github.com/swallace100/ChatGPT-Powered-Twitch-Bot-With-Logging&lt;/a&gt;&lt;br&gt;
⚖️ License: MIT&lt;/p&gt;

</description>
      <category>programming</category>
      <category>ai</category>
      <category>python</category>
    </item>
    <item>
      <title>Eternal Hunt: Building an AI-Powered Text-Based Thriller Game with the OpenAI Agents SDK</title>
      <dc:creator>Steven Wallace</dc:creator>
      <pubDate>Fri, 10 Oct 2025 12:32:41 +0000</pubDate>
      <link>https://dev.to/stevenwallace/eternal-hunt-building-an-ai-powered-text-based-thriller-game-with-the-openai-agents-sdk-5ap4</link>
      <guid>https://dev.to/stevenwallace/eternal-hunt-building-an-ai-powered-text-based-thriller-game-with-the-openai-agents-sdk-5ap4</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;🎮 &lt;strong&gt;Eternal Hunt&lt;/strong&gt; is a text-adventure thriller narrated entirely by an AI agent.&lt;br&gt;&lt;br&gt;
🧠 Built with Python, FastAPI, Gradio, and the OpenAI Agents SDK.&lt;br&gt;&lt;br&gt;
💾 Repo: &lt;a href="https://github.com/swallace100/thriller-game-ai-agent" rel="noopener noreferrer"&gt;github.com/swallace100/thriller-game-ai-agent&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Idea
&lt;/h2&gt;

&lt;p&gt;As a software engineer living in Tokyo, I wanted to qualify for game-dev roles here if the right opportunity came up.&lt;br&gt;
But most studios want to see that you’ve actually shipped a game.&lt;/p&gt;

&lt;p&gt;So I decided to make one myself by combining game design with my other goal of building AI-powered tools.&lt;/p&gt;

&lt;p&gt;That project became Eternal Hunt, a thriller text adventure narrated completely by an AI agent.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Premise
&lt;/h2&gt;

&lt;p&gt;The player lives in New York and carries a rare genetic mutation that grants them an unusually long lifespan. They can live for hundreds, maybe thousands, of years.  &lt;/p&gt;

&lt;p&gt;When a dying billionaire learns this secret, he’s determined to obtain it by any means necessary.&lt;/p&gt;

&lt;p&gt;The player interacts only with the Narrator Agent, an AI storyteller who guides the player and manages the experience.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two Agents: Narrator &amp;amp; Researcher
&lt;/h2&gt;

&lt;p&gt;Eternal Hunt runs on the OpenAI Agents SDK and features two cooperating agents:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Narrator Agent — Handles dialogue, story logic, memory, inventory, and user interaction.
&lt;/li&gt;
&lt;li&gt;Research Agent — Queries the web in real time and feeds results back to the narrator to keep scenes grounded and current.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The user never interacts with the researcher directly.&lt;br&gt;&lt;br&gt;
Instead, the Narrator Agent can call a custom tool and ask the Research Agent for information whenever it needs real-world data to build the next scene.&lt;/p&gt;




&lt;h2&gt;
  
  
  Interfaces &amp;amp; Player Systems
&lt;/h2&gt;

&lt;p&gt;The game can run in either a &lt;strong&gt;Gradio&lt;/strong&gt; or &lt;strong&gt;Streamlit&lt;/strong&gt; interface. These are simple chat boxes that work in light or dark mode.&lt;/p&gt;

&lt;p&gt;Behind that minimal UI is a full game system:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Inventory Tool — lets the Narrator add and remove items from the player’s inventory.
&lt;/li&gt;
&lt;li&gt;Persistent Saves — every action is written to a JSON file so the player can pick up where they left off.
&lt;/li&gt;
&lt;li&gt;Memory Logs — internal context for the Narrator, plus an external save for the game state.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This structure lets the AI maintain continuity while feeling dynamic and alive.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Challenges
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Selective Memory
&lt;/h3&gt;

&lt;p&gt;Sometimes the Narrator decides an event or item isn’t important enough to save.&lt;br&gt;&lt;br&gt;
The player picks up a spoon, later tries to use it… and the AI says it doesn’t exist.&lt;br&gt;&lt;br&gt;
That inconsistency breaks immersion fast.&lt;/p&gt;

&lt;h3&gt;
  
  
  Breaking Immersion
&lt;/h3&gt;

&lt;p&gt;Early builds would gleefully announce tool usage:  &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“I just called &lt;code&gt;add_item_to_inventory()&lt;/code&gt;! Your item is now in your inventory!”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Adding a simple instruction &lt;em&gt;never mention internal tools&lt;/em&gt; fixed it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pacing &amp;amp; Tension
&lt;/h3&gt;

&lt;p&gt;The AI’s writing was technically solid but often lingered too long in scenes.&lt;br&gt;&lt;br&gt;
A thriller needs motion. I’m still tuning prompts and constraints to balance detail with momentum.&lt;/p&gt;




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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Tech&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Frontend&lt;/td&gt;
&lt;td&gt;Gradio + Streamlit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend&lt;/td&gt;
&lt;td&gt;Python + FastAPI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agents&lt;/td&gt;
&lt;td&gt;OpenAI Agents SDK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persistence&lt;/td&gt;
&lt;td&gt;JSON state files + memory log&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom Tools&lt;/td&gt;
&lt;td&gt;Inventory management, world event tracking, environment logic&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Everything is modular. New agents or UI layers can be dropped in easily.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;Agents follow structured instructions well, but you have to test their edge cases.
&lt;/li&gt;
&lt;li&gt;Small behavioral tweaks, like telling them what not to say, make a huge difference.
&lt;/li&gt;
&lt;li&gt;Two cooperating agents can feel surprisingly coordinated. Adding an Enemy Agent or Orchestrator Agent could take this further.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;AI-based text adventures can scale quickly in complexity.&lt;br&gt;&lt;br&gt;
I'd like to see a multi-agent storytelling systems where narrators, enemies, and assistants interact dynamically.&lt;/p&gt;

&lt;p&gt;I’m not sure if major studios would count this as a released game, but maybe it’s enough to get me a spot developing Death Stranding 3. Stranger things have happened.&lt;/p&gt;




&lt;h2&gt;
  
  
  Project Link
&lt;/h2&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/swallace100/thriller-game-ai-agent" rel="noopener noreferrer"&gt;github.com/swallace100/thriller-game-ai-agent&lt;/a&gt;&lt;br&gt;&lt;br&gt;
Screenshots, architecture diagrams, and dev logs are all there.&lt;/p&gt;

&lt;p&gt;If you experiment with AI storytelling, feel free to fork it and try your own narrator style.  &lt;/p&gt;




</description>
      <category>ai</category>
      <category>python</category>
      <category>gamedev</category>
      <category>openai</category>
    </item>
  </channel>
</rss>
