<?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: Todd Sullivan</title>
    <description>The latest articles on DEV Community by Todd Sullivan (@toddsullivan).</description>
    <link>https://dev.to/toddsullivan</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3895637%2Fc957b85c-53f5-4505-8b53-7e62e06088e9.jpeg</url>
      <title>DEV Community: Todd Sullivan</title>
      <link>https://dev.to/toddsullivan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/toddsullivan"/>
    <language>en</language>
    <item>
      <title>My Local AI Assistant Got Worse When I Remembered Too Much</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Tue, 21 Jul 2026 08:06:35 +0000</pubDate>
      <link>https://dev.to/toddsullivan/my-local-ai-assistant-got-worse-when-i-remembered-too-much-3egp</link>
      <guid>https://dev.to/toddsullivan/my-local-ai-assistant-got-worse-when-i-remembered-too-much-3egp</guid>
      <description>&lt;h1&gt;
  
  
  My Local AI Assistant Got Worse When I Remembered Too Much
&lt;/h1&gt;

&lt;p&gt;I moved a personal AI assistant onto a small local model last week and immediately hit a boring problem: the model was fine, but my memory layer was not.&lt;/p&gt;

&lt;p&gt;The old version persisted raw conversation history and replayed it back into the prompt. That worked well enough with hosted models. Then I pointed the same app at a local OpenAI-compatible server running Qwen3-4B-4bit through Swama on the Mac mini.&lt;/p&gt;

&lt;p&gt;After 196 accumulated messages, the assistant started doing the classic small-model failure mode: parroting its own previous replies, over-weighting stale context, and sounding less useful the more “memory” I gave it.&lt;/p&gt;

&lt;p&gt;The fix was not a vector database. It was deleting most of the memory.&lt;/p&gt;

&lt;p&gt;I split memory into two different things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;short-term conversation state&lt;/li&gt;
&lt;li&gt;long-term user facts&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Short-term history now stays in RAM only. It resets after an idle gap, and it has a hard cap so a marathon session cannot poison every future turn.&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_memory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ConversationBufferMemory&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="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_last_activity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&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="n"&gt;idle_limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SESSION_IDLE_MINUTES&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;120&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
&lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_last_activity&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;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;idle_limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;del&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_memory&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Long-term memory is not chat logs. It is a small list of distilled facts: preferences, people, devices, recurring activities, that kind of thing. Maximum 30 facts per user.&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;_FACT_EXTRACTION_PROMPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
Update the fact list. Add only stable, personal facts worth remembering across
conversations: preferences, interests, people, pets, places, devices, recurring
activities. Ignore small talk, one-off requests, and anything the assistant said
about itself.

Return ONLY a JSON array of strings.
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fact extraction runs in a background thread after each exchange. The chat path should not wait for memory housekeeping.&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;threading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_extract_facts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msgs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msgs&lt;/span&gt;&lt;span class="p"&gt;[&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="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;daemon&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also deliberately use a hosted model for the distillation step. The local 4B model is good enough for fast interaction, but long-term memory cleanup is one of those places where quality matters more than latency. It is off the response path anyway.&lt;/p&gt;

&lt;p&gt;The other local-model tweak was tool bias. Small models are much more likely to answer from stale weights even when tools exist, especially if the system prompt says anything like “use your knowledge first.” So the Swama handler adds a blunt override for live data:&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;_TOOL_BIAS&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; IMPORTANT OVERRIDE: for anything happening NOW - weather, sea or&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; kitesurfing conditions, device/home status, prices, news, live data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; of any kind - you MUST call the matching tool. Never answer those&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; from memory. /no_think&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Qwen3 also emits &lt;code&gt;&amp;lt;think&amp;gt;&lt;/code&gt; blocks even when asked not to, including mid-stream after tool calls, so the streaming handler strips those tags incrementally. Not glamorous, but necessary if you do not want raw reasoning markup leaking into a voice/chat UI.&lt;/p&gt;

&lt;p&gt;The useful lesson was this:&lt;/p&gt;

&lt;p&gt;Memory is not “more previous tokens.”&lt;/p&gt;

&lt;p&gt;For a personal assistant, raw transcript replay is the cheapest thing to build and one of the easiest ways to make the system worse. The assistant needs enough recent context to hold the current conversation, plus a tiny set of stable facts that survive across sessions.&lt;/p&gt;

&lt;p&gt;Everything else is prompt pollution with a better name.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Source:&lt;/strong&gt; Recent personal assistant backend work: Swama local model support, Qwen3-4B-4bit via an OpenAI-compatible endpoint, RAM-only session history, 2-hour idle reset, 200-message cap, background fact extraction, and 30 persisted user facts.&lt;br&gt;
&lt;strong&gt;Tags:&lt;/strong&gt; ai, python, llm, devops&lt;br&gt;
&lt;strong&gt;Status:&lt;/strong&gt; published&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>llm</category>
      <category>devops</category>
    </item>
    <item>
      <title>I Made My Voice Agent Feel Faster by Streaming Sentences, Not Audio</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Tue, 14 Jul 2026 08:05:39 +0000</pubDate>
      <link>https://dev.to/toddsullivan/i-made-my-voice-agent-feel-faster-by-streaming-sentences-not-audio-4jej</link>
      <guid>https://dev.to/toddsullivan/i-made-my-voice-agent-feel-faster-by-streaming-sentences-not-audio-4jej</guid>
      <description>&lt;p&gt;The annoying thing about voice agents is that “the model is fast” does not mean the experience is fast.&lt;/p&gt;

&lt;p&gt;I had a small voice assistant running on a local device, talking to a hosted chat backend. The actual LLM call was only one part of the wait. The full path looked more like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;wake word detection&lt;/li&gt;
&lt;li&gt;speech recognition&lt;/li&gt;
&lt;li&gt;authenticated &lt;code&gt;/chat&lt;/code&gt; call&lt;/li&gt;
&lt;li&gt;model response&lt;/li&gt;
&lt;li&gt;local TTS synthesis&lt;/li&gt;
&lt;li&gt;audio playback&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you wait for step 4 to finish before starting step 5, the user hears nothing until the entire reply is done. That feels dead, even when the backend is technically fine.&lt;/p&gt;

&lt;p&gt;So I changed the contract. The hardware client now calls the chat endpoint with &lt;code&gt;stream_tts: true&lt;/code&gt;:&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;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;CHAT_API_BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/chat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream_tts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The backend yields text chunks as they arrive from the model. The device keeps a small buffer, splits complete sentences, and starts synthesizing each sentence immediately:&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;_SENTENCE_BOUNDARY_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;(?&amp;lt;=[.!?])\s+&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;split_complete_sentences&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;sentences&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;remainder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_SENTENCE_BOUNDARY_RE&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="nb"&gt;buffer&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="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sentences&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt; &lt;span class="n"&gt;remainder&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is deliberately boring. Not phoneme streaming. Not a custom audio protocol. Just sentence-level pipelining.&lt;/p&gt;

&lt;p&gt;The next useful bit was overlapping synthesis and playback. A single background worker waits for synthesized WAV files and plays them in order, while a one-worker &lt;code&gt;ThreadPoolExecutor&lt;/code&gt; starts rendering the next sentence as soon as it is complete.&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;for&lt;/span&gt; &lt;span class="n"&gt;sentence&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sentences&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;future&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;synth_executor&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="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tts_engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;synthesize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sentence&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;playback_queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;sentence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;future&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That removed the worst gap: “sentence one finished playing, now start thinking about sentence two’s audio.” The hardware now does the obvious thing a human expects — keep talking.&lt;/p&gt;

&lt;p&gt;I also cut backend time-to-first-token by doing less. For this conversational path, I turned off extended model thinking:&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;_NO_THINKING&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ThinkingConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;thinking_budget&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And I stopped advertising Google Search on every request. The search tool is only added when the prompt smells like it needs current/external information. Most turns do not.&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;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_needs_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;built_contents&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;all_functions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;google_search&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result was about a 5x cut in chat time-to-first-byte for the common path, plus a much better perceived response because speech starts before the full answer exists.&lt;/p&gt;

&lt;p&gt;The lesson was not “stream everything.” It was smaller than that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;stream at the boundary the product can actually use&lt;/li&gt;
&lt;li&gt;overlap the slow local work with the slow network work&lt;/li&gt;
&lt;li&gt;do not give the model tools or reasoning budget unless the turn needs them&lt;/li&gt;
&lt;li&gt;log chunks, sentence counts, gaps, and total time so you can see where the pause moved&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Voice agents do not need heroic architecture to feel better. Sometimes the fix is a regex, a queue, and deleting the expensive defaults.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>devops</category>
      <category>voiceai</category>
    </item>
    <item>
      <title>Silent Data Loss Is Worse Than a Failed Export</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Tue, 07 Jul 2026 08:04:31 +0000</pubDate>
      <link>https://dev.to/toddsullivan/silent-data-loss-is-worse-than-a-failed-export-277c</link>
      <guid>https://dev.to/toddsullivan/silent-data-loss-is-worse-than-a-failed-export-277c</guid>
      <description>&lt;p&gt;This week I fixed a set of bugs in a mobile export pipeline where the scary part was not that the export could fail.&lt;/p&gt;

&lt;p&gt;It was that it could succeed while quietly omitting data.&lt;/p&gt;

&lt;p&gt;The pipeline builds an internal assessment model, validates it, serializes it into an RdSAP XML export, packages evidence, writes a checksum, and records an audit log. The deterministic exporter work was already in place. Same assessment in, same bytes out.&lt;/p&gt;

&lt;p&gt;But deterministic output only helps if the model you serialize has not already lost meaning.&lt;/p&gt;

&lt;p&gt;A code review turned up a few places where valid survey answers were being collapsed into “nothing to export”.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dangerous &lt;code&gt;null&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The first bug was a token mapper:&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="nf"&gt;fuelToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A valid answer, &lt;code&gt;Other&lt;/code&gt;, was returning &lt;code&gt;null&lt;/code&gt;. That meant mandatory fields like main fuel and water heating fuel could disappear from the XML instead of being exported as the existing fallback token.&lt;/p&gt;

&lt;p&gt;The fix was boring:&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;Other&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;other&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Boring is good. Boring means the field is present and downstream validation can do its job.&lt;/p&gt;

&lt;p&gt;The second version of the same bug was draught proofing. &lt;code&gt;Unknown&lt;/code&gt; was a valid answer in the survey UI, but there is no clean numeric RdSAP value for it. The old behavior silently omitted the value. The fixed behavior raises a non-blocking validation warning instead.&lt;/p&gt;

&lt;p&gt;That distinction matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;missing because the assessor never answered: omit&lt;/li&gt;
&lt;li&gt;present but not representable in this export format: warn&lt;/li&gt;
&lt;li&gt;present and representable: export&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are three different states. Treating them all as &lt;code&gt;null&lt;/code&gt; is how data loss gets a polite API.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;false&lt;/code&gt; is not “unanswered”
&lt;/h2&gt;

&lt;p&gt;The more subtle bug was boolean handling.&lt;/p&gt;

&lt;p&gt;The original helper effectively collapsed explicit &lt;code&gt;false&lt;/code&gt; and “never answered” into the same output. So a recorded “no” for fields like cylinder present, immersion heater, or insulation could become indistinguishable from a question that was never asked.&lt;/p&gt;

&lt;p&gt;That is a bad trade. In an assessment/export system, “no” is data.&lt;/p&gt;

&lt;p&gt;The helper now returns a tri-state value:&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;boolean&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the serializer emits true/false for real answers, only omitting the element when the value is genuinely unanswered.&lt;/p&gt;

&lt;p&gt;The test is tiny, which is exactly the point:&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;distinguishes an explicit false answer from never-answered&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildAssessment&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;inspection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;responses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;mkResponses&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;validValues&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;epc_cylinder_present&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="na"&gt;media&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="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hotWater&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cylinderPresent&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No giant test harness. Just one assertion that prevents the bug from coming back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not guess from raw JSON
&lt;/h2&gt;

&lt;p&gt;There was also a LiDAR floor-area parser that tried too hard.&lt;/p&gt;

&lt;p&gt;If the JSON had a &lt;code&gt;total&lt;/code&gt;, use it. Fine.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;total&lt;/code&gt; was missing, the old fallback scanned the raw JSON for a number. That could accidentally grab an individual room area and treat it as the property's total floor area.&lt;/p&gt;

&lt;p&gt;That is worse than failing.&lt;/p&gt;

&lt;p&gt;The fixed version returns &lt;code&gt;null&lt;/code&gt; when &lt;code&gt;total&lt;/code&gt; is missing. No heroic guessing. No “probably 20m² because I found 20 somewhere in the blob”.&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="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;property&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;floorAreaM2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeNull&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Again: boring test, useful guarantee.&lt;/p&gt;

&lt;h2&gt;
  
  
  Audit failures before they escape
&lt;/h2&gt;

&lt;p&gt;One more bug was outside serialization. &lt;code&gt;getExporter()&lt;/code&gt; could throw before the main &lt;code&gt;try/catch&lt;/code&gt;, which meant a format lookup failure skipped the audit-trail write that other failures recorded.&lt;/p&gt;

&lt;p&gt;That is the kind of edge case that makes production debugging annoying. The export failed, but the failure path forgot to leave evidence.&lt;/p&gt;

&lt;p&gt;The fix was just moving the lookup inside the guarded path so the audit log is written consistently.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AI-assisted part
&lt;/h2&gt;

&lt;p&gt;This is where AI-assisted development is actually useful for me.&lt;/p&gt;

&lt;p&gt;Not “write the whole exporter and hope”. More like:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;build the deterministic seam&lt;/li&gt;
&lt;li&gt;have Claude review the boring mapping logic&lt;/li&gt;
&lt;li&gt;turn every discovered ambiguity into a narrow test&lt;/li&gt;
&lt;li&gt;keep the deliberate deferrals explicit&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This pass added 6 focused tests. The export suite moved from 119 to 125 tests, with the targeted export tests passing locally: 3 suites, 23 tests.&lt;/p&gt;

&lt;p&gt;There is still one known bigger issue left alone: validation is currently too RdSAP-specific for every export format. That needs per-format validation profiles. I did not jam it into this fix because it is a different change.&lt;/p&gt;

&lt;p&gt;That is another useful rule when working with AI in real codebases: fix the silent data loss now, leave the larger design change visible, and do not let the model turn a bug fix into a rewrite.&lt;/p&gt;

&lt;p&gt;The best export pipeline is not the cleverest one.&lt;/p&gt;

&lt;p&gt;It is the one that refuses to quietly lie.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>typescript</category>
      <category>mobile</category>
    </item>
    <item>
      <title>The Exporter Was Easy. Making It Deterministic Was the Work.</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Tue, 30 Jun 2026 08:02:27 +0000</pubDate>
      <link>https://dev.to/toddsullivan/the-exporter-was-easy-making-it-deterministic-was-the-work-10el</link>
      <guid>https://dev.to/toddsullivan/the-exporter-was-easy-making-it-deterministic-was-the-work-10el</guid>
      <description>&lt;h1&gt;
  
  
  The Exporter Was Easy. Making It Deterministic Was the Work.
&lt;/h1&gt;

&lt;p&gt;I spent this week building a mobile export pipeline for UK energy assessment data.&lt;/p&gt;

&lt;p&gt;The output is not glamorous: take completed survey responses, turn them into an RdSAP XML file, package the evidence, write a manifest, calculate a checksum, and keep a local audit trail.&lt;/p&gt;

&lt;p&gt;The interesting part was not generating XML. The interesting part was making the whole thing deterministic and testable before adding more export formats.&lt;/p&gt;

&lt;p&gt;The shape ended up like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;responses
  -&amp;gt; buildAssessment()
  -&amp;gt; validateAssessment()
  -&amp;gt; exporter.serialize(assessment)
  -&amp;gt; sha256
  -&amp;gt; write package
  -&amp;gt; persist audit log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exporter itself is deliberately boring:&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="nf"&gt;serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;assessment&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No file system. No clock. No database. No random IDs. Same assessment in, same bytes out.&lt;/p&gt;

&lt;p&gt;That one rule makes the rest of the pipeline much easier to reason about. If the XML changes, it changed because the input changed or the serializer changed. Not because a timestamp moved, a native module behaved differently, or a test environment had a different document directory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Side effects at the edge
&lt;/h2&gt;

&lt;p&gt;The orchestrator is the only layer allowed to do side effects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;exporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;assessment&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;checksum&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;deps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256Hex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pkg&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;writeExportPackage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;inspectionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;exporter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;assessment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;checksum&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fs&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;deps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;persistLog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those dependencies are ports:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ExportDeps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FileSystemPort&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;sha256Hex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;persistLog&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ExportLogEntry&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In production, they map to Expo file system, Expo crypto, and a local SQLite audit table.&lt;/p&gt;

&lt;p&gt;In tests, they are an in-memory file system, Node &lt;code&gt;crypto&lt;/code&gt;, and an array log sink.&lt;/p&gt;

&lt;p&gt;That was not architectural theatre. Under &lt;code&gt;jest-expo&lt;/code&gt;, native modules are often partially unavailable. &lt;code&gt;documentDirectory&lt;/code&gt; can be undefined. Crypto enums can be missing. If the export logic directly imports and touches those modules, the “end-to-end” test either becomes a mock festival or stops covering the actual path.&lt;/p&gt;

&lt;p&gt;With ports, the test runs the real pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;memoryDeps&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;run&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;runExport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;deps&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;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;files&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="nx"&gt;expectedPath&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="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;checksum&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&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;That test proves a few things at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the package path is correct&lt;/li&gt;
&lt;li&gt;the written XML is exactly the pure exporter output&lt;/li&gt;
&lt;li&gt;the checksum is a real SHA-256 of the bytes&lt;/li&gt;
&lt;li&gt;the manifest references the same checksum&lt;/li&gt;
&lt;li&gt;one audit entry is written&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There are also tests for evidence attachment copying, missing attachment sources, validation-blocked exports, checksum reproducibility, and a second JSON archive exporter running through the same pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validation blocks, but still logs
&lt;/h2&gt;

&lt;p&gt;One design choice I care about: failed validation writes an audit record too.&lt;/p&gt;

&lt;p&gt;If a mandatory field is missing, the export does not create files. But the attempt is still logged with &lt;code&gt;success: false&lt;/code&gt;, validation counts, schema version, and no checksum.&lt;/p&gt;

&lt;p&gt;That makes the export feature behave like a real operational system rather than a button that either emits a file or silently refuses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters for AI-built software
&lt;/h2&gt;

&lt;p&gt;This is the seam I want when using AI heavily in engineering work.&lt;/p&gt;

&lt;p&gt;LLMs are very good at producing a first version of “convert this shape into that schema.” They are much less reliable if the codebase lets schema mapping, file IO, logging, hashing, and UI state collapse into one blob.&lt;/p&gt;

&lt;p&gt;The fix is not to use less AI. The fix is to give the code stronger boundaries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;pure transformations for the model or human to edit safely&lt;/li&gt;
&lt;li&gt;deterministic outputs that can be snapshot-tested or checksummed&lt;/li&gt;
&lt;li&gt;injected IO so tests exercise the pipeline without native dependencies&lt;/li&gt;
&lt;li&gt;audit trails for both success and blocked paths&lt;/li&gt;
&lt;li&gt;format-specific exporters behind a small registry&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once those seams exist, adding the next export format is not a rewrite. It is another serializer plus a few tests.&lt;/p&gt;

&lt;p&gt;That is the part I keep finding in real AI-assisted development: the model can help move fast, but the architecture has to make fast changes safe.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Source:&lt;/strong&gt; Recent mobile export module work: RdSAP XML export pipeline, pure serializers, injectable IO ports, SHA-256 packaging, local audit logging, and 24 export tests.&lt;br&gt;
&lt;strong&gt;Tags:&lt;/strong&gt; ai, testing, typescript, mobile&lt;br&gt;
&lt;strong&gt;Status:&lt;/strong&gt; published&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>typescript</category>
      <category>mobile</category>
    </item>
    <item>
      <title>AI Features Need Product Edges, Not Just Better Prompts</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Tue, 23 Jun 2026 08:03:02 +0000</pubDate>
      <link>https://dev.to/toddsullivan/ai-features-need-product-edges-not-just-better-prompts-18k</link>
      <guid>https://dev.to/toddsullivan/ai-features-need-product-edges-not-just-better-prompts-18k</guid>
      <description>&lt;p&gt;Most AI features don't fail because the model is bad.&lt;/p&gt;

&lt;p&gt;They fail because everything around the model is treated like a demo.&lt;/p&gt;

&lt;p&gt;This week I was tightening an iOS workout app that uses Claude through Supabase Edge Functions. The model part is straightforward: send training context, get a structured exercise or plan back, validate it, write it into SwiftData.&lt;/p&gt;

&lt;p&gt;The less glamorous work was the part that makes it feel like an actual product:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;monthly AI credit balance&lt;/li&gt;
&lt;li&gt;offline handling&lt;/li&gt;
&lt;li&gt;auth token storage&lt;/li&gt;
&lt;li&gt;disabled states while generation is running&lt;/li&gt;
&lt;li&gt;different rules for “add exercise” vs “swap this exercise”&lt;/li&gt;
&lt;li&gt;tests for the boring edge cases&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is where most AI app quality lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  One button, several states
&lt;/h2&gt;

&lt;p&gt;The UI has a small “fill with AI” affordance on the exercise editor. Underneath it, the button is not just “call endpoint”. It has to know whether suggestion is currently allowed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;canSuggest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;isLoadingAI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="kt"&gt;NetworkMonitor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isOnline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creditsRemaining&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="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Swap mode can use the original exercise as context.&lt;/span&gt;
    &lt;span class="c1"&gt;// Add mode needs the typed name as a hint.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;isSwapMode&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trimmingCharacters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;whitespaces&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isEmpty&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That little predicate is doing a lot of product work.&lt;/p&gt;

&lt;p&gt;If the user is offline, don't let them tap into a doomed network request.&lt;br&gt;
If a generation is already running, don't double-spend.&lt;br&gt;
If they have zero credits, don't pretend the feature is available.&lt;br&gt;
If they are swapping an existing exercise, don't force them to type a name because the old exercise is already useful context.&lt;/p&gt;

&lt;p&gt;The model does not care about any of this. The user does.&lt;/p&gt;
&lt;h2&gt;
  
  
  Credits should be part of the response
&lt;/h2&gt;

&lt;p&gt;The suggestion response includes the updated credit count:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;SuggestedExercise&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Decodable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;briefDescription&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;muscleGroup&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;sets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;repTargetLow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;repTargetHigh&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;restSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;isDualDumbbell&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Bool&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;creditsRemaining&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the view model updates local state immediately after a successful fill:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="n"&gt;creditsRemaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;creditsRemaining&lt;/span&gt;
&lt;span class="n"&gt;aiFilledFields&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That avoids the classic AI-product weirdness where the backend knows the user has spent a credit but the UI keeps showing stale allowance until the next refresh.&lt;/p&gt;

&lt;p&gt;It is also easier to test. In the app tests, the credit transition is explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;testAIDisabledWhenCreditsReachZero&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="nv"&gt;vm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;ExerciseEditViewModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;makeDay&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt; &lt;span class="nv"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;container&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mainContext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;vm&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Row"&lt;/span&gt;
    &lt;span class="n"&gt;vm&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;creditsRemaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="kt"&gt;XCTAssertTrue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vm&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;canSuggest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;vm&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;creditsRemaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="kt"&gt;XCTAssertFalse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vm&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;canSuggest&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;There are 13 tests just around the exercise edit view model, plus separate coverage for offline error mapping. Not because this is academically interesting, but because this is the stuff that breaks in front of real users.&lt;/p&gt;

&lt;h2&gt;
  
  
  Offline is not a server error
&lt;/h2&gt;

&lt;p&gt;Another small detail: connectivity failures are mapped separately from backend failures.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;connectivityCodes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;URLError&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;Code&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notConnectedToInternet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;networkConnectionLost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timedOut&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cannotConnectToHost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dataNotAllowed&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 resulting message is intentionally plain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="s"&gt;"You're offline. Reconnect to use AI features."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No “unexpected server response”. No fake intelligence. Just tell the user what happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual lesson
&lt;/h2&gt;

&lt;p&gt;Shipping AI features is mostly normal software engineering with a probabilistic dependency in the middle.&lt;/p&gt;

&lt;p&gt;The model call matters, but the surrounding contract matters more:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Can the user invoke it right now?&lt;/li&gt;
&lt;li&gt;What happens if the network dies?&lt;/li&gt;
&lt;li&gt;Is usage counted consistently?&lt;/li&gt;
&lt;li&gt;Does the UI reflect server state immediately?&lt;/li&gt;
&lt;li&gt;Are the edge cases testable without calling the model?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once those pieces are in place, the AI feature stops feeling like a prompt wired to a button and starts feeling like part of the app.&lt;/p&gt;

&lt;p&gt;That is the bar I keep coming back to: not “does the model answer?” but “does this survive normal product reality?”&lt;/p&gt;

</description>
      <category>ai</category>
      <category>swift</category>
      <category>ios</category>
      <category>testing</category>
    </item>
    <item>
      <title>I Replaced My Old AI Agent Stack with Hermes — and It Finally Feels Like Infrastructure</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Fri, 19 Jun 2026 11:16:31 +0000</pubDate>
      <link>https://dev.to/toddsullivan/i-replaced-my-old-ai-agent-stack-with-hermes-and-it-finally-feels-like-infrastructure-240g</link>
      <guid>https://dev.to/toddsullivan/i-replaced-my-old-ai-agent-stack-with-hermes-and-it-finally-feels-like-infrastructure-240g</guid>
      <description>&lt;p&gt;I recently moved my personal AI automation setup over to Hermes.&lt;/p&gt;

&lt;p&gt;Not because I wanted another chatbot. I already had one of those.&lt;/p&gt;

&lt;p&gt;The useful shift was that Hermes feels less like “an AI you talk to” and more like a small operating layer for personal automation: persistent memory, scheduled jobs, skills, gateway routing, tool access, and enough system awareness to actually do maintenance work instead of just narrating it.&lt;/p&gt;

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

&lt;p&gt;Most AI assistant demos stop at the prompt. Real usage starts after the third week, when you need the assistant to remember your preferences, avoid stale work contexts, run scheduled checks, post to the right channel, and not accidentally resurrect some old service you migrated away from months ago.&lt;/p&gt;

&lt;p&gt;That last one is not theoretical.&lt;/p&gt;

&lt;p&gt;After the migration I noticed stale references to my old agent stack still appearing in messages. So I asked Hermes to verify the machine state rather than just reassure me.&lt;/p&gt;

&lt;p&gt;It checked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the live launchd services&lt;/li&gt;
&lt;li&gt;the running gateway process&lt;/li&gt;
&lt;li&gt;old LaunchAgent plists&lt;/li&gt;
&lt;li&gt;cron job delivery metadata&lt;/li&gt;
&lt;li&gt;process names and command lines&lt;/li&gt;
&lt;li&gt;stale references in config/state&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important part: it found the actual answer, not just the obvious one.&lt;/p&gt;

&lt;p&gt;Hermes itself was running cleanly through launchd as &lt;code&gt;ai.hermes.gateway&lt;/code&gt;. The old gateway labels were disabled. But there were still two leftover MCP-related LaunchAgents from the previous setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;com.clawmcp.server
com.clawmcp.tunnel
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One of them was still using an old SSH key path from that previous world. Not catastrophic, but exactly the kind of residue that turns into weird behaviour later.&lt;/p&gt;

&lt;p&gt;Hermes stopped them, disabled the launchd labels, moved the plist files into a timestamped disabled folder, and re-verified that only the Hermes gateway remained active.&lt;/p&gt;

&lt;p&gt;That is the bit I care about.&lt;/p&gt;

&lt;p&gt;A useful AI agent is not just a text generator. It needs to be able to close the loop:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;inspect the real system&lt;/li&gt;
&lt;li&gt;distinguish stale text from live processes&lt;/li&gt;
&lt;li&gt;make a reversible change&lt;/li&gt;
&lt;li&gt;verify the change actually worked&lt;/li&gt;
&lt;li&gt;remember the durable lesson for next time&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Hermes also gives me a cleaner mental model than my previous setup.&lt;/p&gt;

&lt;p&gt;Skills are reusable procedures. Memory is for stable facts. Cron jobs are durable scheduled agents. The gateway lets the same assistant operate from Discord, email, terminal, or wherever I am. Profiles keep different contexts isolated. Toolsets make capabilities explicit.&lt;/p&gt;

&lt;p&gt;That sounds boring, which is why I like it.&lt;/p&gt;

&lt;p&gt;Boring infrastructure is what makes automation trustworthy.&lt;/p&gt;

&lt;p&gt;The more I use AI agents, the less interested I am in “look what this model can say” and the more interested I am in whether the surrounding system can behave predictably over time.&lt;/p&gt;

&lt;p&gt;Can it run every morning and only notify me when something needs attention?&lt;br&gt;
Can it publish an article without leaking secrets?&lt;br&gt;
Can it check the actual machine state before making a claim?&lt;br&gt;
Can it avoid old work contexts I no longer want referenced?&lt;br&gt;
Can it improve its own operating instructions when a workflow changes?&lt;/p&gt;

&lt;p&gt;That is where the productivity gain is.&lt;/p&gt;

&lt;p&gt;The model matters, obviously. But the scaffolding around the model matters more than people admit: state, tools, memory, permissions, scheduling, logs, profiles, and recovery paths.&lt;/p&gt;

&lt;p&gt;Hermes is starting to feel like the right layer for that: not a magic assistant, not a toy, not a demo — just a practical agent runtime I can keep shaping around my own workflows.&lt;/p&gt;

&lt;p&gt;That is probably the highest compliment I can give an automation system:&lt;/p&gt;

&lt;p&gt;I am starting to trust it with boring jobs.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>llm</category>
      <category>automation</category>
    </item>
    <item>
      <title>I Put TestFlight Behind a Preflight Gate Before It Can Touch the Repo</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Tue, 16 Jun 2026 08:04:53 +0000</pubDate>
      <link>https://dev.to/toddsullivan/i-put-testflight-behind-a-preflight-gate-before-it-can-touch-the-repo-5d65</link>
      <guid>https://dev.to/toddsullivan/i-put-testflight-behind-a-preflight-gate-before-it-can-touch-the-repo-5d65</guid>
      <description>&lt;p&gt;I added a preflight gate to an iOS + watchOS app build this week because the build script had a small but annoying property: it mutated the repo before it proved the repo was safe to build.&lt;/p&gt;

&lt;p&gt;The script stamps &lt;code&gt;CFBundleVersion&lt;/code&gt; with a timestamp before generating the Xcode project and archiving for TestFlight. That is useful because every upload gets a monotonically increasing build number.&lt;/p&gt;

&lt;p&gt;It is also exactly the wrong thing to do before quality checks.&lt;/p&gt;

&lt;p&gt;If formatting or tests fail after that stamp, the working tree is now dirty for a reason unrelated to the change I was trying to ship. Small thing, but this is how build scripts become suspicious. You run them, they fail, then you have to separate your actual diff from the build system's leftovers.&lt;/p&gt;

&lt;p&gt;So I moved the build into a fail-fast shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"==&amp;gt; Running preflight checks..."&lt;/span&gt;
&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/scripts/preflight.sh"&lt;/span&gt;

&lt;span class="nv"&gt;BUILD_NUMBER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d%H%M&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="s2"&gt;"s/CFBundleVersion: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;[^&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;]*&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;/CFBundleVersion: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$BUILD_NUMBER&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/project.yml"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important bit is ordering. &lt;code&gt;preflight.sh&lt;/code&gt; runs before the build number is touched.&lt;/p&gt;

&lt;p&gt;The gate currently has four checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. no local surprises&lt;/span&gt;
git &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; status &lt;span class="nt"&gt;--porcelain&lt;/span&gt;

&lt;span class="c"&gt;# 2. formatting is enforced, not auto-mutated&lt;/span&gt;
swiftformat &lt;span class="nt"&gt;--lint&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/SessionzApp/"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/SessionzKit/Sources/"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/SessionzKit/Tests/"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/SessionzWatch/"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--config&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/.swiftformat"&lt;/span&gt;

&lt;span class="c"&gt;# 3. local Swift package tests&lt;/span&gt;
swift &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;--package-path&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/SessionzKit"&lt;/span&gt; &lt;span class="nt"&gt;--parallel&lt;/span&gt;

&lt;span class="c"&gt;# 4. backend edge-function unit tests&lt;/span&gt;
deno &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/supabase/functions/_lib/"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line is the one I care about most.&lt;/p&gt;

&lt;p&gt;The app has a Claude-backed plan generation flow. The mobile app sends goals, equipment, available weights, and a small training context to a backend function. The backend validates auth/subscription state, calls Claude, then returns structured JSON that becomes local SwiftData models.&lt;/p&gt;

&lt;p&gt;I do not want the only tests for that boundary to live in an app simulator.&lt;/p&gt;

&lt;p&gt;So I pulled the pure logic out of the Edge Function handlers into &lt;code&gt;_lib/&lt;/code&gt; modules and tested that directly with Deno:&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="c1"&gt;// subscription.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isActiveStatus&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="kr"&gt;string&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="nx"&gt;boolean&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;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;active&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&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;trialing&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;validateProductId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;productId&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="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;productId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;productId&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same idea on the Swift side. The AI coach needs compact summaries of recent training, not an unbounded dump of every logged set. &lt;code&gt;PerformanceSummary&lt;/code&gt; condenses the last 42 days into short lines like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Dumbbell Floor Press: best 20 kg × 8, trained 3×
Push-up: best 20 reps (bodyweight), trained 2×
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That now has regression coverage for the edge cases that tend to rot quietly: bodyweight vs weighted sets, zero-weight entries, sorting by training frequency, limiting output size, and ignoring sets outside the six-week window.&lt;/p&gt;

&lt;p&gt;The result is not fancy CI. It is just a local quality gate that refuses to make a TestFlight archive unless the repo is clean, formatted, and both sides of the AI boundary pass tests.&lt;/p&gt;

&lt;p&gt;This is the kind of infrastructure I like for AI features: boring checks around the non-boring part.&lt;/p&gt;

&lt;p&gt;The model can be probabilistic. The system around it should not be.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Source:&lt;/strong&gt; Recent Sessionz commits: &lt;code&gt;scripts/preflight.sh&lt;/code&gt;, TestFlight build integration, Swift regression tests for plan/performance logic, and Deno tests for backend Edge Function helpers.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Tags:&lt;/strong&gt; ai, swift, testing, devops&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Status:&lt;/strong&gt; published&lt;/p&gt;

</description>
      <category>ai</category>
      <category>swift</category>
      <category>testing</category>
      <category>devops</category>
    </item>
    <item>
      <title>I Replaced Hardcoded Workouts with a Claude-Generated Plan System</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Tue, 09 Jun 2026 08:03:48 +0000</pubDate>
      <link>https://dev.to/toddsullivan/i-replaced-hardcoded-workouts-with-a-claude-generated-plan-system-4hoi</link>
      <guid>https://dev.to/toddsullivan/i-replaced-hardcoded-workouts-with-a-claude-generated-plan-system-4hoi</guid>
      <description>&lt;p&gt;I spent this week ripping out the fixed-routine part of a SwiftUI workout app and replacing it with a small AI planning system.&lt;/p&gt;

&lt;p&gt;Not a chatbot bolted onto the side. The app asks for goals, reads a compact set of real constraints, calls Claude through a backend function, then stores the resulting 7-day plan locally in SwiftData.&lt;/p&gt;

&lt;p&gt;The old version was simple: two hardcoded push/pull routines and a fixed exercise library. Fine for a prototype, but too rigid. If the user only has dumbbells up to 25kg, trains three days a week, and had a rough sleep week, the app needs to know that before it suggests anything.&lt;/p&gt;

&lt;p&gt;The new flow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;user configures equipment once&lt;/li&gt;
&lt;li&gt;user enters goals, capped at 300 chars&lt;/li&gt;
&lt;li&gt;app reads a small HealthKit summary if permission exists&lt;/li&gt;
&lt;li&gt;Supabase Edge Function verifies auth and subscription state&lt;/li&gt;
&lt;li&gt;Edge Function calls Claude&lt;/li&gt;
&lt;li&gt;app parses the returned JSON into SwiftData models&lt;/li&gt;
&lt;li&gt;watchOS session tracking uses the generated plan day&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The important bit is the prompt shape. I did not want prose back from the model. I wanted app data.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userMessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`Goals: &lt;/span&gt;&lt;span class="p"&gt;${(&lt;/span&gt;&lt;span class="nx"&gt;goals&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="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;
Equipment: &lt;/span&gt;&lt;span class="p"&gt;${(&lt;/span&gt;&lt;span class="nx"&gt;equipment&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="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="s2"&gt;
Available weights (kg): &lt;/span&gt;&lt;span class="p"&gt;${(&lt;/span&gt;&lt;span class="nx"&gt;weights&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;number&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="nx"&gt;healthContext&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
Schema: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;PLAN_JSON_SCHEMA&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The system prompt is deliberately boring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Output ONLY valid JSON matching the schema provided.
No explanation. No markdown fences.
Exercise descriptions: 1 sentence max.
Metric units only.
Include warmup and rest seconds.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That boring prompt is the product decision. The UI does not need motivational filler. It needs &lt;code&gt;PlanDay&lt;/code&gt;, &lt;code&gt;WarmupExercise&lt;/code&gt;, and &lt;code&gt;PlannedExercise&lt;/code&gt; rows that can be rendered, edited later, and tracked on the watch.&lt;/p&gt;

&lt;p&gt;The response schema is inline and maps directly onto the local model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"planName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"days"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"dayNumber"&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="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"isRestDay"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"warmup"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"order"&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="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"duration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"exercises"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"order"&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="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"sets"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"repTargetLow"&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="nl"&gt;"repTargetHigh"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"suggestedWeightKg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;20.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"restSeconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;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;I also put the API call behind a server-side gateway instead of calling Claude directly from iOS. That gives me three useful controls:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JWT verification before generation&lt;/li&gt;
&lt;li&gt;subscription check before spending tokens&lt;/li&gt;
&lt;li&gt;monthly usage tracking per user&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The current limit is 10 plan generations per month. With the compact prompt, the target budget is roughly 800 input tokens and 600 output tokens per generation. That keeps AI cost predictable instead of letting a mobile text field become an unbounded invoice generator.&lt;/p&gt;

&lt;p&gt;On-device still matters here. The generated plan, session logs, equipment inventory, and active plan state live locally in SwiftData. The network is only used to create a new plan. Running a workout should not depend on an API being online.&lt;/p&gt;

&lt;p&gt;The lesson: AI features get much easier to ship when you stop treating the model response as content and start treating it as a typed boundary.&lt;/p&gt;

&lt;p&gt;Prompt in. JSON out. Validate, store, render, track.&lt;/p&gt;

&lt;p&gt;That is less magical than a chat UI, but it is much closer to software.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Source:&lt;/strong&gt; Recent SwiftUI/watchOS app work: replaced hardcoded routines with a Claude-backed 7-day plan generator, HealthKit summary input, Supabase Edge Function gateway, SwiftData local storage, and monthly usage limits.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Tags:&lt;/strong&gt; ai, swift, ios, claude&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Status:&lt;/strong&gt; published&lt;/p&gt;

</description>
      <category>ai</category>
      <category>swift</category>
      <category>ios</category>
      <category>claude</category>
    </item>
    <item>
      <title>The iOS 26 Deep-Link Bug That Only Happened When the App Was Already Open</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Tue, 02 Jun 2026 08:04:23 +0000</pubDate>
      <link>https://dev.to/toddsullivan/the-ios-26-deep-link-bug-that-only-happened-when-the-app-was-already-open-g01</link>
      <guid>https://dev.to/toddsullivan/the-ios-26-deep-link-bug-that-only-happened-when-the-app-was-already-open-g01</guid>
      <description>&lt;p&gt;I spent a chunk of this week chasing a bug that looked like auth, then routing, then Supabase, then Expo Router.&lt;/p&gt;

&lt;p&gt;Actual root cause: iOS 26 was receiving the magic link, but the running app never got the warm-start URL in JavaScript.&lt;/p&gt;

&lt;p&gt;Cold start worked:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;app not running&lt;/li&gt;
&lt;li&gt;tap &lt;code&gt;myapp://auth/callback?code=...&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;iOS launches the app&lt;/li&gt;
&lt;li&gt;Expo Router mounts &lt;code&gt;auth/callback&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;PKCE code exchange succeeds&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Warm start failed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;app already open&lt;/li&gt;
&lt;li&gt;tap the same magic link&lt;/li&gt;
&lt;li&gt;app comes foreground&lt;/li&gt;
&lt;li&gt;no JS &lt;code&gt;Linking&lt;/code&gt; event&lt;/li&gt;
&lt;li&gt;user is still logged out&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No crash. No useful exception. Just a login flow that works from killed state and fails from running state, which is exactly the kind of mobile bug that eats a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Native Part: SceneDelegate Still Matters
&lt;/h2&gt;

&lt;p&gt;On iOS 26, warm-start URLs are delivered through the scene lifecycle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UIScene&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;openURLContexts&lt;/span&gt; &lt;span class="kt"&gt;URLContexts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;UIOpenURLContext&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;for&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="kt"&gt;URLContexts&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;forwardURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Expo app already had an &lt;code&gt;AppDelegate&lt;/code&gt;, but warm-start delivery needed a &lt;code&gt;UIWindowSceneDelegate&lt;/code&gt; as well. The slightly non-obvious bit was how to forward the URL.&lt;/p&gt;

&lt;p&gt;Calling &lt;code&gt;RCTLinkingManager&lt;/code&gt; directly looked reasonable, but it only posts the React Native URL notification. &lt;code&gt;expo-linking&lt;/code&gt; was not listening to that path in this app. Routing through &lt;code&gt;AppDelegate.application(_:open:options:)&lt;/code&gt; let &lt;code&gt;ExpoAppDelegate&lt;/code&gt; forward the URL to all of its subscribers, including Expo's linking module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;forwardURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;URL&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="nv"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UIApplication&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;appDelegate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delegate&lt;/span&gt; &lt;span class="k"&gt;as?&lt;/span&gt; &lt;span class="kt"&gt;AppDelegate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;appDelegate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;application&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;open&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;options&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;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;RCTLinkingManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;application&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;open&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;options&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;That got the URL into JS.&lt;/p&gt;

&lt;p&gt;Then iOS 26 added one more trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Key Window Trap
&lt;/h2&gt;

&lt;p&gt;The system log had the clue: &lt;code&gt;key window is null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The app creates its &lt;code&gt;UIWindow&lt;/code&gt; in &lt;code&gt;didFinishLaunchingWithOptions&lt;/code&gt;, before the &lt;code&gt;UIWindowScene&lt;/code&gt; exists. &lt;code&gt;startReactNative&lt;/code&gt; calls &lt;code&gt;makeKeyAndVisible()&lt;/code&gt;, but at that point the window is not attached to a scene yet.&lt;/p&gt;

&lt;p&gt;On iOS 26, that meant &lt;code&gt;scene:openURLContexts:&lt;/code&gt; never fired for warm-start links.&lt;/p&gt;

&lt;p&gt;The fix was to associate the existing window with the scene and call &lt;code&gt;makeKeyAndVisible()&lt;/code&gt; again after &lt;code&gt;windowScene&lt;/code&gt; is set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UIScene&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;willConnectTo&lt;/span&gt; &lt;span class="nv"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UISceneSession&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="nv"&gt;connectionOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UIScene&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;ConnectionOptions&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;windowScene&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scene&lt;/span&gt; &lt;span class="k"&gt;as?&lt;/span&gt; &lt;span class="kt"&gt;UIWindowScene&lt;/span&gt; &lt;span class="k"&gt;else&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;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;appDelegate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UIApplication&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delegate&lt;/span&gt; &lt;span class="k"&gt;as?&lt;/span&gt; &lt;span class="kt"&gt;AppDelegate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;window&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;appDelegate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;windowScene&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;windowScene&lt;/span&gt;
    &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;makeKeyAndVisible&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;After that, &lt;code&gt;scene:openURLContexts:&lt;/code&gt; fired reliably. Verified on the iOS 26 simulator: native SceneDelegate log, AppDelegate openURL log, then the JS linking handler.&lt;/p&gt;

&lt;h2&gt;
  
  
  The JS Part: Don't Route During Foreground Animation
&lt;/h2&gt;

&lt;p&gt;Once the URL reached JS, the first instinct was to navigate to &lt;code&gt;/auth/callback&lt;/code&gt; and let that screen do the exchange.&lt;/p&gt;

&lt;p&gt;That was fragile for two reasons.&lt;/p&gt;

&lt;p&gt;First, during warm start the app is still moving through &lt;code&gt;UISceneActivationStateForegroundInactive&lt;/code&gt;. I saw the URL arrive at &lt;code&gt;10:56:09.749&lt;/code&gt;; foreground transition finished at &lt;code&gt;10:56:10.140&lt;/code&gt;. Navigation commands during that window can be silently dropped.&lt;/p&gt;

&lt;p&gt;Second, &lt;code&gt;auth/callback&lt;/code&gt; may already be mounted. A &lt;code&gt;useEffect([])&lt;/code&gt; on that screen will not re-run just because another link arrived.&lt;/p&gt;

&lt;p&gt;So I moved warm-start exchange into the always-mounted root layout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;linkSub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Linking&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;url&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="nx"&gt;url&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;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Linking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;queryParams&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;queryParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;
    &lt;span class="p"&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;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exchangeCodeForSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&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="p"&gt;{});&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unsub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useAuthStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signingIn&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&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="nx"&gt;signingIn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;unsub&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&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="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&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;unsub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is the split:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;native code guarantees warm-start URLs reach Expo linking&lt;/li&gt;
&lt;li&gt;root layout handles every warm-start URL event&lt;/li&gt;
&lt;li&gt;auth callback remains as the cold-start/race fallback&lt;/li&gt;
&lt;li&gt;navigation waits until the auth store has a settled session&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Takeaway
&lt;/h2&gt;

&lt;p&gt;Deep links are not one flow. They are at least two:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;cold start: launch app into a URL&lt;/li&gt;
&lt;li&gt;warm start: deliver URL into an already-running app&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you only test the cold path, your auth flow can look perfect while the common real-world case is broken.&lt;/p&gt;

&lt;p&gt;For Expo + React Native apps on newer iOS versions, I now treat warm-start deep links as their own integration test: app open, link tapped, native URL delivery observed, JS &lt;code&gt;Linking&lt;/code&gt; event observed, auth state settled, navigation after state settle.&lt;/p&gt;

&lt;p&gt;That is more ceremony than a one-line &lt;code&gt;Linking.addEventListener&lt;/code&gt;, but it is the difference between a demo login flow and a production one.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Source:&lt;/strong&gt; Recent Expo / React Native auth work: iOS 26 warm-start deep-link fix, SceneDelegate URL forwarding, PKCE exchange moved to root layout.&lt;br&gt;
&lt;strong&gt;Tags:&lt;/strong&gt; ios, reactnative, devops, javascript&lt;/p&gt;

</description>
      <category>ios</category>
      <category>reactnative</category>
      <category>devops</category>
      <category>javascript</category>
    </item>
    <item>
      <title>The Silent Code Path: When Your AI Runs on Camera But Not on Gallery</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Fri, 29 May 2026 08:17:16 +0000</pubDate>
      <link>https://dev.to/toddsullivan/the-silent-code-path-when-your-ai-runs-on-camera-but-not-on-gallery-21dn</link>
      <guid>https://dev.to/toddsullivan/the-silent-code-path-when-your-ai-runs-on-camera-but-not-on-gallery-21dn</guid>
      <description>&lt;p&gt;Here's a bug that's easy to miss and harder to debug: your AI runs perfectly on one input path, silently does nothing on another, and there's no error — just missing results.&lt;/p&gt;

&lt;p&gt;I hit this recently in an on-device inspection app. The flow is straightforward: capture a photo (camera or photo library), run AI hazard detection, overlay bounding boxes, trigger violation alerts if needed.&lt;/p&gt;

&lt;p&gt;Camera captures worked great. Gallery picks saved the photo fine. But no bounding boxes appeared, no alerts fired. The AI was just... not running.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Actually Happened
&lt;/h3&gt;

&lt;p&gt;Here's the stripped-down structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Camera capture handler&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleRequestCapture&lt;/span&gt; &lt;span class="o"&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;uri&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;savePhoto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uri&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;detectAndSave&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// ✅ AI runs&lt;/span&gt;
  &lt;span class="nf"&gt;checkViolationAlerts&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// Library pick handler  &lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleLibraryPick&lt;/span&gt; &lt;span class="o"&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;uri&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;savePhoto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ❌ detectAndSave was never called&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The library path was added later, modelled on the save logic but not the full inference pipeline. No crash. No warning. Just missing detections.&lt;/p&gt;

&lt;p&gt;The fix was six lines — copy the &lt;code&gt;detectAndSave&lt;/code&gt; + alert block into the library handler. But finding it took longer than writing the fix.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why This Pattern Is Easy To Ship
&lt;/h3&gt;

&lt;p&gt;On-device AI inference tends to get wired in during the happy path. You build the camera flow first, the AI gets integrated there, everything works in demos. Then you add the gallery pick as a "nice to have" — and because you're only thinking about the file I/O, you copy the save logic but not the inference call.&lt;/p&gt;

&lt;p&gt;There's no type error. No lint warning. The function name (&lt;code&gt;handleLibraryPick&lt;/code&gt;) doesn't imply inference should happen. The photo saves successfully, so from the app's perspective, nothing broke.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lessons From The Fix
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. Treat every input path as a first-class citizen.&lt;/strong&gt;&lt;br&gt;
If AI inference is core to your feature, it should run regardless of how the image arrived. Camera, gallery, deep link, background upload — all of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Extract inference into a shared pipeline.&lt;/strong&gt;&lt;br&gt;
After the fix I refactored toward a single &lt;code&gt;processPhoto(uri)&lt;/code&gt; function that both handlers call. Now if the pipeline changes, it changes in one place.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;processPhoto&lt;/span&gt; &lt;span class="o"&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;uri&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;savePhoto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uri&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;detectAndSave&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;checkViolationAlerts&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;handleRequestCapture&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uri&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;processPhoto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uri&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;handleLibraryPick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uri&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;processPhoto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. End-to-end tests that cover input variants.&lt;/strong&gt;&lt;br&gt;
Unit tests on &lt;code&gt;detectAndSave&lt;/code&gt; wouldn't have caught this — the function worked fine, it just wasn't being called. What you need is an integration test that exercises each entry point and asserts that inference results exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Visibility into what ran.&lt;/strong&gt;&lt;br&gt;
This is the sneaky part: when AI silently doesn't run, you need observability to know it happened. Adding a log line (&lt;code&gt;AI inference: skipped | ran on &amp;lt;uri&amp;gt;&lt;/code&gt;) to your inference call sites makes this class of bug immediately obvious in development.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Broader Pattern
&lt;/h3&gt;

&lt;p&gt;This isn't unique to AI. Any side-effect that only gets wired to one code path — analytics events, permission checks, cache invalidation — can silently miss inputs added later. But with AI inference it's particularly painful because the failure mode is invisible and the debugging surface is narrow (you're looking at model output, not a thrown error).&lt;/p&gt;

&lt;p&gt;Build the pipeline once, call it everywhere.&lt;/p&gt;

</description>
      <category>mobile</category>
      <category>ai</category>
      <category>reactnative</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Wrapping Apple's LiDAR Room Scanner as a Native Expo Module</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Wed, 27 May 2026 08:08:52 +0000</pubDate>
      <link>https://dev.to/toddsullivan/wrapping-apples-lidar-room-scanner-as-a-native-expo-module-cip</link>
      <guid>https://dev.to/toddsullivan/wrapping-apples-lidar-room-scanner-as-a-native-expo-module-cip</guid>
      <description>&lt;p&gt;Most property and field-service apps still ask users to manually enter room dimensions. Tape measure, pen, back to the app, mistype, repeat. It's tedious and it's lossy.&lt;/p&gt;

&lt;p&gt;I've been building a property inspection app and this week I shipped a feature that replaces manual floor-area entry with a LiDAR scan. Walk around a room for 30 seconds, hit Done — the app returns floor area, wall count, door count, window dimensions, and a confidence score for each surface. No internet connection required. No server round-trips. All on-device.&lt;/p&gt;

&lt;p&gt;Here's how I wired Apple's RoomPlan framework into a React Native / Expo app as a native module.&lt;/p&gt;

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

&lt;p&gt;Apple's &lt;code&gt;RoomPlan&lt;/code&gt; framework uses the LiDAR sensor on iPhone 12 Pro and later to build a structured 3D model of a room in real time. It gives you back typed objects: floors, walls, doors, windows — each with dimensions and a confidence rating (&lt;code&gt;high&lt;/code&gt;, &lt;code&gt;medium&lt;/code&gt;, &lt;code&gt;low&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The challenge is surfacing this in a cross-platform Expo app without hacking around the JS/native boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the Module
&lt;/h2&gt;

&lt;p&gt;Expo's native module API makes this cleaner than the old React Native bridge approach. The module exposes two functions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;isAvailable()&lt;/code&gt; — synchronous check, returns &lt;code&gt;true&lt;/code&gt; on LiDAR + iOS 17+&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;scanRoom()&lt;/code&gt; — async, presents a full-screen capture UI and resolves with structured results
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kt"&gt;AsyncFunction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"scanRoom"&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="nv"&gt;promise&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
  &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="kt"&gt;RoomCaptureSession&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isSupported&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;promise&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"UNSUPPORTED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Requires a LiDAR-equipped device"&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="c1"&gt;// present RoomCaptureViewController...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The view controller runs &lt;code&gt;captureSession.run(configuration:)&lt;/code&gt;, delegates back through &lt;code&gt;RoomCaptureSessionDelegate&lt;/code&gt;, and when the user hits Done it hands raw &lt;code&gt;CapturedRoomData&lt;/code&gt; to &lt;code&gt;RoomBuilder&lt;/code&gt;. The builder does a cleanup pass (with &lt;code&gt;.beautifyObjects&lt;/code&gt;) and returns a &lt;code&gt;CapturedRoom&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Comes Back
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;floorArea&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;room&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;floors&lt;/span&gt;
  &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dimensions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dimensions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&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="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I map the result into a plain dictionary — area in m², per-surface dimensions, confidence string — and resolve the Expo promise. The JS side gets a typed object:&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;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;RoomPlan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scanRoom&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;floorAreaM2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// e.g. 18.34&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;floors&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "high"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Graceful Degradation
&lt;/h2&gt;

&lt;p&gt;Non-LiDAR devices get &lt;code&gt;isAvailable()&lt;/code&gt; → &lt;code&gt;false&lt;/code&gt; and fall through to a manual input form. No crashes, no confusing errors. The LiDAR path is an enhancement, not a hard dependency.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why On-Device Matters Here
&lt;/h2&gt;

&lt;p&gt;For property data, sending floor plans to a cloud service introduces latency, cost, and privacy concerns. Every scan happening fully on-device means no API bills per-scan, no waiting on a round-trip, and no floor plan leaving the phone until the user explicitly submits their report.&lt;/p&gt;

&lt;p&gt;The practical result: floor area capture went from ~3 minutes (manual) to under 60 seconds with better accuracy than tape-measure entry. On iPhone Pro hardware, LiDAR readings are accurate to within a few centimetres on a normal rectangular room.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Limitation Worth Knowing
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;RoomPlan&lt;/code&gt; requires a LiDAR sensor — iPhone 12 Pro and later, or iPad Pro. Most fieldwork happens on phones. For a consumer app targeting everyone, you need a fallback. For a professional tool where you control (or specify) the hardware, worth requiring it outright.&lt;/p&gt;




&lt;p&gt;If you're building anything involving physical space measurement on iOS, RoomPlan is dramatically underused. Apple ships a full structured-capture pipeline and most apps still ask users to type numbers into a form.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>expo</category>
      <category>javascript</category>
      <category>ai</category>
    </item>
    <item>
      <title>When Your On-Device Model Decides How Often to Ping You</title>
      <dc:creator>Todd Sullivan</dc:creator>
      <pubDate>Mon, 25 May 2026 08:03:36 +0000</pubDate>
      <link>https://dev.to/toddsullivan/when-your-on-device-model-decides-how-often-to-ping-you-10h1</link>
      <guid>https://dev.to/toddsullivan/when-your-on-device-model-decides-how-often-to-ping-you-10h1</guid>
      <description>&lt;p&gt;Most notification systems are dumb. They fire on a fixed schedule decided by a developer who's never met the user. Every day at 9am. No matter what.&lt;/p&gt;

&lt;p&gt;I've been building an iOS health app that does something different: the on-device ML model's training state drives notification frequency. The more the model knows about you, the less it needs to ask.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem With Fixed Schedules
&lt;/h2&gt;

&lt;p&gt;For a health tracking app, consistent data collection is everything — it's the training set. Early on, you need daily check-ins to bootstrap the model. But once the model is trained and the user has established patterns, daily prompts become noise. People turn off notifications. You lose signal entirely.&lt;/p&gt;

&lt;p&gt;The naive fix is "let users choose their own frequency." That works until users pick "weekly" in week one when the model is still blind.&lt;/p&gt;

&lt;p&gt;The better fix: make the frequency a function of the data itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Logic
&lt;/h2&gt;

&lt;p&gt;I built a &lt;code&gt;NotificationFrequencyManager&lt;/code&gt; that recomputes check-in frequency after every engagement cycle and after every model training run. Three signals go in:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Data volume&lt;/strong&gt; — how many days of logs exist&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logging consistency&lt;/strong&gt; — what percentage of the last 14 days has at least one entry (≥70% threshold to step down)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ML training state&lt;/strong&gt; — is the personalised model untrained, training, or trained?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The output maps to three frequencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="kt"&gt;NotificationFrequency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Codable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Comparable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;high&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;  &lt;span class="c1"&gt;// Daily — new users, untrained model&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;medium&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;  &lt;span class="c1"&gt;// Every 2 days — moderate data, some consistency&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;low&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;  &lt;span class="c1"&gt;// Every 4 days — trained model, high consistency&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's a hard floor at 4 days. Even the most consistent user with a fully trained model gets nudged at least every 4 days. The model still needs new data to stay fresh. Silence would mean drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing the Loop
&lt;/h2&gt;

&lt;p&gt;The clever part is that ML training completion &lt;em&gt;directly triggers&lt;/em&gt; a frequency recalculation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;DailyLog&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;trainingState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;TrainingState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;NotificationFrequency&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;frequency&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;compute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;trainingState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;trainingState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;currentFrequency&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;frequency&lt;/span&gt;
    &lt;span class="n"&gt;storedFrequency&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;frequency&lt;/span&gt;  &lt;span class="c1"&gt;// persisted to App Group UserDefaults&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;frequency&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the flow is: user logs data → engagement manager runs → model retrains → frequency updates → next notification scheduled at new interval. The app literally quiets down as it learns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Smooth Transitions
&lt;/h2&gt;

&lt;p&gt;One thing I got wrong in early iterations: jumping straight from daily to every-4-days felt jarring in testing. The fix was requiring &lt;em&gt;both&lt;/em&gt; consistency &lt;em&gt;and&lt;/em&gt; data volume thresholds before stepping down. You can't hit &lt;code&gt;low&lt;/code&gt; unless you've been on &lt;code&gt;medium&lt;/code&gt; for at least a week.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Only step from medium → low if model is trained AND consistency is high&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;dataVolume&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;consistency&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;trainingState&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trained&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;low&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why This Matters
&lt;/h2&gt;

&lt;p&gt;The insight generalises well beyond health apps. Any time you have an adaptive ML component, the model's confidence or training state is a signal you can use to drive other app behaviour. Frequency of prompts. Depth of UI. Whether to show explanations or trust the user already gets it.&lt;/p&gt;

&lt;p&gt;The model knowing you means the app behaves differently for you. That's actually what "personalised AI" should mean — not just personalised outputs, but personalised interactions.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>swift</category>
      <category>machinelearning</category>
      <category>mobiledev</category>
    </item>
  </channel>
</rss>
