<?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: Marco Schweizer</title>
    <description>The latest articles on DEV Community by Marco Schweizer (@msc2601).</description>
    <link>https://dev.to/msc2601</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%2F4025523%2F9c3b467d-71aa-435c-84ca-43cc7d6e0f6b.png</url>
      <title>DEV Community: Marco Schweizer</title>
      <link>https://dev.to/msc2601</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/msc2601"/>
    <language>en</language>
    <item>
      <title>Building a bedtime-story app with AI and clear guardrails</title>
      <dc:creator>Marco Schweizer</dc:creator>
      <pubDate>Sat, 11 Jul 2026 20:56:41 +0000</pubDate>
      <link>https://dev.to/msc2601/building-a-bedtime-story-app-with-ai-and-clear-guardrails-45nf</link>
      <guid>https://dev.to/msc2601/building-a-bedtime-story-app-with-ai-and-clear-guardrails-45nf</guid>
      <description>&lt;p&gt;Bedtime in our house hit a wall. My kid wanted a new story every night, and "the one about the dragon, but different" is a rough brief at 8pm when you are running on empty. So I built the thing I wished existed, solo, and shipped it.&lt;/p&gt;

&lt;p&gt;Träumli is a bedtime-story app that writes a fresh, personalized story with your child as the hero: you set up who they are and a couple of guardrails, and you get a story to read aloud in a few moments. This post is the developer cut, and it is honest in two directions. Honest with parents about what the AI can't do, and honest with you about what broke the first time I shipped it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest part parents actually feel
&lt;/h2&gt;

&lt;p&gt;I lead with what the app can't do, on purpose. It does not replace you reading to your child, and it does not read to them for you. The stories are fiction by design, because an LLM will state a wrong fact with total confidence and bedtime is not the place to find out. AI can spin a new dragon every night; it cannot know your daughter cried at preschool today and might want to process those feelings. That line is the product, not a disclaimer I buried in a settings screen.&lt;/p&gt;

&lt;p&gt;That stance has a technical consequence, and being honest about it matters. Each listener carries an exclusion list (no thunder tonight, no scary forests, whatever the current fear is). That list goes into the generation request as an explicit priority: the boundaries outrank the story goal, so if the two ever collide the model is told to follow the boundaries and fall back to a calm alternative. The output is then schema-validated before anything is shown, so a malformed payload is rejected instead of rendered.&lt;/p&gt;

&lt;p&gt;What I deliberately do not do is pretend a prompt instruction is a guarantee. There is no second model grading the first, and I will not claim the boundaries hold 100 percent of the time, because I can't prove that and this whole app is supposed to be honest about the AI. The real backstop is a human: the parent should always read the story before they read it aloud. Model-side steering plus a person in the loop, not "the AI is safe, trust it."&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that might have charged people and then lost their story
&lt;/h2&gt;

&lt;p&gt;My first version of story generation was one synchronous POST held open for about two minutes while the model wrote. It demoed beautifully and it was quietly broken.&lt;/p&gt;

&lt;p&gt;On iOS, if a parent backgrounded the app mid-generation (which is exactly what a tired parent might do), the OS suspended the process and tore down the socket with &lt;code&gt;NSURLErrorNetworkConnectionLost&lt;/code&gt;. The server did not know or care: it finished generating and deducted Mondstaub, the in-app currency. But the response had nowhere to go. The charge fired after the model returned and before the story reached the client, with no rollback.&lt;/p&gt;

&lt;p&gt;So the worst possible outcome for a paid feature: the parent paid, and got nothing. It never showed up in a quick demo because you do not background the app during a demo. It showed up in real bedtimes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: make the story survive the client
&lt;/h2&gt;

&lt;p&gt;The reframe that fixed it: the story is durable server-side work that outlives the connection, not a value returned from a request. The client submits, gets a job id, and polls.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;story_generation_jobs&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;user_account_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_accounts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;cascade&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;
    &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'processing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'completed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'failed'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="n"&gt;story_request&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;story_version&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;mondstaub_amount&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="c1"&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 result is written into the job row, so it survives the app being backgrounded or killed. &lt;code&gt;POST /story-requests&lt;/code&gt; now returns &lt;code&gt;202 {jobId, status}&lt;/code&gt; instead of blocking. Delivery is polling only, which had a nice side effect: the whole thing shipped as an over-the-air update, no push infrastructure and no native release.&lt;/p&gt;

&lt;p&gt;The actual leak fix is two decisions working together.&lt;/p&gt;

&lt;p&gt;First, deduct only on durable completion, in one transaction, and make it at-most-once at the database level with a partial unique index:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- A story generation job can be charged at most once, keyed on the job id.&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;unique&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt; &lt;span class="n"&gt;mondstaub_transactions_story_deduction_once&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mondstaub_transactions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reference_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="k"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'story_deduction'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The completion RPC selects the job &lt;code&gt;for update&lt;/code&gt; guarded on &lt;code&gt;status = 'processing'&lt;/code&gt;, inserts the deduction with &lt;code&gt;on conflict do nothing&lt;/code&gt;, persists the story and a balance snapshot, and flips the status, all in the same transaction. A redelivered completion becomes a no-op that just returns the current balance. You cannot double-charge, and you cannot charge for a story that did not durably land.&lt;/p&gt;

&lt;p&gt;Second, generation runs as an in-process background runner on the existing Hono/Node server. No queue infrastructure, because a little solo app does not need Kafka to write a bedtime story. What it does need is a way to fail safely if the server restarts mid-generation:&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;const&lt;/span&gt; &lt;span class="nx"&gt;STORY_JOB_TIMEOUT_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;STORY_JOB_STALE_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;STORY_JOB_TIMEOUT_MS&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;STORY_JOB_RETENTION_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A boot reaper plus a periodic sweep mark stale &lt;code&gt;pending&lt;/code&gt;/&lt;code&gt;processing&lt;/code&gt; jobs as &lt;code&gt;failed&lt;/code&gt;. Because the charge only happens on completion, a job killed by a deploy costs the user nothing. The upfront balance check (a plain &lt;code&gt;402&lt;/code&gt; if you cannot afford it) stays, so you find out before you wait, not after.&lt;/p&gt;

&lt;h2&gt;
  
  
  The recovery shelf: paid-for work you'd otherwise lose
&lt;/h2&gt;

&lt;p&gt;Once generation was durable, a subtler gap opened: a story can be fully generated and charged for, and still never become something the parent kept, because they read it and closed the app. The story exists; it is just stranded.&lt;/p&gt;

&lt;p&gt;The answer is a Story Recovery shelf, and the design constraint I care about most is that it is not a history. It is a self-emptying triage tray: a completed, un-kept story stays reachable for 7 days, and it leaves the moment you Keep it, Discard it, or it expires. Keeping does not re-charge and discarding does not refund, because the Mondstaub was spent once, at generation, guarded by that same idempotency index. The parent paid for a generated story; deciding later whether to keep it is not a second purchase.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest of the stack, briefly
&lt;/h2&gt;

&lt;p&gt;It is a monorepo: an Expo / React Native app for iOS and Android, a Next.js site and blog (this page), a Hono/Node API, and an internal admin. Supabase for data, auth, and RLS; RevenueCat for purchases.&lt;/p&gt;

&lt;p&gt;Two product choices worth naming. Privacy is parent-operated by design: the parent runs the app, the child never touches it, and I do not collect data on children. That is the right thing to do under GDPR, and it also keeps the app out of the kid-directed store regimes that would restrict the analytics and crash reporting I actually need. And monetization has no required subscription: a few stories free, then a one-time pack that never expires, with an optional auto-topup sub you never need. I did not want the thing I built for my own kid to be another monthly charge you forget to cancel.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell past me
&lt;/h2&gt;

&lt;p&gt;Ship the thing that survives a backgrounded app, not the thing that demos well. The synchronous version passed every test I thought to write, because I was testing the happy path on a phone I was actively looking at. The failure only lived where real users live: distracted, interrupted, one thumb on the home gesture.&lt;/p&gt;

&lt;p&gt;None of this was clever engineering. It was mostly stemming from focusing too much on the happy path and losing sight of edge cases or typical failure scenarios. This is the first mobile app I shipped and there were many valuable lessons to be learned and I am sure even more will come in the near future.&lt;/p&gt;

&lt;p&gt;Träumli is on the &lt;a href="https://www.traeumli.app/get?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=launch" rel="noopener noreferrer"&gt;App Store and Google Play&lt;/a&gt; now if you want to poke at it. I would genuinely like to hear where it still breaks, from builders and parents both.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>postgres</category>
      <category>node</category>
      <category>mobile</category>
    </item>
  </channel>
</rss>
