My wife had the idea. "An app that briefly stops you before you scroll through Instagram for another 45 minutes." Simple. Elegant. Obviously doable.
That was a few months ago. What followed was one of the most frustrating side projects of my career – and simultaneously one of the most educational.
DoomStop was also a deliberate experiment: I wanted to know how far you can get with pure vibe-coding. The approach of developing almost exclusively through AI prompts – you describe, the AI builds, you check the logic. "Build a complete app with a single prompt" – you read that everywhere. I wanted to test it. On a real app. With real problems.
Spoiler: that claim is about as accurate as "you can fix anything with a hammer."
What DoomStop does
When you open a configured app, a timer starts. When it runs out, a shield appears – a blocking screen showing how many times you've opened the app today and how much time you've already sunk into it. Then: two buttons. "Continue" starts a shorter follow-up timer. "I'm done" sends you out.
No punishment. No blocking. Just a quiet mirror: you know what you're doing.
The Flutter illusion
My first plan was Flutter. Cross-platform, one codebase – smart, right?
The problem: Apple's FamilyControls API works exclusively in native Swift extensions. No Flutter, no React Native, nothing. So I built a Flutter app that called a Swift extension that called another Swift extension that communicated with yet another Swift extension. Flutter was essentially translating button presses into database entries. The actual app? Completely in Swift.
After a few weeks I threw Flutter out. That was the best decision of the project – and simultaneously the most annoying one, because it was so obvious.
The entitlement maze
Anyone wanting to use Apple's FamilyControls needs a distribution entitlement. No entitlement, no shield, no monitoring – so no app. You apply, wait, and eventually get it.
Eventually took nearly a month in my case.
Then it turned out: each of the four extensions needs its own entitlement. Four more applications. This time "only" a few days.
What nobody had told me – Claude included, even though I asked explicitly: the development entitlement and the distribution entitlement are two completely different things. With the development entitlement you can select apps. That's it. Setting a shield, starting monitoring – that only works with distribution. Which meant: every test ran through TestFlight. Archive, upload, wait, install, test, find bug, repeat.
I ran that loop probably 40 times.
The API has opinions. Strong opinions.
This gets technical – but I promise it's worth it.
One thing upfront: Claude doesn't research documentation on its own. I couldn't just ask "what can I do with this API?" and expect a reliable answer. I had to dig through the Apple docs myself, find the relevant section, and present it to Claude – only then did real understanding of the problem emerge. With an API as sparsely documented as FamilyControls, that massively slowed down every debugging loop.
First finding: token.localizedDisplayName is always nil in the main app. Apple doesn't let the developer know which app name the monitored token has. In the shield, Apple renders the name itself via an opaque label token – the user sees "Instagram", I see nothing. In my app's dashboard: nil. That's not a bug, that's privacy. I still fought against it for too long.
Second finding: DeviceActivityCenter.shared is not accessible from the ShieldActionExtension. The extension that reacts to user input in the shield can't communicate directly with the monitoring system. Instead: Darwin notifications, UserDefaults signals, a monitor as intermediary. Three components for "when user taps OK, keep the timer running."
Third finding: .defer opens the app – but always shows, unavoidably, a native white iOS screen first. Right in the middle of my dark blocking screen: white flash. For a while the solution was .close: close the shield, user has to tap again, the extra tap as deliberate friction. Almost sounded like a feature.
The real solution came later: keep .defer, but pre-warn the monitor extension to set the next shield before .defer resolves. No gap, no native overlay. Three components, one Darwin notification, one UserDefaults signal – for what sounds like: "open the app."
The app that accused its user from the start
Eventually the core functionality was stable. Then came the bugs. And they had character.
DoomStop counts how many times you've opened an app. Except the counter was counting pretty much everything – except actually opening the app.
Setting up a new app: counter jumps to 1 before you've ever touched the app. Adjusting a timer and hitting save: counter +1. The more diligently you configured DoomStop, the guiltier you looked on your own dashboard. And every morning at midnight, a logic error during the day change automatically created a UsageSession – so the first thing you'd see in the morning was: Instagram, unlocked 1 time. In your sleep.
My personal favorite was a different bug: shield appears. You click "I'm done", land on the home screen. Open the app again. Shield appears again. Click again. This time: app opens without a shield. And after that: no more shield for this app. Permanently.
You could click away your own safety net with two taps. For a self-control app, that's particularly funny – the user who struggles the most was protected the least.
Tests: the lesson I learned too late
At some point the pattern was undeniable: we fix one bug, create two new ones. Claude fixed extension A, extension B started misbehaving. Every fix was a roulette.
The reason was structural – the code wasn't testable. So we rewrote everything cleanly: dependency injection, clear responsibilities, a proper unit test suite. The refactoring produced not a single new bug. Not one. And since then the bug rate has dropped noticeably.
The insight isn't new: introduce tests early, not when the system is already on fire. I should have done it right after the first stable prototype. I didn't. I paid for it.
3am – and the AI sleep advisor
The follow-up timer wasn't appearing consistently. Sometimes immediately, sometimes not at all, sometimes only after restarting the app. We'd been circling the problem for hours. And then – I could feel it – we were close.
And then this appeared on my screen:
"I would recommend taking a break for today. Fresh eyes sometimes help. Sleep helps with problem-solving."
It was 3am. I was right before the breakthrough. My AI pair programmer recommended I go to bed.
Claude even followed up: "I understand, but after an intense debugging session, sleep can work wonders..."
I kept going.
What we found: three simultaneous root causes. intervalDidStart was actively deleting the shield when it shouldn't have. Wrong completionHandler in the wrong extension. And the most absurd part: the time I'd spent in the DoomStop app itself was being deducted from the follow-up budget of the monitored app. My own app was penalizing me for using my own app.
We solved the problem that same night.
What remains
DoomStop isn't published yet. It runs as an internal beta, and it works – the shield appears, the timers run, the data is correct.
What I took away: vibe-coding works well for a lot of things. For boilerplate, for UI logic, for fast iteration. For an iOS app that reaches deep into Apple's screen time API? You need to understand what you're building yourself. The AI writes faster than I do – but it only knows what I show it. For undocumented API behavior, for caching quirks, for five extensions interacting across different processes: you have to do the research yourself.
"Build a complete app with one prompt" – not accurate. What is accurate: with a good workflow and thorough context engineering, you can build an app that would have taken significantly longer without AI assistance. That's still impressive enough.
And the AI sleep advisor was wrong, by the way. Just to be clear.
