Skip to content

Repository files navigation

os_intents

pub package CI license: MIT

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.

Running an action from the Shortcuts app: the handler answers and the app never opens

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 blank flutter create via run_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

What works

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.

Pipeline

dart run os_intents_cli:os_intents build

One 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.

The idea

@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.

What a parameter may be

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.

When the action cannot run

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.

Testing, without a device

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.

Shipping in more than one language

dart run os_intents_cli:os_intents sync --l10n

Every 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 — AppShortcutPhrase is a plain String in the SDK, so the English phrase is its own key. Xcode extracts them into AppShortcuts.strings for you; add a <lang>.lproj copy to translate them. The keys are the strings you wrote in Dart, $app and all. A single-file AppShortcuts.xcstrings exists but needs iOS 17, and sync --l10n lists 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.xml is 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.

macOS

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.

Offering an action back after the user takes it

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.

Why another one

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_intents keeps a headless iOS engine of its own and publishes Android shortcuts from Dart. Its source of truth is YAML.
  • intelligence, flutter_app_intents and sirikit_media_intents are 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.

Layout

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.

Why there is an Xcode step at all

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.

License

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.

About

Declare OS-level app actions in Dart. Generates iOS App Intents and Android AppFunctions at build time — Siri, Spotlight, Shortcuts and on-device agents can run them without opening the app.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages