DEV Community

Cover image for I built my brother a bedtime storyteller that runs on his laptop
Asuran
Asuran Subscriber

Posted on

I built my brother a bedtime storyteller that runs on his laptop

Hacktoberfest Weekend Challenge: Build for a Friend Submission 🤝

This is a submission for the Hacktoberfest Weekend Challenge: Build for a Friend

My brother and his wife are lovely parents and terrible storytellers. They'd both tell you that. Their kid is four, has just started school and wants a new story every single night. By the third night of "once upon a time there was a... dog?" everyone in that bedroom knows it isn't going well.

So I built them Nightjar.

Nightjar: the kid picks a picture and a story is written for them

What I Built

Nightjar is a bedtime storyteller that runs on their laptop.

  • The kid picks. Eight picture tiles a four year old can tap without reading: Pim the turtle, the moon, snow, a friendly dragon, a sleepy train, teddy, the sea and Big school, which I added for the first weeks of school. Or they can say anything they like. Short story or a long one.
  • An open model writes it. Gemma 3 1B through Ollama, on the laptop. No account, no cloud.
  • A guardrail reads every word first. Not a prompt asking nicely. A rule that runs on the output and can't be argued with.
  • It's read aloud, word by word. An offline voice (Kokoro-82M) reads it with each word lit as it's spoken, so the kid can follow along. A soft lullaby plays underneath, a new one for every story.
  • Then it stops. "The end. Goodnight." Nothing auto-plays the next one. This is bedtime, not YouTube.
  • And it learns. A grown-up taps once when the kid is asleep. TabPFN reads those nights and picks tomorrow's length, pace and how calm the story should be.

That last part is the one I couldn't find anywhere else. I checked before building: there are several bedtime story generators on GitHub, a couple even clone a parent's voice. None of them ask whether the story worked.

Demo

Live site: https://nightjar-bedtime.vercel.app

Try this What happens
Home, tap Voice off to turn it on A recorded chapter read aloud over a lullaby, each word lit. Tap any word to jump there
Ask for a story, tap Big school A new story written inside your browser, screened, then read with your device's voice
The tuner, change any "asleep in" number All 72 story settings rescored, tomorrow's pick moves
Break the guardrail, load "zero width bypass" A hidden spelling of "monster" refused, with "Python agrees"
The book The week's chapters as a page-turning book, plus a printable A5 PDF

The storybox reading a chapter, each word lit as it is spoken

A fair warning about the in-browser version: it downloads Gemma 3 270M (550 MB without WebGPU) and on a laptop with no GPU a story takes several minutes. It's there so you can try it. The real thing is the laptop box: nightjar serve puts the same page on 127.0.0.1, where a short story is written and voiced in about three minutes and a long one in about four and a half. I timed those in Chrome against the real models, not guessed.

A long Big school story on the laptop box, read aloud

Code

GitHub logo zkasuran / nightjar

Bedtime stories that learn what puts her to sleep. Local Gemma, a hard guardrail, TabPFN.

Nightjar: bedtime stories that learn what gets them to sleep

Live site · DEV post · Try it · How it works · Real vs simulated · Security · Run it

tests npm audit core deps model licence

Nightjar is a bedtime storyteller I built for my brother and his wife. Their kid is four and just started school. They would both tell you they are bad at bedtime stories.

So the kid taps a picture or says what tonight's story should be about. An open model on the laptop in their house writes it, short or long. A guardrail outside the model reads every word before anyone hears it. Then an offline voice reads it aloud over a soft lullaby made for that story, with each word lit as it is spoken. One chapter, then "The end. Goodnight."

The part other story generators skip: a grown-up taps once when the kid is asleep. A tabular model picks tomorrow's length, pace and calm level from those minutes…




How I Built It

tap a picture → tuner picks tonight's settings → series bible adds last night
  → Gemma 3 1B writes (Ollama, local) → guardrail screens, rewrites up to 3×
  → Kokoro narrates with a word clock → lullaby under it → "The end"
  → grown-up taps "asleep in 10" → tomorrow's settings
Enter fullscreen mode Exit fullscreen mode

The guardrail. It normalises text first (NFKC, zero width characters stripped, curly quotes folded) so mon​ster and monster are both "monster". Then it checks banned words, the kid's own fear list, the model talking about itself, leaked JSON and length. The title too, because the title gets read aloud.

That title rule exists because of a real bug. My first test asked for "a thunderstorm and a monster in the basement". Gemma handled it nicely and wrote about gentle rain. Then it called the chapter "Thunder and the Moon's Light". Thunder was on the fear list. I'd only been checking the body.

Scary requests aren't refused, by the way. A four year old asking for a dragon that breathes fire deserves a dragon. The request is screened and the prompt asks for a friendly version. The output guardrail still has the final say.

Try to sneak a monster past the guardrail

The tuner. One row per night means the data will never be big. Ten rows is a lot for a real family. That's hopeless for anything you train with gradients and it's exactly what TabPFN was built for: in-context learning on tiny tables, no training step. Leave one out on the sample log:

Model Error predicting a hidden night
Always guess the average 4.99 min
k-NN 2.32 min
TabPFN 0.62 min

I need to be honest about that table. Those nine rows are sample data I wrote with one clean pattern in them: shorter, slower and calmer stories mean faster sleep. A strong model finds a clean pattern. It proves the pipeline works. It doesn't prove Nightjar helps a real kid. Only real nights can. That's what the next few weeks are for.

The tuner: every story setting scored, tomorrow's pick highlighted

The voice. My first version used the browser's speech engine. On Linux it was scratchy. The highlighted words fell behind and then restarted from the top every time you paused. So the chapters are narrated offline by Kokoro, which reports when each word starts. The player runs on one clock: the audio's own position when the voice is on, a silent timer when it's off. Pause, resume, tap a word, drag the bar, switch chapters: they all move that one number, so the words can't drift from the sound. A browser test checks the lit word against the audio position in every one of those cases.

On a phone, a story written in the browser has no recording, so it's read with the phone's own voice (it picks Siri or Google voices over the robotic ones), one sentence at a time.

The lullaby is composed live with Web Audio: a soft pad, a music box on a slow five-note scale and a low root note, through a little reverb. The story's title seeds the key, chords, tempo and melody, so every story gets its own tune. Nothing recorded, nothing to license.

Long stories. A 1B model won't write past about 150 words no matter how you ask. So a long story is three parts that continue each other. That took a few tries. The first long story ended, said goodnight, then started again in part two, because the system prompt told it every story ends asleep. Middle parts now get a prompt that forbids ending. They carry the opening so names don't change (the dragon "Sparkle" became "Flicker" two parts later). They're refused if they repeat an earlier sentence.

What I found reading the transcripts back

I recorded eight nights from the real command line and read them the next morning. That taught me more than any test I'd written.

  • The model ignores length. Asked for 220 words it wrote 77 to 156. "Too short" was the most common refusal.
  • Two accepted chapters ended with the prompt's own field name read aloud: "Open thread: Maybe tomorrow the moon will show Pim a rainbow." Both passed on the night. That's a rule now. The site still shows both, marked as slipped.
  • The kid asked for "a dragon that breathes fire" and got Pim wishing for "a tiny flame". "flames" is banned. "flame" isn't. A word list catches words, not ideas. The site says so on its Limits page.
  • Right after publishing, I asked for "horror" myself and it failed every draft. My prompt said "don't use the word horror" and the retry said "rejected for: horror". A small model repeats what it reads, so it wrote "horror" three times. Now the request is rewritten before the model sees it: horror becomes a cosy story where a shadow turns out to be a kind friend, a zombie becomes a sleepy giant. The scary word never reaches the prompt at all.
  • With the whole series history in the prompt, the model wrote about the turtle every time the kid asked for a dragon. When there's a request now, only last night's one line summary goes in.

Eight recorded nights, with the drafts the guardrail threw away

Built to be poked at. One gate, ./verify.sh, runs 65 Python tests (fuzzing, bypass attempts, tampered files, the local server refusing other hosts) and 16 web tests that hold the TypeScript guardrail and tuner to the Python results. The site has a strict CSP and checks a sha256 on every data file and audio file before using it. nightjar serve only answers on 127.0.0.1 and refuses any other Host header.

Why Does Open Innovation Matter?

For this project it isn't a principle. It's four practical problems a closed API couldn't solve.

  1. The kid's data stays home. A four year old's name, fears and the minute they fall asleep every night are about the worst thing to send to someone else's server. Here it's three small files on my brother's laptop.
  2. It's free every night. Around 350 stories a year at zero cost per story. A metered API turns every bedtime into a small decision. Open weights make it a habit.
  3. It works with the wifi down. At 8 pm with a tired kid, "the internet's out" is a broken promise.
  4. I can build around it. A guardrail outside the model, a tuner choosing its settings, an offline voice with word timings, a version pinned so it never changes under them. You can't wrap a closed endpoint that tightly.

Handing it over

This is going onto my brother's laptop this week, with Big school as the first tile. The kid in the live demo is a placeholder called Mira and the sleep log is sample data, both labelled on every page. I'll update this post with what the three of them actually think. I suspect the kid will mostly want the dragon.

Prize Categories

  • Best Use of Gemma. Gemma 3 1B writes every story locally through Ollama, short or in three parts. Gemma 3 270M and 1B also run in the browser.
  • Best Use of TabPFN. The TabPFN v2 regressor chooses each night's story settings from minutes to sleep. Built with PriorLabs-TabPFN.

Built with AI coding assistance. I directed it, reviewed the code and ran everything above myself; the numbers come from real runs. Gemma is provided under and subject to the Gemma Terms of Use found at ai.google.dev/gemma/terms. Narration: Kokoro-82M (Apache-2.0).

Top comments (0)