npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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

| 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-invoke

Then 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:android

EAS 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 android

A 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 development to 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 --local

Quick 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. shortTitle powers the long-press app icon Quick Actions menu — add up to 4 intents with shortTitle to 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-render

ResolvedIntent 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. useInvokeInit reads it on mount.
  • Warm start — app was backgrounded; intent fires as a native event. useInvokeInit subscribes and routes it to useInvoke.
// 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": [...]
}]

appGroupId is 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 a hideAsync() call in your root _layout.tsx — not just in index.tsx. When the app opens from a widget tap, Expo Router routes directly to the deep-link destination screen. index.tsx is never mounted, so any hideAsync() call inside it is never reached. The native splash stays on screen forever.

Calling hideAsync() twice (once in _layout.tsx and once in index.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.tsxmyapp://orders
  • myapp://errands with no app/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:ios

The 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 generate to generate a typed invoke-types.d.ts so 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 as source: '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 test

validate

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 TTL

simulateWarmStart

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