Declare OS-level app actions in Dart. The generator emits the iOS App Intents and Android AppFunctions code the system needs at compile time, so Siri, Spotlight, Shortcuts and on-device agents can run your app's actions — ideally without opening the app at all.
The pitch in one line: you never open Xcode.
Not a mock-up. That is the example app's addTask handler, written in Dart,
invoked by iOS from the Shortcuts app. The prompt is the requestValueDialog
from the annotation; the answer is the handler's return value. iOS launched the
app's own process in the background to run it and never brought it to the
foreground — measured, not assumed:
Runner[91295] OSINTENTS_HOST intent=addTask process=Runner uiEngine=yes
Status: 0.3.x, stabilising toward 1.0. iOS, macOS and Android work end to end, and each ✅ in the table below says how it was verified — most ran on a device under the two harnesses in
probe/, macOS builds from a blankflutter createviarun_cold_start.sh. docs/verified.md keeps the ledger: what ran on a device, what only compiles, what has never been observed (the largest remaining gap is Siri invoking a phrase by voice), and no production app has shipped on this yet — if yours is the first, you get hands-on integration help. The API may still move before 1.0; ROADMAP.md says where. Open a discussion and you will get an answer.
Contents · What works · Pipeline · The idea · Parameters · Failures · Testing · Localisation · macOS · Donations & shortcuts · Why another one · Layout · Why the Xcode step
| iOS | Android | |
|---|---|---|
| Invoked by the OS, app stays closed | ✅ observed from Shortcuts | ✅ AppFunctions (headless) |
| App shortcuts / launcher entries | ✅ Spotlight, Shortcuts | ✅ generated shortcuts.xml |
| Spoken triggers | ✅ Siri phrases | ✅ Assistant capabilities (built-in intents) |
@AppEntity + @EntityQuery resolution |
✅ verified on device | — |
Execution.background — runs with no UI |
✅ verified on device | ✅ verified on emulator |
Execution.static_ — answers with no engine |
✅ verified on device | ✅ verified on emulator |
| Snippet cards | ✅ compiles and round-trips | — |
| Buttons on a snippet card | ✅ iOS 17+, verified through the store | — |
| Suggested by the system (donation) | ✅ verified on device | — deliberately, see below |
Phrases naming your data ('Open $project in $app') |
✅ refresh chain verified on device | — |
Ask-then-continue (needsConfirmation) |
✅ iOS 26; round trip verified on device | fallback spoken, action unperformed |
| Live cards — redraw after a button, or on demand | ✅ iOS 26 SnippetIntent, opt-in per intent |
— |
| Launcher entries pushed at runtime | — deliberately, see below | ✅ verified on emulator |
| macOS | ✅ macOS 13+, built from a blank flutter create |
— |
Floors: iOS 16, minSdk 24 on Android — Flutter's own default since 3.35, so
only an app created by an older Flutter has the one-line change to make — and
Flutter 3.32+ for the runtime (3.35+ to run the generator). CI builds on both
of those Flutters, not just the newest.
Android is two layers. App shortcuts and capabilities cost nothing and are
generated by default; AppFunctions — the headless one — is opt-in behind
sync --android because it forces compileSdk 37, AGP 9.1.1 and Gradle 9.3.1
on your app for something only Android 16+ can run. Details in
docs/android.md.
dart run os_intents_cli:os_intents buildOne command over three steps — build_runner for the registry and manifest,
sync to carry that manifest into ios/ and android/, install to register
what it wrote with the Xcode target and the Android manifest. All three are
idempotent and can be run on their own.
Verified on the example app: four intents, an entity with its query and an enum
all reach the built bundle, and the phrases resolve to the provider the OS
selects. That is os_intents doctor reading the built .app, not the sources:
Intents the OS will see (4)
AddTaskOsIntent "Add task"
runs without opening the app
title String required
dueDate Date optional
project ProjectEntity optional
priority linkEnumeration optional
CompleteTaskOsIntent, CountOpenTasksOsIntent, DueTodayOsIntent
Entities (1)
ProjectEntity "Project" resolved by Runner.ProjectQuery
Spoken phrases (3 via Runner.OsIntentsShortcuts)
root.ssu.yaml present — Siri has the phrase model
sync --check and install --check fail on drift, for CI — the first that the
generated files match the manifest, the second that the native projects
reference them. os_intents doctor reads a built bundle and reports what the OS
will actually see.
@AppIntent(
title: 'Add task',
description: 'Creates a new task in the Inbox',
phrases: [r'Add a task to $app', r'New $app task'],
execution: Execution.background,
)
Future<IntentResult> addTask({
@Param(title: 'Title', requestValueDialog: 'What should it be called?')
required String title,
@Param(title: 'Due date') DateTime? dueDate,
}) async {
final task = await TaskRepo.instance.create(title, dueDate);
return IntentResult.dialog('Added "${task.title}"');
}dart run build_runner build turns that into a Swift AppIntent struct, an
AppShortcutsProvider, a Kotlin @AppFunction, and the Dart dispatcher that
routes an invocation back to your function.
| Dart | iOS | Android | notes |
|---|---|---|---|
String int double bool |
same | same | |
DateTime |
Date |
Long |
epoch milliseconds, UTC |
Uri |
URL |
String |
|
Duration |
Measurement<UnitDuration> |
Long |
microseconds — Dart's own integer form, as DateTime uses its millisecondsSinceEpoch |
Measurement |
Measurement<Unit…> |
Double |
needs @Param(dimension:); the value arrives in the SI base unit |
IntentFile |
IntentFile |
— | iOS only |
an @AppEntity class |
AppEntity + query |
String |
crosses as its identifier |
an @AppEnum enum |
AppEnum |
String + value constraint |
crosses as the constant's name |
Measurement is a quantity the user picks with a unit, and which unit picker
they see is part of the generated Swift type — so it is declared rather than
inferred:
@Param(title: 'Distance', dimension: Dimension.length) required Measurement distance,Seven dimensions: length, mass, duration, speed, temperature,
volume, energy. App Intents has 22, and the other fifteen are iOS 17 —
measured against the SDK, which is why the list stops where the package's own
floor does.
IntentFile has no Android counterpart, and that is a difference in model
rather than a gap: Android hands an agent a content URI and a permission grant
instead of the bytes. An intent taking one is left out of the AppFunctions
surface, and sync --android says which and why. Its app shortcut is
unaffected.
The same types can be returned with returns:, except Measurement — a bare
Type has nowhere to put a dimension, and the generator refuses it rather than
emitting Swift that will not build.
if (!await auth.isSignedIn) {
return IntentResult.failure('Sign in to add tasks.');
}That sentence is what Siri reads out and what Shortcuts shows. An exception is
not: a handler that throws is reported to the system as The action could not
be completed, and the real message goes to the log — toString() routinely
carries an identifier, a path or a row out of somebody's database, and the
system says it out loud. OsIntents.unexpectedErrorMessage changes that
fallback; a debug build still passes the exception through, since during
development the only person listening is you.
final harness = IntentHarness($osIntentsRegistry);
expect(await harness.invoke('addTask', {'title': 'Buy milk'}),
isA<DialogResult>());
expect(harness.registeredIds, ['addTask', 'dueToday']); // renames fail CI
expect(await harness.entitiesMatching('Task', 'milk'), isNotEmpty);That runs handlers and entity queries. For everything else the package
offers — donations, static answers, launcher shortcuts — there is a fake
platform, because without one the platform interface answers false and an
empty list to keep a plugin-less build working, so the test passes whether or
not your code did anything:
import 'package:os_intents/testing.dart';
final platform = FakeOsIntentsPlatform.install();
await OsIntents.install($osIntentsRegistry);
await completeTask('t-1');
expect(platform.donations.single.id, 'completeTask');platform.invokeIntent('addTask', args: {...}) goes the way the OS does —
through the dispatcher, so what comes back is the wire map the system would
have been handed, error handling and all.
dart run os_intents_cli:os_intents sync --l10nEvery title, description, prompt and choice is then looked up by key in
ios/Runner/OsIntents/OsIntents.xcstrings, which sync writes and install
adds to the target's Resources phase. Open it in Xcode, translate, done.
The key is derived from ids — addTask.title, addTask.due.ask — not from the
English text, so improving the wording does not orphan every translation of it.
When the English does change, the other languages are marked needs_review
rather than silently left describing the old copy, which is what Xcode itself
does in the same situation.
sync merges; it never overwrites. A catalogue holds work that came from a
person. New keys are added, translations are kept, and a key no intent declares
any more is reported rather than deleted — you decide when that is safe.
Two things localise by a different mechanism, both Apple's rather than ours:
- Phrases cannot be keyed —
AppShortcutPhraseis a plainStringin the SDK, so the English phrase is its own key. Xcode extracts them intoAppShortcuts.stringsfor you; add a<lang>.lprojcopy to translate them. The keys are the strings you wrote in Dart,$appand all. A single-fileAppShortcuts.xcstringsexists but needs iOS 17, andsync --l10nlists the keys instead of writing one when your deployment target is lower. - Android already localises: shortcut labels are string resources, so a
values-de/strings.xmlis all it takes. AppFunction descriptions come from KDoc and cannot be localised at all — that is the platform's design.
Pass --l10n to sync --check too, or CI will report drift.
Nothing to turn on. If the project has a macos/ folder, os_intents build
writes the same generated Swift into macos/Runner/OsIntents and registers it
with that Runner target too — App Intents is one framework across Apple's
platforms, and the emitter names both floors in the same @available. macOS 13+,
the same way iOS is 16+.
One difference, and it only shows with the app closed: macOS FlutterEngine has
no libraryURI parameter, so a headless engine there can only reach an
entrypoint in the library that holds main(). While the app is running the
router uses the UI isolate and nothing is lost. sync says so when it applies
to you. Declaring the handler in main.dart avoids it entirely.
doctor reads a macOS bundle too — its metadata lives under
Contents/Resources/, and it carries no root.ssu.yaml, which is normal there
rather than a problem.
Declaring an intent makes it available. Telling the system one just happened makes it suggested — and the two platforms do that by different mechanisms, so they have different names here rather than one call that quietly does nothing on half your users' devices.
await TaskRepo.instance.complete(task.id);
// iOS: a hint to Siri's ranking model.
await OsIntents.donate('completeTask', {'taskId': task.id});
// Android: an entry on the launcher, which the app owns.
await OsIntents.pushShortcut(
DynamicShortcut(
id: 'task-${task.id}',
intentId: 'completeTask',
shortLabel: task.title,
args: {'taskId': task.id},
),
);Each returns false on the platform that has no counterpart, so both are safe to
call unconditionally. A dynamic shortcut runs the named intent through exactly
the path a generated app shortcut uses, so the handler sees args as if the
system had filled them in.
OsIntents.shortcuts(), removeShortcuts() and maxShortcuts() round it out.
Pushing the same id twice replaces the entry, which is what makes "the last five
things you did" cheap to keep. At the cap, Android 11+ drops the lowest-ranked
entry itself; below that pushShortcut returns false rather than picking one of
your shortcuts to throw away.
Not first, and not alone — five packages occupy this niche, two of them active. Their sources were read rather than guessed at; the full read, mechanism by mechanism, is docs/prior_art.md:
app_intents(+ annotations + codegen) is the same shape as this one and has a wider API — AppEnum, unions,PersonName,Duration, App Schema, String Catalog localisation. What it does not have is headless execution: its own docs warn that the default method-channel mode fails when an intent runs in the isolated process, and steer you to the two modes that open the app (a URL scheme, or a cache drained on resume). It also requires iOS 17 and Android 16.flutter_assistant_intentskeeps a headless iOS engine of its own and publishes Android shortcuts from Dart. Its source of truth is YAML.intelligence,flutter_app_intentsandsirikit_media_intentsare iOS-only and want hand-written Swift.
So what is actually different here:
| os_intents | |
|---|---|
| Did it reach the OS? | os_intents doctor reads the built bundle and tells you — nothing else does. Plus sync --check and install --check as CI drift guards |
| When it does not work | docs/troubleshooting.md: every silent failure in this space, and the command that identifies each one |
| Runs without opening the app | measured, from Shortcuts, on iOS 16+ — the isolated-process question settled rather than routed around |
| Android cost | two layers; the version chain is opt-in, not imposed |
| Claims | every one in the table above is backed by a device harness, and what is unproven says so |
Every claim in that table, with the evidence behind it and the list of things still unobserved: docs/verified.md.
packages/
os_intents app-facing: annotations, results, registry, test harness
os_intents_platform_interface the contract platform implementations fulfil
os_intents_ios iOS bridge, headless engine, snippet view (Swift)
os_intents_android Android bridge, headless engine, shortcut routing (Kotlin)
os_intents_gen build_runner builder → Dart + Swift + Kotlin + shortcuts XML
os_intents_cli sync / install / doctor
probe/
risk1_metadata where may generated Swift live? (answered)
android_appfunctions Android feasibility and the Kotlin emitter's end-to-end check
run_cold_start.sh a blank flutter create → one command → a built app
docs/
troubleshooting.md it built and Siri still cannot see it
risk1.md experiment design and verdict
android.md both Android layers, and what each costs
verified.md what ran on a device, and what never has
Pub workspace — one flutter pub get at the root resolves everything.
Flutter version is pinned per-repo via fvm (.fvmrc, currently 3.44.8) so this
work cannot disturb other projects on the machine.
App Intents are registered at compile time: Xcode extracts metadata from
Swift types into Metadata.appintents inside the app bundle, and nothing
declared at runtime from Dart is ever visible to Siri. So the generated Swift has
to land somewhere the extractor will look — and that is your app target, at
ios/Runner/OsIntents/. Registering that folder once is the whole of
os_intents install.
It could have been the plugin instead, and a probe showed that would actually
work — but a published package lives in ~/.pub-cache, shared between projects
and wiped by pub cache repair, so per-project generated sources could never
live there. Spoken phrases settled it regardless: an AppShortcutsProvider
declared in a plugin is dropped in silence, no error and no warning, and Siri
never gets the utterance.
The experiments, their measurements and both verdicts: docs/risk1.md.
MIT — see LICENSE.
The code os_intents generates is yours. The Swift, Kotlin and XML written
into your project by build_runner and os_intents sync are the output of a
tool, like a compiler's, and carry no obligation from this licence — no notice
to keep, no attribution to add. The licence covers os_intents itself.
