Most AI features don't fail because the model is bad.
They fail because everything around the model is treated like a demo.
This week I was tightening an iOS workout app that uses Claude through Supabase Edge Functions. The model part is straightforward: send training context, get a structured exercise or plan back, validate it, write it into SwiftData.
The less glamorous work was the part that makes it feel like an actual product:
- monthly AI credit balance
- offline handling
- auth token storage
- disabled states while generation is running
- different rules for “add exercise” vs “swap this exercise”
- tests for the boring edge cases
That is where most AI app quality lives.
One button, several states
The UI has a small “fill with AI” affordance on the exercise editor. Underneath it, the button is not just “call endpoint”. It has to know whether suggestion is currently allowed:
var canSuggest: Bool {
guard !isLoadingAI,
NetworkMonitor.shared.isOnline,
(creditsRemaining ?? 1) > 0
else { return false }
// Swap mode can use the original exercise as context.
// Add mode needs the typed name as a hint.
return isSwapMode || !name.trimmingCharacters(in: .whitespaces).isEmpty
}
That little predicate is doing a lot of product work.
If the user is offline, don't let them tap into a doomed network request.
If a generation is already running, don't double-spend.
If they have zero credits, don't pretend the feature is available.
If they are swapping an existing exercise, don't force them to type a name because the old exercise is already useful context.
The model does not care about any of this. The user does.
Credits should be part of the response
The suggestion response includes the updated credit count:
struct SuggestedExercise: Decodable {
let name: String
let briefDescription: String
let muscleGroup: String
let sets: Int
let repTargetLow: Int
let repTargetHigh: Int
let restSeconds: Int
let isDualDumbbell: Bool
let creditsRemaining: Int
}
Then the view model updates local state immediately after a successful fill:
creditsRemaining = result.creditsRemaining
aiFilledFields = true
That avoids the classic AI-product weirdness where the backend knows the user has spent a credit but the UI keeps showing stale allowance until the next refresh.
It is also easier to test. In the app tests, the credit transition is explicit:
func testAIDisabledWhenCreditsReachZero() {
let vm = ExerciseEditViewModel(mode: .add(makeDay()), context: container.mainContext)
vm.name = "Row"
vm.creditsRemaining = 1
XCTAssertTrue(vm.canSuggest)
vm.creditsRemaining = 0
XCTAssertFalse(vm.canSuggest)
}
There are 13 tests just around the exercise edit view model, plus separate coverage for offline error mapping. Not because this is academically interesting, but because this is the stuff that breaks in front of real users.
Offline is not a server error
Another small detail: connectivity failures are mapped separately from backend failures.
static let connectivityCodes: Set<URLError.Code> = [
.notConnectedToInternet,
.networkConnectionLost,
.timedOut,
.cannotConnectToHost,
.dataNotAllowed,
]
The resulting message is intentionally plain:
"You're offline. Reconnect to use AI features."
No “unexpected server response”. No fake intelligence. Just tell the user what happened.
The actual lesson
Shipping AI features is mostly normal software engineering with a probabilistic dependency in the middle.
The model call matters, but the surrounding contract matters more:
- Can the user invoke it right now?
- What happens if the network dies?
- Is usage counted consistently?
- Does the UI reflect server state immediately?
- Are the edge cases testable without calling the model?
Once those pieces are in place, the AI feature stops feeling like a prompt wired to a button and starts feeling like part of the app.
That is the bar I keep coming back to: not “does the model answer?” but “does this survive normal product reality?”
Top comments (0)