Build write-up · 10 min read
How I built Lucy Learns
A dog-training app that works offline, built for my own household. I designed, built and illustrated it alone, with the AI tools run from a terminal instead of a chat window. What that changed about how I work, and what it did not.
- Role
- Everything: product, design, build
- Built with
- Plain JavaScript, a service worker and the Web Speech API; no framework, no build step
- Runs as
- An installable phone app that works offline, served from GitHub Pages
- Status
- In use, still in development
-
01 Pick up where the trainer left off
-
02 Run it one instruction at a time
-
03 Finish on a win
My approach
-
01Research
- A trainer’s handouts, written for someone sitting still
- Practice watched where it happens: leash in one hand, five minutes
- Acceptance criteria written to be falsifiable on a real phone
-
02Design
- Progress in the trainer’s levels, not points and streaks
- One instruction per screen, one tap per rep
- Voice commands cut after real sessions, then a step timer
-
03Build
- Plain JavaScript modules: no framework, no build step, no dependencies
- Service worker for offline, local storage for state, Web Speech API to read the steps aloud
- Illustrations from JSON specs a Node script posts to an image model
- AI built working versions in an afternoon. I judged them
-
04Test
- On a real phone: installed, offline, one-handed
- A contrast script gates every commit against the WCAG floor
- A blind pilot: four models, shuffled order, filenames hidden
- Two pictures failed and were redrawn until strangers read them right
The problem
We hired a professional trainer for our dog and got a stack of handouts: good instructions, written for a person sitting still. Practice happens in a hallway, leash in one hand and treat pouch in the other, five minutes at a time. By evening nobody remembers whether the barking is getting better or it was just a bad Tuesday.
So the app has a narrow job: turn the handouts into something you can run one-handed. It also records enough that “is this working” has an answer. It supports the trainer. It does not replace one or diagnose behavior.
Five decisions that shaped it
Progress is measured in the trainer’s units, not mine
The obvious move is points, streaks or a satisfying progress bar. The unit here is the level, the trainer’s own. The number on the app’s program map is the number in her lesson report, so nobody reconciles the two in their head. Points would have looked more like a product and been worse at the job.
-

The program, in her levels
-

The week, ready to hand to the trainer
-

The cues, in her words
Logging happens during the session, not after it
One tap after each repetition, one more on arousal to save the session. That is the entire logging burden, capped deliberately: anything heavier does not get used with a dog attached to your other arm. The tally records what happened, not a reconstruction at the end of the day.
Speaking and listening, split
Voice looks like one feature. It is two, and they cost nothing like the same thing.
Speakingon by default
- Runs on the device
- No permission needed
- Works with no signal
Listeningoff by default
- Ships audio to a server
- Needs a microphone grant
- Needs a connection
Listening came out the first time it met the hallway
The split held up. The listening half did not, and the microphone was not why. It was a misconception, and it was mine: I designed the commands for the handler and forgot who else was listening.
A handler saying “next step” brightly at a phone is saying words in the cue voice. The dog is being taught that words in that voice are for her. The commands avoided every cue and still shared the room with them, and Lucy was distracted by every one. The AI I built it with did not flag that up front either. A couple of real sessions did.
So the listening came out and the steps turn by themselves instead:
- A switch on the get-ready screen puts each step on a timer. The same control sits on every step, beside a pause and a live count. The pace gets changed where it feels wrong, not on a settings page
- The Next button fills as the clock runs, so from arm’s length you can see it about to press itself
- The clock never answers the rep question. It walks the steps and stops on the last one, because a timer that answered it would be the reflex “went well” made automatic
- One session rewrote every label to say timer or step, because “Move on by itself” was met with “move what on?”
- The voice reads the step’s number in front of each instruction: the beat the handler wanted, and the cushion an idle audio route needed
- Reading aloud did not move at all, because it asks nothing of anyone’s voice
- 1
- day to replace it with a timer that walks the steps
- 0
- changes to the speaking half, which shipped on and stayed on
Listening was a conversational interface. It failed on nothing an accuracy figure measures. The words were caught and the steps turned. The number would have looked fine. No review of the design would have found it, mine or the AI’s. The problem was in the room. Only using the product there, with the people and the dog it is for, could show it.
What a voice feature has to settle before it ships is not whether it hears the person using it. It is who else is in earshot, and what that sound has already been taught to mean.
Built for two postures
The session screen is designed around where your hands are, which turns out to be two different places.
In one hand
Phone in whichever hand the leash leaves free. The app reads each step aloud so you never look down, and with the timer on the steps turn by themselves. The one tap left is the one that says how the rep went.
On a stand
The setup for the busiest moments: the phone, or an iPad, propped upright in portrait where you practice. The instruction reads from a step away. The steps turn on the timer. Both hands stay with the dog until the rep question needs a thumb.
A terminal, not a chat window
It did not decide what to build. Every decision above came from watching two people fail to practice consistently, and from twenty years of knowing which shortcuts turn into support tickets later.
The hard part of illustrating a training app is not getting a model to draw a woman training a dog. It is that it has to be the same dog every time, in the same room. Nothing may change from one screen to the next except the behavior being taught.
The dog turns toward her. The leash goes slack. The door opens. That is a design problem before it is a drawing problem. It has an established state, one intended change, and a long list of things that have to stay put.
What the terminal changed is how far I can carry that alone. Each scene is a JSON spec that a Node script assembles and posts to OpenAI’s image model. The reference images go in a set order, each with a named job:
- The style exemplar, always first
- A likeness sheet for the handler and one for Lucy: black coat, white chest, scruffy face, collar with the round blue tag, never a harness
- For an adjacent moment, the previous picture itself, so that nothing but the action may differ
- The wording to put in front of it, its numbered list generated from that same array
- One sentence naming what this picture has to get right, which every round is checked against before anyone looks at it twice
A chat window drew the first eight and it worked. What it lost is what makes a bad picture diagnosable: which references were attached, and in what order, the strongest lever on consistency there is.
As ordered data, a wrong request is refused before it costs anything. A redraw that drifts goes back to the last round that was right, not forward from the drifted one. Every round is on file, so going back is a choice rather than a memory.
The four styles the app has had show where the time went.
-
01Painted, in a chat window
The set the app launched with. It is a picture of a house rather than an instruction. The room has more detail than the step, and at the 56 pixels the program map gives it, none of it survives. Lucy is also not the same dog twice: twelve of the thirty it drew put her in a harness she does not own.
-
02Warm vector, still in a chat window
The room empties out and the rules about the dog get absolute. Flat fills, one action per frame, never a harness in any image, ever. It was still drawn by pasting paragraphs into a chat window. No round was recorded. No picture could be asked for the same way twice.
-
03Cool and flat, from the terminal
The first set a script drew rather than a conversation: every scene a JSON spec, the reference images ordered, every round on file. It also took the app’s chrome a little too literally and painted the violet onto the walls. That was the one thing nobody asked for.
-
04The wall repainted, in one afternoon
The violet belonged to the app, not the room, and taking it back out meant repainting every wall in the library. The same change a month earlier would have been another five days in a chat window. This time it was an afternoon, with every round on file.
-

01 First launch, explained with a picture
-

02 Every level opens on its scene
-

03 Twenty-four portraits from the same pipeline
Making a picture got cheap, so making one stopped being the accomplishment. The decisions moved up a level:
- What has to stay the same
- What is allowed to vary
- Whether the picture shows the behavior it claims to
- When to stop
The judgment did not change. The distance between thinking and making did.
How I checked it before shipping it
Three things, all cheap.
Acceptance criteria that can be falsified
Every line is observably true or false on a real phone, and someone who has never seen the code reaches the same verdict I would. Written that way they are testable rather than admirable.
A blind pilot, before a real panel
The illustrations came out of a model, so the second check is an evaluation. Does the output show what it claims to, judged by someone who was not in the room when it was made?
- Four language models, no knowledge of the project, saw the same eight images in different orders
- One question each: what is the person doing, and what is the dog doing
- Two pictures failed outright, all four viewers misreading them the same way
- Both were redrawn and passed the same blind check with fresh viewers before shipping
Not a substitute for testing with people. A way to catch the obvious failures for free, before the expensive test is worth running. The shape of it transfers to anything generated:
- Fix the question the output has to answer before any output exists
- Put it to viewers with no stake and no context
- Shuffle the order, so position cannot flatter a picture, and hide the filenames, so the name cannot answer the question on the image’s behalf
- Read agreement among wrong readings as the strongest signal in the set. Four viewers failing the same way is a fault in the artifact, not four opinions
What makes it an evaluation rather than a reaction is that the pass mark was written down first.
Before · 0 of 4
After · redrawn
Use, which is the check that takes features out
Anything I am not sure of ships switched off. It gets turned on in the hallway and either earns its place or goes.
Voice commands went after a couple of real sessions, for a reason no diagram could have shown and no AI raised. The timer that replaced them had every label rewritten after one session. A feature that survives that is one I have watched work, not one I expect to.