@crockalet/react-native-app-intents helps React Native apps define type-safe intents once, generate native
App Intents/App Shortcuts files, and route launched intents back into JavaScript.
npm install @crockalet/react-native-app-intents
For iOS bare React Native apps, install pods after adding the package:
npx pod-install
Create intent files anywhere matched by your config, for example src/orders.intents.ts:
import { defineEntity, defineIntent, p } from "@crockalet/react-native-app-intents";
export const Order = defineEntity({
id: "Order",
title: "Order",
inventory: [{ id: 1, number: "1234", customer: "Taylor" }],
schema: p.object({
id: p.int(),
number: p.string(),
customer: p.string(),
}),
identifier: (order) => String(order.id),
displayRepresentation: (order) => ({
title: `Order #${order.number}`,
subtitle: order.customer,
image: { systemName: "bag" },
}),
});
export const openOrder = defineIntent({
id: "openOrder",
title: "Open Order",
description: "Open a specific order.",
phrases: ["Open order ${orderNumber} in ${.applicationName}"],
params: {
orderNumber: p.string({
title: "Order number",
default: "1234",
requestValueDialog: "What's the order number?",
}),
},
surfaces: {
spotlight: true,
appShortcut: {
icon: {
androidResourceName: "@mipmap/ic_launcher_round",
systemName: "shippingbox",
},
},
},
android: {
appAction: {
capability: "actions.intent.GET_ORDER",
},
},
ios: {
appIntent: {},
},
behavior: { opensAppToForeground: true },
});
export const openSavedOrder = defineIntent({
id: "openSavedOrder",
title: "Open Saved Order",
phrases: ["Open ${order} in ${.applicationName}"],
params: {
order: p.entity(Order, {
androidBiiParam: "order",
title: "Order",
default: { id: 1, number: "1234", customer: "Taylor" },
}),
},
surfaces: {
spotlight: true,
appShortcut: true,
},
android: {
appAction: {
capability: "actions.intent.GET_ORDER",
},
},
ios: {
appIntent: {},
},
});
Create app-intents.config.ts at your app root:
import { defineAppIntentsConfig } from "@crockalet/react-native-app-intents/codegen";
export default defineAppIntentsConfig({
intents: ["src/**/*.intents.ts"],
scheme: "myapp",
ios: {
output: "ios/MyApp/AppShortcuts.swift",
appGroupIdentifier: "group.com.example.myapp",
appShortcutsProviderName: "MyAppShortcuts",
bundleIdentifier: "com.example.myapp",
siriUsageDescription: "Used to let Siri run app actions.",
},
android: {
manifest: "android/app/src/main/AndroidManifest.xml",
shortcutsOutput: "android/app/src/main/res/xml/app_shortcuts.xml",
packageName: "com.example.myapp",
},
types: { output: "src/generated/app-intents.d.ts" },
});
Run codegen:
npx app-intents generate
Use --check in CI to fail when generated files are stale:
npx app-intents generate --check
Use the package root as the Expo config plugin. The plugin auto-loads
app-intents.config.ts from your app root, so Expo prebuild and the CLI share
one source of truth:
{
"expo": {
"plugins": ["@crockalet/react-native-app-intents"]
}
}
If your config lives elsewhere, pass configPath:
{
"expo": {
"plugins": [
["@crockalet/react-native-app-intents", { "configPath": "./config/app-intents.ts" }]
]
}
}
Expo mode honors configured ios.output, android.manifest,
android.shortcutsOutput, and android.shortcutsStringsOutput paths relative to
the app root. An ios.output without an ios/ prefix is written under the
generated iOS project folder.
Use Expo’s expo-asset config plugin when you want a custom Android shortcut
icon from an image file instead of a built-in launcher resource. The plugin links
the file into the native project during prebuild, and the file name becomes the
Android resource name.
{
"expo": {
"plugins": [
["expo-asset", { "assets": ["./assets/shortcuts/open_order.png"] }],
"@crockalet/react-native-app-intents"
]
}
}
Then reference that asset from your intent definition or dynamic shortcut:
export const openOrder = defineIntent({
id: "openOrder",
title: "Open Order",
phrases: ["Open order ${orderNumber} in ${.applicationName}"],
params: {
orderNumber: p.string({ default: "1234" }),
},
surfaces: {
appShortcut: {
icon: {
androidResourceName: "@drawable/open_order",
systemName: "shippingbox",
},
},
},
});
await appIntents.updateDynamicShortcuts([
{
icon: {
androidResourceName: "@drawable/open_order",
iosTemplateImageName: "open_order",
systemName: "shippingbox",
},
intent: openOrder,
params: { orderNumber: "1234" },
shortTitle: "Open order #1234",
},
]);
Notes:
expo-asset uses the file name as the native resource name, so
assets/shortcuts/open_order.png becomes @drawable/open_order.npx expo prebuild after adding, removing, or renaming shortcut icon
files.iosTemplateImageName: "open_order" (no file
extension) to point at the bundled asset by name.systemName; Expo-bundled PNG assets are not used
there.Add the package as an Expo config plugin:
{
"expo": {
"plugins": [
[
"@crockalet/react-native-app-intents",
{
"intents": ["src/**/*.intents.ts"],
"scheme": "myapp",
"ios": {
"appGroupIdentifier": "group.com.example.myapp",
"siriUsageDescription": "Used to let Siri run app actions."
}
}
]
]
}
}
Then run prebuild:
npx expo prebuild
The plugin currently:
Info.plist for the URL scheme and optional Siri/app-group settingsios.appGroupIdentifier is configuredMainActivity can receive foreground deep linksAppDelegate.swiftCodegen handles the generated Swift and Android XML files, but bare iOS apps still need to forward
home-screen quick actions from AppDelegate.swift:
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
// ... existing startup ...
if let shortcutItem = launchOptions?[.shortcutItem] as? UIApplicationShortcutItem,
handleShortcutItem(shortcutItem) {
return false
}
return true
}
func application(
_ application: UIApplication,
performActionFor shortcutItem: UIApplicationShortcutItem,
completionHandler: @escaping (Bool) -> Void
) {
completionHandler(handleShortcutItem(shortcutItem))
}
private func handleShortcutItem(_ shortcutItem: UIApplicationShortcutItem) -> Bool {
guard let url = shortcutItem.userInfo?["url"] as? String else {
return false
}
ReactNativeAppIntents.recordIncomingURLString(url)
return true
}
Android deep links are routed through the generated manifest intent filters and,
when the app is already open, through the main activity’s foreground-intent
launch mode into React Native’s onNewIntent handling.
import { createAppIntentsRuntime } from "@crockalet/react-native-app-intents";
import { openOrder, openSavedOrder } from "./orders.intents";
const appIntents = createAppIntentsRuntime({
scheme: "myapp",
intents: [openOrder, openSavedOrder] as const,
});
const initialIntent = await appIntents.getInitialIntent();
if (initialIntent?.id === "openOrder") {
// Navigate from a cold launch.
}
const unsubscribe = appIntents.onIntent(openOrder, (params) => {
// Navigate from a warm/background launch.
});
await appIntents.updateDynamicShortcuts([
{
icon: {
androidResourceName: "@mipmap/ic_launcher_round",
iosTemplateImageName: "burger",
systemName: "shippingbox",
},
intent: openOrder,
params: { orderNumber: "1234" },
shortTitle: "Open order #1234",
longTitle: "Taylor",
},
]);
surfaces.appShortcut can be either true or an object with an iOS SF Symbol
(systemName) and/or an Android shortcut resource reference (androidResourceName).
For dynamic shortcuts, icon can additionally include iosTemplateImageName.
await appIntents.donate(openOrder, { orderNumber: "1234" });
await appIntents.clearDonations();
On iOS this creates an NSUserActivity eligible for system predictions and clears donated
interactions/user activities. On Android this publishes a removable long-lived shortcut donation and
clears the shortcut donations created by donate.
For auth-gated or feature-flagged flows, treat donations and dynamic shortcuts as derived session state. Donate when the user completes a real action, and clear everything on logout or when the feature is disabled:
import { useEffect } from "react";
import { createAppIntentsRuntime } from "@crockalet/react-native-app-intents";
import { openOrder } from "./orders.intents";
const appIntents = createAppIntentsRuntime({
scheme: "myapp",
intents: [openOrder] as const,
});
async function handleOpenedOrder(orderNumber: string) {
// Only donate actions the user actually performed.
await appIntents.donate(openOrder, { orderNumber });
}
useEffect(() => {
if (!session || !flags.orderShortcuts) {
void appIntents.clearDonations();
void appIntents.updateDynamicShortcuts([]);
return;
}
void appIntents.updateDynamicShortcuts([
{
intent: openOrder,
params: { orderNumber: session.lastViewedOrderNumber },
shortTitle: "Open last order",
longTitle: `Open order ${session.lastViewedOrderNumber}`,
},
]);
}, [session, flags.orderShortcuts]);
async function logout() {
await auth.signOut();
await appIntents.clearDonations();
await appIntents.updateDynamicShortcuts([]);
}
This keeps Siri/App Shortcuts suggestions aligned with the current account state instead of exposing stale shortcuts after logout.
android.appAction to opt an intent into Android App Actions.surfaces.assistant and top-level androidBii are legacy shims and should be avoided in new intent definitions.| Scenario | Status | Coverage |
|---|---|---|
actions.intent.GET_ORDER scalar slot binding |
Supported | Core validation, codegen snapshot, runtime test |
actions.intent.GET_ORDER entity-backed shortcut inventory |
Supported | Core validation, codegen snapshot, example app |
| Capability-bound Android donations and dynamic shortcuts | Supported | Runtime test, Android native module wiring |
Legacy surfaces.assistant / top-level androidBii usage |
Compatibility only | Core validation |
| Google Assistant voice triggering | Best effort | Manual verification only |
| Verified App Links / Play Console review steps | Manual setup required | Codegen diagnostics |
For an opt-in adb smoke test against the bare Android example app, run:
RN_APP_INTENTS_ANDROID_E2E=1 bun test packages/react-native/test/android-app-actions.e2e.test.ts
ios.appIntent to opt an intent into native Siri/App Intents generation.surfaces.siri no longer enables App Intents by itself; keep using surfaces.appShortcut and surfaces.spotlight for those separate surfaces.ios.appIntent.response.dialog is supported only for background intents; it cannot be combined with behavior.opensAppToForeground.object params are flattened into Swift leaf parameters for App Intents, but phrases cannot interpolate the object parameter itself.| Scenario | Status | Coverage |
|---|---|---|
ios.appIntent foreground URL handoff |
Supported | Codegen snapshot, runtime tests, example app |
Static ios.appIntent.response.dialog |
Supported | Core validation, Swift typecheck, snapshot |
Nested object params in generated App Intents |
Supported | Core validation, Swift typecheck, snapshot |
surfaces.siri without ios.appIntent |
Unsupported | Core validation |
Object-param placeholders inside phrases |
Unsupported | Core validation |
| Custom bundled image assets in generated App Shortcuts | Unsupported | Documentation only |
| Dynamic Siri dialog sourced from JS/native perform logic | Not yet supported | Explicit non-goal for current slice |
defineIntent, defineEntity, and p.* parameter builders.android.appAction authoring plus Android shortcuts XML, strings XML, and manifest patching.ios.appIntent authoring plus Swift App Intents/App Shortcuts generation.ios.appIntent.response.dialog.This repository includes a GitHub Actions workflow that builds this docs/ directory with GitHub
Pages. In the GitHub repository settings, set Pages -> Build and deployment -> Source to
GitHub Actions. Push to main, then open:
https://crockalet.github.io/react-native-app-intents/