Building an Android app that turns natural language into device actions (texts, calendar events, calls, etc.) — the core UX challenge isn't the LLM parsing, it's the confirmation step.
Show too little detail and users don't trust it enough to hit confirm. Show too much and you've just rebuilt the five-tap flow you were trying to kill.
Right now I'm testing: one line of plain English ("Text Sarah: running late") vs. a structured breakdown (recipient / message / action). Plain English wins on speed, but breaks down for anything with more than one moving part — like chaining a reschedule + notify + calendar update into a single confirmable plan.
Curious if anyone else building agent-style UIs has landed on a pattern that scales from "one action" to "multi-step plan" without the confirmation screen becoming its own five-tap flow.
I’ve been wrestling with this exact problem lately. Theoretically agents ought to seek user approval for every high‑stakes or state‑modifying operation. But constant prompts get disruptive. No matter how refined your UX is, it can devolve into mere "performative approval".
"Performative approval" is a great name for it, and I got pushed on the same failure mode on a different thread just now — someone running managed services said every added approval step trains people to click through without reading, so the safety mechanism becomes the reason they get burned.
The reframe I landed on there: confirmation shouldn't be the default for high-stakes actions, undo should be. Confirmation is what you fall back to only when undo genuinely isn't possible — and "genuinely isn't possible" turns out to be a smaller set than it feels like at design time, because a lot of "high-stakes" actions are only irreversible in a soft sense (a sent draft you could still retract before read, a setting change you could revert). Real full-screen interruption should probably be reserved for the actual hard cases: something another person has already seen or received, where there's no reaching back into their state once it's happened.
That doesn't solve performative approval entirely, since some things really can't have an undo. But it shrinks the number of times you're asking, which is most of the fatigue problem — people don't tune out one rare, genuinely serious prompt, they tune out the fifth confirmation this hour for something that turned out fine four times already.
Ran into a version of this building a Notion-based tracker with
filtered views — getting the "which condition should require
confirmation vs. just happen automatically" balance right took more
iteration than the actual build. Did you end up with a rule of thumb,
or is it still case-by-case for you?
2. “Confirmation-before-action is harder to design than I expected.”
This is exactly where agent safety gets complicated.
The interesting part isn’t only whether the confirmation rule exists — it’s whether the agent actually stops when it should, escalates correctly when uncertain, and does not execute through another path after a denial.
For consequential actions, I think confirmed non-execution becomes just as important as the approval event itself.
"Confirmed non-execution" is a category I hadn't been tracking at all, and it's a real gap — I've been treating denial as the end state, not as something that itself needs verification. That's uncomfortably close to a bug I actually hit: a stale result from an earlier attempt rendered as "executed" right after the current attempt correctly failed and denied. The denial worked. It just didn't stop a different, already-in-flight execution from completing anyway and reporting success on top of it.
So "did it stop" and "does the log agree it stopped" turned out to be two different questions in practice, not one. What would confirmed non-execution actually look like as a design pattern — is it something like a receipt for the negative case (an explicit "action X was blocked, verified not-run" record), or is it more about the execution layer itself refusing to complete once a denial fires, regardless of what's already in flight?
Both—but the execution control has to come first, and the receipt must be backed by independent state evidence.
I’d model confirmed non-execution as three separate checks:
The negative receipt should therefore say more than “denied.” It should identify the action and attempt IDs, the policy decision, any related in-flight executions, their cancellation or fencing status, and the observed downstream state.
Your example shows exactly why this matters: the current attempt was correctly denied, but an earlier attempt retained authority to complete. The denial log was truthful about the decision but misleading about the resulting system state.
If the system cannot prove that related executions were stopped and no side effect occurred, the verdict should not be “blocked.” It should be insufficient evidence or execution state unresolved. A denial is a control-plane event; confirmed non-execution is an independently verified outcome.
"The denial log was truthful about the decision but misleading about the resulting system state" — that's the exact bug, better stated than I managed to state it myself. My fix at the time (tag every attempt with an ID, drop stale results that don't match the current one) covers your #1 and #2 by accident, but I hadn't separated them as distinct guarantees, and I don't think I actually have #3 at all. I check that the new attempt doesn't fire something wrong. I don't independently verify the target system to confirm nothing landed from the old one — I'm trusting that dropping the stale result was sufficient, which is exactly the unverified assumption you're pointing at.
The "insufficient evidence" verdict is the piece I'm most likely to steal directly. Right now my system only has two states worth logging, blocked or executed, and your model makes clear there's a third: technically denied, but not provably clean. For something like a sent text, post-condition verification is hard, I can't query the recipient's phone to confirm nothing arrived, so that third state might end up being permanent for certain action types rather than something I ever fully close.
Genuinely useful framework, thank you — going to go check whether my current system can even produce evidence for #2 and #3 separately, or if it's silently treating "I didn't see it happen" as "it didn't happen."
That distinction about the recipient’s phone is important because confirmed non-execution has to be bounded to what the system can actually observe.
For a text message, you may never be able to prove “nothing appeared on the device.” But you may be able to prove narrower facts:
That supports a verdict like “confirmed not submitted to provider,” which is stronger and more honest than claiming “not delivered.”
If an earlier request was accepted but its final state cannot be established, then “denied, execution unresolved” is probably the correct permanent result. The control decision succeeded, but the system cannot prove the external outcome.
Your attempt IDs solve an important identity problem: they prevent one attempt’s result from being mistaken for another’s. But dropping the stale result only fixes what the interface believes. It does not revoke the older attempt’s authority or establish what the external system did with it.
So I think the state model becomes something like:
And yes—“I didn’t observe execution” is absence of evidence. It only becomes evidence of non-execution when the observation boundary itself is defined and complete.
"Your attempt IDs solve an important identity problem... but dropping the stale result only fixes what the interface believes, it does not revoke the older attempt's authority" — that's the gap, precisely, and it's worse than I'd registered. My fix makes the UI honest. It does nothing to stop the actual old execution from completing somewhere downstream I'm not watching. I'd been treating "the display is correct now" as equivalent to "the problem is fixed," and it's really just equivalent to "I stopped lying to myself about it."
The three-state model is the thing I'm taking wholesale: EXECUTED, DENIED_CONFIRMED, DENIED_UNRESOLVED. Especially DENIED_UNRESOLVED as a real, nameable, permanent state rather than something my system currently has no slot for at all — right now anything that isn't a clean EXECUTED just silently becomes "blocked," which quietly launders unresolved into confirmed.
The "confirmed not submitted to provider" vs "not delivered" distinction is the one that keeps this honest instead of turning into overclaiming dressed up as rigor — I could see a version of this model where someone builds DENIED_CONFIRMED, gets excited it exists, and starts implying it means "definitely didn't happen" when it only ever meant "didn't leave through this boundary." Worth being disciplined about which sentence the state actually licenses you to say to a user.
This is a really useful distinction. I think you’ve identified the part I was missing: attempt IDs solve identity, but they don’t necessarily solve authority. An old attempt can still have the ability to execute even after the UI has correctly moved on.
I also really like the three-state model, especially
DENIED_UNRESOLVED. That prevents “we didn’t observe it completing” from quietly becoming “we know it didn’t happen.”The boundary qualification on
DENIED_CONFIRMEDis important too. “Confirmed not submitted to provider” is a very different claim from “confirmed not delivered.” I think the system should make that epistemic boundary explicit rather than letting a clean status accidentally imply more certainty than the evidence supports.That gives me a much clearer picture of what the actual fix needs to address: not just making stale attempts identifiable, but making their authority independently revocable or fenceable.
"Not just making stale attempts identifiable, but making their authority independently revocable or fenceable" — that's the actual scope of the fix, precisely, and noticeably bigger than the one I shipped. Appreciate you working through this with me instead of leaving it at the framework level; going to sit with how revocation actually works for something already dispatched to a provider I don't control, since that's the part I don't have an answer for yet.
This is actually the problem OpsWatch is being built to solve. The key isn’t “did we identify the stale attempt?” — it’s “did that attempt still have authority to cause a side effect?” Once a provider accepts it, you need guarantees around fencing, acceptance and downstream state rather than pretending cancellation happened.
You’ve already hit the limitation firsthand. Want to see the approach I’m taking?
I’ve had better luck splitting the confirmation by risk, not by number of steps. Low-risk steps can stay as one compact plan, but anything that sends a message, changes a calendar, or spends money gets its own explicit line. Otherwise the screen is fast, but people still do not trust it enough to confirm.
This lines up with something I got pushed on in another thread yesterday — someone made the case that the real split isn't risk level, it's recoverability. Sending a message and spending money both land in your "gets its own line" bucket, but for slightly different reasons: one's irreversible because someone else already saw it, the other's irreversible because the money's gone. Not sure yet if that's a meaningful distinction or just two words for the same thing in practice.
Either way, "risk" or "recoverable," the actual design conclusion seems to be the same: collapse the low-stakes steps into one compact plan, and give anything that can't be undone its own explicit line instead of burying it in a bullet list. Going to try that split next — curious whether users actually read the standalone lines more carefully, or just get confirmation-fatigue faster because now there are two different visual patterns to parse instead of one.