One of the hardest trust problems in an AI agent isn’t getting the action right. It’s convincing the user it’s going to get the action right before it runs.
StareBrain operates on a user’s phone — real apps, real data, actions that can’t always be undone. Booking a cab, sending a message, setting a reminder. The agent needs to earn the user’s confidence on every action, not just the first time.
The way we approached this: every action goes through a dry-run pass before it executes. The agent builds a full action plan, estimates the side effects, and presents a human-readable summary for confirmation. Only after the user approves does the real execution begin.
Here’s how that works in code.
The action plan
Before any execution, the planner produces an ActionPlan — a complete description of what the agent intends to do:
`data class ActionPlan(
val actionId: String,
val intent: String,
val steps: List<PlannedStep>,
val estimatedSideEffects: List<SideEffect>,
val requiresConfirmation: Boolean
)
data class PlannedStep(
val stepId: String,
val appTarget: String,
val operation: String,
val parameters: Map<String, String>,
val isReversible: Boolean
)
sealed class SideEffect {
data class DataWrite(val app: String, val description: String) : SideEffect()
data class NetworkRequest(val destination: String, val description: String) : SideEffect()
data class Notification(val description: String) : SideEffect()
object NoSideEffect : SideEffect()
}`
The plan is built entirely from the intent and the resolved device context — no app is opened, no tap is made. It’s a description of what would happen, not what is happening.
Dry-run execution
The dry-run pass walks every step in the plan and validates it without executing:
`class DryRunExecutor(
private val appRegistry: InstalledAppRegistry,
private val permissionChecker: PermissionChecker
) {
fun validate(plan: ActionPlan): DryRunResult {
val stepResults = plan.steps.map { step ->
validateStep(step)
}
return DryRunResult(
planId = plan.actionId,
stepResults = stepResults,
isExecutable = stepResults.all { it.canExecute },
blockers = stepResults.flatMap { it.blockers }
)
}
private fun validateStep(step: PlannedStep): StepValidation {
val appInstalled = appRegistry.isInstalled(step.appTarget)
val hasPermission = permissionChecker.hasRequired(step.operation)
val blockers = buildList {
if (!appInstalled) add(Blocker.AppNotInstalled(step.appTarget))
if (!hasPermission) add(Blocker.PermissionMissing(step.operation))
}
return StepValidation(
stepId = step.stepId,
canExecute = blockers.isEmpty(),
blockers = blockers
)
}
}
data class DryRunResult(
val planId: String,
val stepResults: List<StepValidation>,
val isExecutable: Boolean,
val blockers: List<Blocker>
)
sealed class Blocker {
data class AppNotInstalled(val appName: String) : Blocker()
data class PermissionMissing(val permission: String) : Blocker()
data class ParameterUnresolved(val paramName: String) : Blocker()
}`
If the dry run finds blockers, the agent surfaces them before asking for confirmation — not after attempting execution and failing.
The confirmation surface
The dry-run result feeds directly into the confirmation UI. The user sees a plain-language summary of what’s about to happen:
`fun ActionPlan.toConfirmationSummary(): ConfirmationSummary {
return ConfirmationSummary(
title = intent,
steps = steps.map { step ->
"${step.appTarget}: ${step.operation} " +
step.parameters.entries.joinToString(", ") { "${it.key} = ${it.value}" }
},
sideEffects = estimatedSideEffects
.filterNot { it is SideEffect.NoSideEffect }
.map { it.toHumanReadable() },
hasIrreversibleSteps = steps.any { !it.isReversible }
)
}
data class ConfirmationSummary(
val title: String,
val steps: List<String>,
val sideEffects: List<String>,
val hasIrreversibleSteps: Boolean
)`
If any step is irreversible, the confirmation dialog uses stronger language and requires an explicit tap — not just a dismiss. A booking can’t be unbooked automatically. A sent message can’t be unsent. The user should know that before they confirm.
Why dry-run before confirm, not confirm before dry-run
The tempting shortcut is to ask for confirmation first, then validate. It’s simpler to implement.
The problem: if validation fails after confirmation, you’ve already set an expectation you can’t fulfill. The user said yes, and then the agent came back with “actually, the app isn’t installed.” That’s a worse experience than not asking yet.
Dry-run first means the confirmation is always about a plan the agent knows it can execute. The “yes” is to something real, not to a plan that might fail on step two.
The gap this closes
On-device agents earn trust by being predictable. The user doesn’t need the agent to be perfect — they need to know what it’s about to do before it does it, and to have a moment to say no.
Dry-run mode is how we make that moment meaningful. Not a generic “the agent will now perform actions on your behalf” disclaimer, but a specific, checkable list of exactly what’s going to happen.
If the user can read the plan and say “yes, that’s what I meant” — that’s the confirmation worth having.
Top comments (0)