DEV Community

Marco Schweizer
Marco Schweizer

Posted on • Originally published at traeumli.app

Building a bedtime-story app with AI and clear guardrails

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.

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.

The honest part parents actually feel

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.

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.

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."

The bug that might have charged people and then lost their story

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.

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 NSURLErrorNetworkConnectionLost. 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.

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.

The fix: make the story survive the client

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.

create table public.story_generation_jobs (
  id uuid primary key default gen_random_uuid(),
  user_account_id uuid not null references public.user_accounts (id) on delete cascade,
  status text not null default 'pending'
    check (status in ('pending', 'processing', 'completed', 'failed')),
  story_request jsonb not null,
  story_version jsonb,
  mondstaub_amount numeric(12, 2),
  -- …
);
Enter fullscreen mode Exit fullscreen mode

The result is written into the job row, so it survives the app being backgrounded or killed. POST /story-requests now returns 202 {jobId, status} 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.

The actual leak fix is two decisions working together.

First, deduct only on durable completion, in one transaction, and make it at-most-once at the database level with a partial unique index:

-- A story generation job can be charged at most once, keyed on the job id.
create unique index mondstaub_transactions_story_deduction_once
  on public.mondstaub_transactions (source, reference_id)
  where source = 'story_deduction';
Enter fullscreen mode Exit fullscreen mode

The completion RPC selects the job for update guarded on status = 'processing', inserts the deduction with on conflict do nothing, 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.

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:

export const STORY_JOB_TIMEOUT_MS = 4 * 60 * 1000;
export const STORY_JOB_STALE_MS = STORY_JOB_TIMEOUT_MS + 60 * 1000;
export const STORY_JOB_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
Enter fullscreen mode Exit fullscreen mode

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

The recovery shelf: paid-for work you'd otherwise lose

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.

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.

The rest of the stack, briefly

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.

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.

What I'd tell past me

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.

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.

Träumli is on the App Store and Google Play now if you want to poke at it. I would genuinely like to hear where it still breaks, from builders and parents both.

Top comments (0)