expo-invoke
v3.1.0
Published
The complete native-surface-to-JS bridge for Expo. One intent config → Siri, Google Assistant, home screen widgets, Dynamic Island, app icon menus, notification actions, NFC, QR, deep links and more — all through a single useInvoke() hook.
Maintainers
Readme
expo-invoke
The complete native-surface-to-JS bridge for Expo. Define one intent in app.json and it automatically works from every surface — Siri, Google Assistant, home screen widgets, Dynamic Island, app icon long-press menus, notification action buttons, NFC tags, QR codes, deep links, and more — all through a single useInvoke() hook.
No manual Swift or Kotlin required. The config plugin generates all native code from your app.json.
Table of Contents
- Requirements
- Installation
- Quick Start
- useInvoke() — Full API
- Hooks
- useInvokeInit
- useInvoke
- useDonation — Siri Suggestions
- useShortcuts — Launcher Long-Press Menu
- useWidgetData — Push State to Widgets
- useSpotlight — iOS Spotlight Search
- useFocusFilter — iOS Focus Mode
- useAddToSiri
- useQuickTile — Android Quick Settings
- useHandoff — Apple Handoff
- useLiveActivityIntent — Dynamic Island
- useInvokeHistory
- useInvokeAnalytics
- Components
- Parameter Types
- app.json Config Reference
- Intent TTL
- CLI
- TypeScript Codegen
- Testing
- Supported Surfaces
Requirements
| Platform | Minimum | |---|---| | iOS | 16.0 (App Intents API) | | Android | API 24 / Android 7.0 | | Expo SDK | 50+ | | expo-modules-core | 1.12+ |
Installation
npx expo install expo-invokeThen rebuild your native app. Choose the workflow that fits your project:
Local build
npx expo prebuild # generates native code from your app.json
npx expo run:ios # build + run on simulator or device
npx expo run:androidEAS Build (cloud)
expo-invoke works fully with EAS Build. EAS automatically runs expo prebuild before each build, so the config plugin generates all Swift and Kotlin code in the cloud — no local Xcode or Android Studio required.
# Install EAS CLI
npm install -g eas-cli
# Configure your project (first time only)
eas build:configure
# Build for both platforms
eas build --platform all
# Or build for a specific platform
eas build --platform ios
eas build --platform androidA minimal eas.json to get started:
{
"cli": {
"version": ">= 10.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {}
},
"submit": {
"production": {}
}
}Development builds: For the fastest iteration loop during development, use
eas build --profile developmentto create a development build — a custom Expo Go that includes your native modules including expo-invoke.
EAS Build local
Run the EAS build pipeline locally (requires Xcode / Android Studio):
eas build --platform ios --local
eas build --platform android --localQuick Start
1. Configure your intents in app.json
{
"plugins": [
["expo-invoke", {
"appGroupId": "group.com.myapp.invoke",
"enableWidget": true,
"widgetTitle": "My App",
"widgetAccentColor": "#267AD9",
"widgetBgColor": "#E6F4FF",
"widgetIcon": "square.grid.2x2.fill",
"widgetDeepLink": "myapp://home",
"widgetSizes": ["small", "medium"],
"intents": [
{
"id": "create_errand",
"title": "Create an Errand",
"phrases": ["Create an errand in ${applicationName}"],
"shortTitle": "Create Errand",
"icon": "shippingbox"
}
]
}]
]
}
${applicationName}is required in every Siri phrase so the system knows which app to launch.shortTitlepowers the long-press app icon Quick Actions menu — add up to 4 intents withshortTitleto populate it.
2. Initialize in your root layout
// app/_layout.tsx
import { useInvokeInit } from 'expo-invoke';
export default function RootLayout() {
useInvokeInit(); // handles cold start AND warm start — call once, here only
return <Stack />;
}3. React to intents anywhere in your app
import { useEffect } from 'react';
import { useInvoke } from 'expo-invoke';
export default function HomeScreen() {
const { intent, clear } = useInvoke({ intentId: 'create_errand' });
useEffect(() => {
if (!intent) return;
router.push('/create-errand');
clear();
}, [intent]);
return <View />;
}useInvoke() — Full API
const { intent, parameters, source, clear } = useInvoke({
// Filter to a specific intent (optional)
intentId: 'create_errand',
// Filter to specific surfaces (optional — defaults to all)
source: ['siri', 'widget', 'nfc'],
// Inline handler (optional — alternative to watching the returned value)
onFire: (intent) => {
console.log(intent.intentId, intent.source, intent.parameters);
},
});
// intent — ResolvedIntent | null
// parameters — Record<string, ParameterValue> (shorthand for intent?.parameters)
// source — InvokeSource | undefined
// clear() — clears the pending intent so it doesn't re-fire on re-renderResolvedIntent shape:
{
intentId: string; // e.g. "create_errand"
source: InvokeSource; // e.g. "siri"
timestamp: number; // ms since epoch
parameters: Record<string, ParameterValue>;
rawUrl?: string; // present when source is 'deep_link' | 'nfc' | 'qr'
}Hooks
useInvokeInit
Call once in your root layout. Handles two start paths:
- Cold start — app was not running; Siri/widget wrote the intent to native storage before JS loaded.
useInvokeInitreads it on mount. - Warm start — app was backgrounded; intent fires as a native event.
useInvokeInitsubscribes and routes it touseInvoke.
// app/_layout.tsx
import { useInvokeInit } from 'expo-invoke';
export default function RootLayout() {
useInvokeInit();
return <Stack />;
}useInvoke
The universal receiver. Works the same regardless of which surface fired the intent.
// No filter — receive any intent from any surface
const { intent, clear } = useInvoke();
// Filter by intentId
const { intent } = useInvoke({ intentId: 'track_errand' });
// Filter by source
const { intent } = useInvoke({ source: ['widget', 'notification'] });
// Inline handler
useInvoke({
intentId: 'create_errand',
onFire: ({ parameters }) => {
createErrand({ pickup: parameters.pickup });
},
});useDonation — Siri Suggestions
Donating an action tells Siri the user performed it. After several donations, Siri surfaces the phrase as a suggestion on the lock screen, in Spotlight, and in the Siri watch face.
import { useDonation } from 'expo-invoke';
function CreateErrandScreen() {
const { donate, remove, removeAll } = useDonation();
const handleCreate = async (errand) => {
await createErrand(errand);
// Donate after a successful action — Siri learns the pattern
donate({
intentId: 'create_errand',
title: `Create errand to ${errand.destination}`,
parameters: { pickup: errand.pickup },
});
};
// Remove a specific donation (e.g. when errand is cancelled)
const handleCancel = () => remove('create_errand');
// Remove all donations for a fresh start
const handleReset = () => removeAll();
}Donations accumulate over time. The more a user performs an action the higher Siri ranks it.
useShortcuts — Launcher Long-Press Menu
Dynamic shortcuts appear when the user long-presses your app icon. They update in real time based on what the user does most.
import { useShortcuts } from 'expo-invoke';
function App() {
const { setShortcuts, clear } = useShortcuts();
useEffect(() => {
// Called after login — show personalized shortcuts
setShortcuts([
{
id: 'recent_errand',
title: 'Resume Last Errand',
subtitle: 'Pick up from Accra Mall',
icon: 'arrow.clockwise',
intentId: 'track_errand',
parameters: { errandId: '123' },
},
{
id: 'new_errand',
title: 'Create Errand',
icon: 'plus',
intentId: 'create_errand',
},
]);
}, [userId]);
}Static shortcuts (always visible, defined at install time) are configured via the shortTitle field in app.json and generated automatically by the config plugin.
Home Screen Widget (Built-in — no Swift required)
expo-invoke includes a zero-native-code WidgetKit extension. Enable it in app.json and push data from TypeScript — no Xcode, no Swift files, no separate plugin needed.
1. Enable in app.json
["expo-invoke", {
"appGroupId": "group.com.myapp.invoke",
"enableWidget": true,
"widgetTitle": "My App",
"widgetDescription": "Quick access from your home screen.",
"widgetDataKey": "myWidgetData",
"widgetAccentColor": "#267AD9",
"widgetBgColor": "#E6F4FF",
"widgetIcon": "square.grid.2x2.fill",
"widgetDetailIcon": "person.fill",
"widgetEmptyText": "Nothing here yet",
"widgetRefreshMinutes": 15,
"widgetDeepLink": "myapp://home",
"widgetSizes": ["small", "medium", "large"],
"intents": [...]
}]
appGroupIdis required for widget data sharing — the app and the widget extension read/write the same UserDefaults container.
Widget configuration reference:
| Option | Type | Default | Description |
|---|---|---|---|
| enableWidget | boolean | false | Enable the built-in WidgetKit extension |
| appGroupId | string | — | Required. App Group shared between app and widget. Must start with group. |
| widgetTitle | string | App name | Display name shown in the iOS widget picker |
| widgetDescription | string | "Quick access from your home screen." | Description shown in the widget picker |
| widgetDataKey | string | "invokeWidgetData" | UserDefaults key to read widget data from — must match your setWidgetData() call |
| widgetAccentColor | string | "#267AD9" | Hex accent color used for count, icon, and badge (e.g. "#3B82F6") |
| widgetBgColor | string | "#E6F4FF" | Hex background color for all widget sizes (e.g. "#EFF6FF") |
| widgetIcon | string | "square.grid.2x2.fill" | SF Symbol name for the main widget icon (e.g. "bag.fill", "car.fill") |
| widgetDetailIcon | string | "person.fill" | SF Symbol name for detail rows — assignee, carrier, etc. |
| widgetEmptyText | string | "Nothing here yet" | Text shown in the large widget when there is no data |
| widgetRefreshMinutes | number | 15 | How often (in minutes) the widget timeline refreshes. Must be 1–60. |
| widgetDeepLink | string | "" | URL scheme opened when the widget is tapped (e.g. "myapp://home") |
| widgetSizes | string[] | ["small","medium"] | Sizes to generate: "small", "medium", "large" |
2. Push data from TypeScript
import { setWidgetData } from 'expo-invoke';
import type { InvokeWidgetPayload } from 'expo-invoke';
// Call this whenever your data changes — after fetching, after mutations, etc.
function syncWidget(activeItems: MyItem[]) {
const latest = activeItems[0];
const payload: InvokeWidgetPayload = {
count: activeItems.length,
badgeLabel: activeItems.length === 1 ? 'Active Order' : 'Active Orders',
title: latest?.name ?? null,
subtitle: latest?.status ?? null,
detail: latest?.assigneeName ?? null,
// deepLink: 'myapp://orders/123' ← optional per-item override
};
setWidgetData('myWidgetData', payload);
}InvokeWidgetPayload fields:
| Field | Type | Description |
|---|---|---|
| count | number | Prominent badge number (e.g. active order count) |
| badgeLabel | string | Label under the count (e.g. "Active Orders") |
| title | string \| null | Main item title |
| subtitle | string \| null | Status line (e.g. "In Progress") |
| detail | string \| null | Extra detail (e.g. assignee name) |
| deepLink | string \| null | Optional per-item deep-link URL override |
3. Fix the splash screen for widget launches
Common gotcha — app freezes on widget tap
If you use
NativeSplash.preventAutoHideAsync(), add ahideAsync()call in your root_layout.tsx— not just inindex.tsx. When the app opens from a widget tap, Expo Router routes directly to the deep-link destination screen.index.tsxis never mounted, so anyhideAsync()call inside it is never reached. The native splash stays on screen forever.Calling
hideAsync()twice (once in_layout.tsxand once inindex.tsx) is safe — the second call is a no-op.
// app/_layout.tsx
import * as NativeSplash from 'expo-splash-screen';
NativeSplash.preventAutoHideAsync().catch(() => {});
export default function RootLayout() {
React.useEffect(() => {
NativeSplash.hideAsync().catch(() => {}); // always fires — covers widget/deep-link entry
}, []);
return <Stack />;
}Also make sure widgetDeepLink points to a real route in your app. In Expo Router, group folders like (tabs) are transparent in URLs:
app/(tabs)/orders.tsx→myapp://orders✓myapp://errandswith noapp/errands.tsx→ 404 screen ✗
Test the deep link before shipping: xcrun simctl openurl booted "myapp://your/path"
4. Prebuild + run
npx expo prebuild --platform ios --clean
npx expo run:iosThe widget is generated entirely by the plugin — InvokeWidget.swift is written to ios/InvokeWidget/ automatically. After prebuild the file is there but you never need to edit it.
Testing in the simulator:
- Long-press the home screen → tap + → search "My App"
- Choose small, medium, or large size
- Tap the widget — verify the app opens to the correct screen without freezing
- Long-press the app icon for Quick Action shortcuts
useWidgetData — Push State to Widgets
Push live app state to home screen widgets without the user opening the app. The widget reads from the same shared storage.
import { useWidgetData } from 'expo-invoke';
function TrackingScreen({ errand }) {
const { set } = useWidgetData('active_errand');
useEffect(() => {
set({
title: errand.title,
status: errand.status,
eta: errand.estimatedArrival,
});
}, [errand]);
}On iOS, data is stored in an App Group shared UserDefaults container. Configure the App Group in app.json:
["expo-invoke", { "appGroupId": "group.com.myapp.invoke" }]useSpotlight — iOS Spotlight Search
Index items so users can find them via iOS Spotlight (swipe down on home screen). Tapping a result fires the associated intent.
import { useSpotlight } from 'expo-invoke';
function ErrandsListScreen({ errands }) {
const { index, deindex } = useSpotlight();
useEffect(() => {
index(
errands.map((e) => ({
id: `errand-${e.id}`,
title: e.title,
description: e.description,
keywords: ['errand', 'delivery', e.destination],
intentId: 'track_errand',
intentParameters: { errandId: e.id },
}))
);
return () => deindex(errands.map((e) => `errand-${e.id}`));
}, [errands]);
}useFocusFilter — iOS Focus Mode
Read the active iOS Focus mode (Work, Personal, Sleep, etc.) and adapt your UI or behaviour accordingly.
import { useFocusFilter } from 'expo-invoke';
function NotificationSettings() {
const focus = useFocusFilter();
// focus: 'work' | 'personal' | 'sleep' | 'do_not_disturb' | 'driving' | null
return (
<View>
{focus === 'work' && <Text>Work mode — showing only work shortcuts</Text>}
{focus === 'sleep' && <Text>Sleep mode — notifications silenced</Text>}
{focus === null && <Text>No active focus</Text>}
</View>
);
}Re-reads automatically every time the app comes to the foreground.
useAddToSiri
Programmatically open the native "Add to Siri" sheet so users can record a custom voice phrase for any intent.
import { useAddToSiri } from 'expo-invoke';
// or use the pre-built <AddToSiriButton /> component
function IntentSettings() {
const addToSiri = useAddToSiri('create_errand');
return (
<TouchableOpacity onPress={addToSiri}>
<Text>Add "Create Errand" to Siri</Text>
</TouchableOpacity>
);
}useQuickTile — Android Quick Settings
Show a tile in the Android Quick Settings panel (swipe down twice). The tile can be tapped even when the app is closed.
import { useQuickTile } from 'expo-invoke';
function TrackingScreen({ isTracking }) {
const { update } = useQuickTile();
useEffect(() => {
update(
isTracking ? 'active' : 'inactive',
isTracking ? 'Tracking errand' : 'Start tracking'
);
}, [isTracking]);
}States: 'active' (tinted) · 'inactive' (dimmed) · 'unavailable' (greyed out, not tappable)
The tile maps to the quick_tile intent source — configure which intent the tile fires in app.json with "supportsQuickTile": true on the intent.
useHandoff — Apple Handoff
Let users continue an in-progress action on another Apple device (iPhone → iPad → Mac).
import { useHandoff } from 'expo-invoke';
function CreateErrandScreen({ draft }) {
const { advertise, stop, received } = useHandoff();
useEffect(() => {
// Broadcast: "I'm creating an errand, any device can continue"
advertise('create_errand', { draft: JSON.stringify(draft) });
return () => stop();
}, [draft]);
useEffect(() => {
if (!received) return;
// Another device handed off to this one — restore the draft
const restoredDraft = JSON.parse(received.parameters.draft as string);
setDraft(restoredDraft);
}, [received]);
}useLiveActivityIntent — Dynamic Island
Link a Live Activity to an intent so tapping the Dynamic Island compact view or Lock Screen banner fires that intent.
import { useLiveActivityIntent } from 'expo-invoke';
function ErrandTrackingScreen({ activityId }) {
const { set } = useLiveActivityIntent(activityId);
useEffect(() => {
// Tapping the Dynamic Island fires track_errand
set('track_errand');
}, [activityId]);
}Received via useInvoke({ source: ['live_activity'] }).
Configure at the plugin level for static linking:
["expo-invoke", {
"liveActivityIntents": {
"errandInProgress": "track_errand"
}
}]useInvokeHistory
Access the last N intents that fired (persisted in memory for the session).
import { useInvokeHistory } from 'expo-invoke';
function RecentActionsScreen() {
const history = useInvokeHistory(10); // last 10
return (
<FlatList
data={history}
renderItem={({ item }) => (
<Text>{item.intentId} via {item.source}</Text>
)}
/>
);
}Returns ResolvedIntent[] in reverse-chronological order (most recent first).
useInvokeAnalytics
Track how often intents fire and from which surfaces — useful for optimising which shortcuts to promote.
import { useInvokeAnalytics } from 'expo-invoke';
function AnalyticsDashboard() {
const { fired, donated } = useInvokeAnalytics();
// fired: { create_errand: 42, track_errand: 17 }
// donated: { create_errand: 8 }
return (
<View>
{Object.entries(fired).map(([id, count]) => (
<Text key={id}>{id}: {count} fires</Text>
))}
</View>
);
}Tap into the global analytics bus to forward events to Segment, Amplitude, etc.:
import { onIntentFired } from 'expo-invoke';
// In your analytics bootstrap (once, at app start)
onIntentFired((intent) => {
analytics.track('Intent Fired', {
intentId: intent.intentId,
source: intent.source,
});
});Components
<AddToSiriButton />
Renders the native "Add to Siri" button on iOS. No-op on Android.
import { AddToSiriButton } from 'expo-invoke';
<AddToSiriButton
intentId="create_errand"
onPress={() => console.log('Sheet opened')}
style={{ alignSelf: 'center', marginTop: 16 }}
/><ShortcutChip />
A tappable chip representing a dynamic shortcut. Pressing it promotes the shortcut to the top of the launcher long-press list.
import { ShortcutChip } from 'expo-invoke';
<ShortcutChip
shortcutId="quick_errand"
label="Quick Errand"
icon="📦"
onPress={() => router.push('/create-errand')}
/><InvokeDebugger />
A dev-only floating overlay that shows the current pending intent, its source, and the last 5 intents from history. Renders null in production automatically.
// app/_layout.tsx — add it once, forget it
import { InvokeDebugger } from 'expo-invoke';
export default function RootLayout() {
useInvokeInit();
return (
<>
<Stack />
<InvokeDebugger />
</>
);
}Parameter Types
The 8 supported parameter types and the JavaScript value shape you receive in intent.parameters:
| Type | Config | JS value |
|---|---|---|
| text | { "type": "text" } | string |
| number | { "type": "number", "min": 1, "max": 100 } | number |
| boolean | { "type": "boolean" } | boolean |
| date | { "type": "date" } | number (ms timestamp) |
| location | { "type": "location" } | { latitude: number, longitude: number, name?: string } |
| person | { "type": "person" } | { displayName?: string, phoneNumber?: string, emailAddress?: string, contactIdentifier?: string } |
| enum | { "type": "enum", "options": [{ "id": "...", "title": "..." }] } | string (the selected option id) |
| array | { "type": "array", "itemType": "text" \| "number" \| "location" } | string[], number[], or location object [] |
Example: reading typed parameters
useInvoke({
intentId: 'create_errand',
onFire: ({ parameters }) => {
const pickup = parameters.pickup as { latitude: number; longitude: number; name?: string };
const type = parameters.type as string; // enum id e.g. "delivery"
const quantity = parameters.quantity as number;
},
});Use
npx expo-invoke generateto generate a typedinvoke-types.d.tsso TypeScript knows the exact shape of every intent's parameters.
app.json Config Reference
intents
Each intent becomes a Siri phrase, an Android App Action, an app icon shortcut, and a widget button — all from one config entry.
{
"id": "create_errand",
"title": "Create an Errand",
"description": "Quickly create a new delivery errand",
"phrases": [
"Create an errand in ${applicationName}",
"New delivery in ${applicationName}"
],
"shortTitle": "Create Errand",
"icon": "shippingbox",
"requiresConfirmation": false,
"spokenResponse": "Opening ${applicationName} to create your errand",
"openAppWhenRun": true,
"supportsCarPlay": false,
"supportsWatch": false,
"supportsControlCenter": false,
"supportsQuickTile": false,
"deepLinkPatterns": ["myapp://errands/create"],
"ttl": 30,
"parameters": [
{ "name": "pickup", "type": "location", "title": "Pickup location", "optional": true },
{ "name": "type", "type": "enum", "title": "Errand type",
"options": [{ "id": "delivery", "title": "Delivery" }, { "id": "pickup", "title": "Pickup" }] },
{ "name": "notes", "type": "text", "title": "Notes", "optional": true }
]
}| Field | Type | Description |
|---|---|---|
| id | string | Stable unique identifier. Never rename after shipping. |
| title | string | Shown in Siri and system UI. |
| description | string? | Subtitle in Shortcuts.app. |
| phrases | string[] | Siri voice phrases. Must include ${applicationName}. |
| shortTitle | string? | Short label for app icon menus and widget buttons (≤ 10 chars for Android). |
| icon | string? | SF Symbol name (iOS) or Material icon name (Android). |
| requiresConfirmation | boolean? | Show a Siri confirmation dialog before running (default: false). |
| spokenResponse | string? | Text Siri reads aloud after the intent runs. Supports ${paramName} substitution. |
| openAppWhenRun | boolean? | Whether to bring the app to foreground (default: true). |
| supportsCarPlay | boolean? | Enable in CarPlay. Phrase works automatically in CarPlay when true. |
| supportsWatch | boolean? | Enable companion button on watchOS / Wear OS. |
| supportsControlCenter | boolean? | iOS 18+ Control Center control. |
| supportsQuickTile | boolean? | Android Quick Settings tile. |
| deepLinkPatterns | string[]? | URL patterns that fire this intent (e.g. "myapp://errands/create"). |
| ttl | number? | Seconds before a stored intent expires (default: 30). |
| parameters | IntentParameter[]? | Typed parameters Siri / Google can collect. |
widgets
Define home screen widgets. The config plugin generates the native WidgetKit extension (iOS) and AppWidgetProvider (Android) automatically.
"widgets": [
{
"id": "quick_errand",
"title": "Quick Errand",
"description": "One-tap errand creation",
"sizes": ["small", "medium"],
"backgroundColor": "#1C1C1E",
"actions": [
{ "label": "Create", "intentId": "create_errand", "icon": "plus" },
{ "label": "Track", "intentId": "track_errand", "icon": "location" }
]
}
]Widget button taps fire the intent with source: 'widget'. Use useWidgetData() to push live data into the widget from the app.
Supported sizes: small · medium · large · extraLarge · accessory (iOS lock screen)
notificationActions
Wire notification action buttons to intents. The config plugin registers the UNNotificationCategory (iOS) and NotificationCompat.Action (Android) automatically.
"notificationActions": [
{
"categoryId": "ERRAND_UPDATE",
"tapIntent": "track_errand",
"actions": [
{ "id": "track", "title": "Track", "intentId": "track_errand" },
{ "id": "cancel", "title": "Cancel", "intentId": "cancel_errand", "destructive": true },
{ "id": "confirm", "title": "Confirm", "intentId": "confirm_errand", "foreground": true }
]
}
]tapIntent— intent fired when the user taps the notification body (not a button). Received assource: 'notification_tap'.destructive: true— renders the button in red on iOS.foreground: true— brings the app to foreground when tapped.
When sending a notification from your server, include the category ID:
{ "aps": { "category": "ERRAND_UPDATE", "alert": "Your errand has an update" } }liveActivityIntents
Map Live Activity names to intents. Tapping the Dynamic Island or Lock Screen banner fires the mapped intent.
"liveActivityIntents": {
"errandInProgress": "track_errand",
"paymentPending": "confirm_payment"
}Received as source: 'live_activity'. Override dynamically with useLiveActivityIntent().
Plugin options
["expo-invoke", {
"intents": [...],
"widgets": [...],
"notificationActions": [...],
"liveActivityIntents": { ... },
"voiceUsageDescription": "Use voice commands to create and track errands.",
"appGroupId": "group.com.myapp.invoke"
}]| Option | Description |
|---|---|
| voiceUsageDescription | iOS NSSiriUsageDescription / NSVoiceUsageDescription in Info.plist. |
| appGroupId | iOS App Group for widget data sharing via useWidgetData(). |
Intent TTL
When an intent fires while the app is closed, it is written to native storage before JS loads. useInvokeInit() reads it on mount.
The default TTL is 30 seconds. If the app takes longer than 30s to fully load and call useInvokeInit(), the intent is discarded. You can override per intent:
{ "id": "create_errand", "ttl": 60, ... }This prevents a stale intent from firing long after the user has moved on. The TTL countdown starts from when the intent was stored natively (before the app launched), not from when JS mounts.
CLI
# Validate your app.json expo-invoke config
npx expo-invoke validate
# Generate TypeScript types from your config
npx expo-invoke generate
# Run the expo-invoke test suite
npx expo-invoke testvalidate
Catches:
- Missing
${applicationName}in phrases - Phrases longer than 140 characters
- Conflicting phrases across intents
- Duplicate intent or parameter IDs
- Invalid identifier format
- iOS shortcut count > 10 (warning)
- Android shortcut count > 5 (warning)
$ npx expo-invoke validate
Validating expo-invoke config in /my-app ...
⚠️ 1 warning(s):
• [intents] 11 intents with shortTitle — Android allows max 5 launcher shortcuts.
✅ Configuration is valid.generate
Outputs invoke-types.d.ts in your project root with per-intent parameter types (see TypeScript Codegen).
TypeScript Codegen
Run npx expo-invoke generate after editing your app.json. It produces invoke-types.d.ts:
// invoke-types.d.ts (auto-generated)
import type { ResolvedIntent } from "expo-invoke";
export type InvokeIntentId = "create_errand" | "track_errand" | "cancel_errand";
export interface CreateErrandIntent extends ResolvedIntent {
intentId: "create_errand";
parameters: {
pickup?: { latitude: number; longitude: number; name?: string };
type: "delivery" | "pickup";
notes?: string;
};
}Use it for type-safe intent handling:
import type { CreateErrandIntent } from '../invoke-types';
useInvoke({
intentId: 'create_errand',
onFire: (intent) => {
const typed = intent as CreateErrandIntent;
console.log(typed.parameters.type); // "delivery" | "pickup"
},
});Testing
import {
mockInvoke,
simulateColdStart,
simulateWarmStart,
createMockIntent,
} from 'expo-invoke';mockInvoke
Feeds an intent directly into the action store. useInvoke() will fire on the next render.
mockInvoke({ intentId: 'create_errand', source: 'siri' });
mockInvoke({ intentId: 'track_errand', source: 'widget', parameters: { errandId: '42' } });simulateColdStart
Simulates the app being closed when the intent fired. The intent is stored with a slightly past timestamp.
simulateColdStart({ intentId: 'create_errand', ageMs: 500 });
// ageMs: how old the intent is (default 100ms) — keep under your TTLsimulateWarmStart
Simulates the app being backgrounded when the intent fired (same as mockInvoke but named for clarity).
simulateWarmStart({ intentId: 'create_errand', source: 'notification' });createMockIntent
Creates a ResolvedIntent object for use in assertions without storing it.
const intent = createMockIntent({ intentId: 'test', source: 'nfc', parameters: { tag: 'abc' } });
expect(intent.source).toBe('nfc');Jest example
import { mockInvoke } from 'expo-invoke';
import { renderHook, act } from '@testing-library/react-hooks';
import { useInvoke } from 'expo-invoke';
test('fires handler when matching intent arrives', () => {
const handler = jest.fn();
renderHook(() => useInvoke({ intentId: 'create_errand', onFire: handler }));
act(() => {
mockInvoke({ intentId: 'create_errand', source: 'siri' });
});
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][0].source).toBe('siri');
});Supported Surfaces
| Surface | iOS | Android | Source value |
|---|---|---|---|
| Voice (Siri) | ✓ App Intents (iOS 16+) | — | siri |
| Voice (Google Assistant) | — | ✓ App Actions BII | google |
| App icon long-press | ✓ App Shortcuts | ✓ Static/dynamic shortcuts | shortcut |
| Home screen widget | ✓ WidgetKit | ✓ AppWidgetProvider | widget |
| Dynamic Island tap | ✓ iOS 16.1+ | — | live_activity |
| Lock screen Live Activity | ✓ iOS 16.1+ | — | live_activity |
| Notification action button | ✓ UNNotificationAction | ✓ NotificationCompat.Action | notification |
| Notification body tap | ✓ | ✓ | notification_tap |
| iOS Spotlight | ✓ NSUserActivity | — | spotlight |
| iOS Control Center | ✓ iOS 18+ ControlWidget | — | control_center |
| Android Quick Settings | — | ✓ TileService | quick_tile |
| NFC tag tap | ✓ | ✓ | nfc |
| QR code scan | ✓ | ✓ | qr |
| Universal Link / App Link | ✓ | ✓ | deep_link |
| Custom URL scheme | ✓ | ✓ | deep_link |
| Share sheet | ✓ UIActivityViewController | ✓ Intent.ACTION_SEND | share |
| Apple Handoff | ✓ NSUserActivity | — | handoff |
| CarPlay | ✓ (phrases work automatically) | — | carplay |
| Android Auto | — | ✓ (via App Actions) | android_auto |
| Shortcuts automation | ✓ (automatic via App Intents) | — | automation |
| Siri Suggestions | ✓ via useDonation() | — | siri |
| watchOS tap/voice | ✓ WCSession | ✓ Wear Data Layer | watch |
License
MIT — SmartHive Labs
