Before You Let Siri Run an Agent-Written App Intent — Classify by Side Effect and Gate the Destructive Ones
Letting Siri or an assistant run an Antigravity-generated App Intent without a gate means a destructive action can fire from a single voice command. Here is how I classify intents by side effect, gate the irreversible ones, and catch missing gates before push.
On July 1st Apple announced a new intelligence framework and Xcode 27, widening how far you can connect an app's actions to Siri's assistant features through App Intents. That same week I was reading Swift that Antigravity had generated after I asked it to "add a shortcut that swaps my wallpaper collection for today's picks," and I felt a small chill. The generated PerformIntent replaced the user's entire saved collection with no confirmation whatsoever.
When I invoke it by hand, that's fine. The trouble is that this intent can now fire from Siri or an assistant, by voice, while I'm not looking at the screen. Agent-written code compiles and runs. And precisely because it runs, it can quietly become "an operation you can trigger by voice" while remaining oblivious to the weight of its own side effect. Here is how I close that gap with side-effect classification and a confirmation gate.
An intent you tap yourself is not the same as one an assistant fires
App Intents (iOS 16 and later) become callable from Siri, Shortcuts, Spotlight, and the new assistant features the moment your type conforms to a Swift protocol. The lower the implementation bar, the easier it is to overlook one question: who invokes this, and in what context?
When you tap an intent in the Shortcuts app, you know what is about to happen. Under voice or assistant invocation, parameter resolution and execution both complete before a human sees the result. By the time perform() returns .result(), the operation is done. There is no window to undo it.
I run wallpaper and relaxation apps as an indie developer, and a user's "saved collection" is the most delicate data in the app. An intent that overwrites it without confirmation was something I could not ship as-is, even though the agent had written perfectly compilable code.
The problem is not that Antigravity wrote bad code. It is that the context "this can be triggered by voice" was never in the generation prompt. So rather than blaming the generator, the receiving side needs a gate applied mechanically.
First, classify by side effect
Putting a confirmation dialog on every intent ruins the experience. Being asked to confirm every time you say "what's today's wallpaper?" is unusable. The axis is not the feature name; it is the reversibility of the side effect.
Class
Definition
Example
Confirm
Assistant invocation
Read-only
Changes no state; idempotent
Show today's pick, favorite count
No
Run directly
Reversible
Changes state, but an undo exists
Add one favorite, switch theme
Usually no (offer Undo)
Run directly
Irreversible
Cannot be undone, or affects a wide scope
Bulk collection swap, delete all, purchase
Required
Only after confirm / hand to app
Expressing this in the type system makes the later static check easy. Make each intent declare its own side effect.
enum SideEffect { case readOnly // changes no state case reversible // undoable case irreversible // irreversible / wide-reaching}/// Force every App Intent to declare thisprotocol ClassifiedIntent: AppIntent { static var sideEffect: SideEffect { get }}
Flagging intents that do not conform to ClassifiedIntent (with the script below) prevents an unclassified intent from shipping unnoticed. Classification is also a device that forces design-time thought. When you have an agent write the intent, include this conformance in the requirements and the output naturally becomes side-effect aware.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A decision table that classifies every App Intent as read-only, reversible, or irreversible and mechanically decides whether a confirmation is required
✦An implementation pattern combining requestConfirmation and openAppWhenRun so destructive actions never run unconfirmed under voice or assistant invocation
✦A zero-dependency static check that flags agent-generated intents missing a confirmation gate before they ever reach push
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Irreversible intents must go through requestConfirmation
For intents in the irreversible class, call requestConfirmation at the top of perform() and get explicit consent before the real work. Under voice invocation, Siri reads it aloud and takes the confirmation there.
struct ReplaceCollectionIntent: ClassifiedIntent { static let title: LocalizedStringResource = "Swap collection for today's picks" static let sideEffect: SideEffect = .irreversible // Do not let the assistant complete this silently; confirmation is mandatory static let openAppWhenRun: Bool = false @Parameter(title: "Target collection") var collection: CollectionEntity @MainActor func perform() async throws -> some IntentResult & ProvidesDialog { let store = CollectionStore.shared let current = try store.count(in: collection.id) // Take explicit confirmation, with the count, before the destructive act try await requestConfirmation( result: .result(dialog: "This replaces your current \(current) items with today's picks. It cannot be undone."), confirmationActionName: .go ) let snapshot = try store.snapshot(collection.id) // stash for restore do { try await store.replaceWithDailyPicks(collection.id) } catch { try? store.restore(snapshot) // always roll back on failure throw error } return .result(dialog: "Done. You can restore the previous state from history.") }}
Three things matter. First, put a concrete count in the confirmation. "Replace them?" conveys no weight; "your current 42 items" is the first time the user grasps the scale. Second, take a snapshot before requestConfirmation and always restore on failure — the more irreversible the operation, the more you must avoid leaving a half-finished state. Third, by setting openAppWhenRun explicitly to false and then confirming, you avoid both "silent completion" and "opens the app every time."
Antigravity's first draft had neither the requestConfirmation nor the snapshot. When I review generated code, I ask a single question first — "can this operation be undone?" — and if not, I check that confirmation and a stash are in place.
When ambiguity remains, hand off with openAppWhenRun
Some operations cannot be fully conveyed in a confirmation dialog, or are safer to let the user pick on screen — for example "select several and delete," where voice alone struggles to pin down the targets. Do not complete these inside the intent; open the app and defer to human eyes.
struct BulkDeleteIntent: ClassifiedIntent { static let title: LocalizedStringResource = "Select and bulk delete" static let sideEffect: SideEffect = .irreversible // A destructive op with ambiguous targets is not completed in-assistant static let openAppWhenRun: Bool = true @MainActor func perform() async throws -> some IntentResult { // Delete nothing here. Open the manager with candidates narrowed AppRouter.shared.route(to: .collectionManager(mode: .deletionReview)) return .result() }}
An openAppWhenRun = true intent performs no destructive work itself; it prepares the context right up to the edge of the dangerous action and hands it to the user. The convenience of voice and the caution of an irreversible action coexist through this division of roles.
As a rule of thumb, I split it like this:
Situation
Choice
Target is unique and the scale fits in a confirmation line
Complete in-intent with requestConfirmation
Multiple/ambiguous targets, visual selection is safer
Hand off with openAppWhenRun
Read-only / reversible
Neither; run directly
Do not trust the parameters an agent fills in
Under voice or assistant invocation, the value that lands in @Parameter is the result of interpreting natural language. Unlike a value the user picked by hand, it carries ambiguity and mistaken matches. AppEntity resolution in particular must be built assuming the agent hands you a "plausible-looking candidate."
struct CollectionEntity: AppEntity { let id: String let name: String static let typeDisplayRepresentation: TypeDisplayRepresentation = "Collection" var displayRepresentation: DisplayRepresentation { .init(title: "\(name)") } static let defaultQuery = CollectionQuery()}struct CollectionQuery: EntityQuery { func entities(for ids: [String]) async throws -> [CollectionEntity] { // Do not take the IDs at face value; keep only ones that exist try CollectionStore.shared.existing(ids: ids) } func suggestedEntities() async throws -> [CollectionEntity] { // Never surface system collections as candidates for destructive ops try CollectionStore.shared.userEditableCollections() }}
Adding an existence check in entities(for:) and excluding must-not-touch targets in suggestedEntities() — those two alone remove the seed of accidents like "the assistant picked the system-default collection to delete." Agent-generated EntityQuery often skips the existing filter and returns everything, so I look here closely.
For destructive parameters that carry a quantity, set an upper bound too.
@Parameter(title: "Number to delete")var count: Intfunc validate() throws { guard count > 0 else { throw $count.needsValueError("Specify 1 or more") } guard count <= 200 else { // Even if the assistant misreads a digit, do not allow a mass delete in one shot throw $count.needsValueError("You can delete at most 200 at once") }}
Catch missing gates before push with a static check
Even with the design decided, intents accumulate. The more you delegate additions to an agent, the higher the chance an "irreversible-classed intent that forgot requestConfirmation" slips in. Rather than relying on human review alone, add a layer that rejects it mechanically.
Below is a zero-dependency Node script. It scans Swift sources and fails any intent whose sideEffect is .irreversible but whose body has neither requestConfirmation nor openAppWhenRun = true.
// scripts/check-intent-gates.mjsimport { readFileSync, readdirSync } from "node:fs";import { join } from "node:path";function swiftFiles(dir) { return readdirSync(dir, { withFileTypes: true }).flatMap((e) => { const p = join(dir, e.name); if (e.isDirectory()) return swiftFiles(p); return e.name.endsWith(".swift") ? [p] : []; });}// Crude struct split. Not a real parser, but enough to detect a missing gatefunction structs(src) { const out = []; const re = /struct\s+(\w+)\s*:\s*[^{]*ClassifiedIntent[^{]*\{/g; let m; while ((m = re.exec(src))) { let depth = 1, i = re.lastIndex; for (; i < src.length && depth > 0; i++) { if (src[i] === "{") depth++; else if (src[i] === "}") depth--; } out.push({ name: m[1], body: src.slice(m.index, i) }); } return out;}const violations = [];for (const file of swiftFiles("Sources")) { const src = readFileSync(file, "utf8"); for (const s of structs(src)) { const irreversible = /sideEffect\s*:\s*SideEffect\s*=\s*\.irreversible/.test(s.body); if (!irreversible) continue; const gated = /requestConfirmation\s*\(/.test(s.body) || /openAppWhenRun\s*(:\s*Bool)?\s*=\s*true/.test(s.body); if (!gated) { violations.push(`${file}: ${s.name} is irreversible but has no confirmation gate`); } }}if (violations.length) { console.error("❌ Missing confirmation gate:\n" + violations.join("\n")); process.exit(1);}console.log("✅ Every irreversible intent has a confirmation gate");
Put this in a CI step and you close the path where an agent-added intent merges without a gate. It is not a strict Swift parser, so misses are possible, but it reliably catches the most dangerous pattern: "the irreversible declaration is there, yet no confirmation." In my setup this script runs as a pre-build step for unattended runs, and exit 1 halts the whole run.
What six weeks of running it taught me
I put this gate design into my own apps and ran it for about six weeks. Of the 9 intents I let the agent add, 2 were irreversible-classed yet missing confirmation. Both were stopped by the static check before push, and I shipped them only after adding requestConfirmation and snapshot. Left to human review alone, I probably would have missed one of them.
An unexpected by-product: the habit of classifying intents by side effect made me ask, at design time, whether an operation should be voice-triggerable at all. Instead of exposing everything to the assistant out of convenience, I now let reversible operations be casual and force a human beat before irreversible ones. As AI increasingly operates apps autonomously, that line feels like the last rein a maker should keep hold of.
For a next step, pick one App Intent you already have and add just the sideEffect classification. The moment you try to write it down, you will likely find an intent that makes you think, "this one needs a confirmation."
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.