Wearables-level stream: you do not add a capability to a session, and you do not need a running device session. This matches how Session lifecycle describes “Hey Meta” — a short, system-owned transaction rather than a sustained device session.Use https://wearables.developer.meta.com/docs/develop/dat/voice-invocations/, then use the Wearables MCP endpoint https://mcp.developer.meta.com/wearables to call search_dat_docs for current Device Access Toolkit voice invocation guidance. Inspect my app entry points first, then add the smallest change that detects a voice-triggered launch, starts the voice invocations stream, handles every invocation type exhaustively, and answers every invocation through its response handle exactly once. Do not assume a device session is required, and run the relevant local checks.
Info.plist, and voice invocations do not require a CAMERA or MICROPHONE permission request. Whether invocations reach your app is determined entirely by your Developer Center configuration.VoiceInvocationError values on stream.errors:| Error | Cause |
|---|---|
CHANNEL_NOT_CONNECTED | The voice invocation channel is not connected. |
CHANNEL_ERROR | An unspecified channel error occurred. |
FAIL_TO_SEND_INIT_REQUEST | The init request could not be sent. |
INIT_REQUEST_ERROR | The init request returned an error. |
FAIL_TO_SEND_MESSAGE | A message could not be sent. |
INVALID_ACTION_MESSAGE_PROTO | A malformed action message was received. |
UNKNOWN_MESSAGE_TYPE | An unrecognized message type was received. |
ACTION_FAILED | An action failed. |
INVALID_SESSION_STATE | An operation was attempted in an invalid session state. |
invalidWearablesInterface and deviceNotFound. Three iOS cases are both thrown and publishable — invalidWearablesInterface from the initializer, and deviceNotFound and channelNotConnected from start(deviceIdentifier:). On Android the equivalent conditions surface only on errors, because neither the stream factory nor its automatic start throws.INVALID_ACTION_MESSAGE_PROTO on the error stream and does not emit a bogus invocation.com.meta.wearable.dat.core.voiceinvocations:import com.meta.wearable.dat.core.Wearables import com.meta.wearable.dat.core.selectors.AutoDeviceSelector import com.meta.wearable.dat.core.voiceinvocations.VoiceInvocationsStream import com.meta.wearable.dat.core.voiceinvocations.isVoiceInvocationsIntent import com.meta.wearable.dat.core.voiceinvocations.startVoiceInvocationsStream import com.meta.wearable.dat.core.voiceinvocations.types.actions.LaunchApp
onCreate and onNewIntent — an already-running app is foregrounded through onNewIntent, not onCreate.override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (isVoiceInvocationsIntent(intent)) {
startYourExperience()
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
if (isVoiceInvocationsIntent(intent)) {
startYourExperience()
}
}
isVoiceInvocationsIntent(intent) returns true only for intents originating from the Meta AI app as a result of a voice command. Do not try to reconstruct the check yourself from intent extras.val inside onCreate goes out of scope before you can close it.private lateinit var stream: VoiceInvocationsStream
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
stream = Wearables.startVoiceInvocationsStream(AutoDeviceSelector())
}
start() call. If the automatic start fails, the failure is logged and surfaces on errors and state rather than throwing. The optional second parameter is the CoroutineDispatcher used for background work, defaulting to the SDK’s IO dispatcher.lifecycleScope.launch {
stream.invocations.collect { invocation ->
when (invocation) {
is LaunchApp -> {
navigateToMainScreen()
invocation.responseHandle.sendSuccess(null)
}
}
}
}
sendSuccess and sendFailure are suspend functions returning Boolean: true when the response was delivered. Both take an optional actionOutput string.lifecycleScope.launch {
stream.state.collect { state -> updateUi(state) }
}
lifecycleScope.launch {
stream.errors.collect { error -> log(error.description) }
}
SessionState has three values:| State | Meaning |
|---|---|
STARTING | The stream is connecting. |
STARTED | The stream is listening for invocations. |
STOPPED | Not started, or no active devices are connected. |
override fun onDestroy() {
stream.close()
super.onDestroy()
}
VoiceInvocationsStream is Closeable. Close it when you stop caring about invocations; the stream’s lifecycle is owned by you, not by the SDK.MockGlasses.services.voiceInvocation.val glasses = mockKit.pairGlasses(GlassesModel.RAYBAN_META).getOrThrow()
val voiceInvocation = glasses.services.voiceInvocation
if (voiceInvocation.hasConnectedApps()) {
voiceInvocation.simulateLaunchAppAction()
}
simulateLaunchAppAction() returns the request ID of the dispatched action, or null when no app is connected — so a null return means your app was not listening, not that the invocation failed.simulateIncompleteAction() to dispatch a deliberately malformed action and confirm your app surfaces the error rather than acting on a bogus invocation.import MWDATCore
@MainActor
final class VoiceInvocationsController {
private var stream: VoiceInvocationsStream?
private var invocationToken: (any AnyListenerToken)?
private var errorToken: (any AnyListenerToken)?
func startListening(on deviceIdentifier: DeviceIdentifier) throws {
let stream = try VoiceInvocationsStream(wearables: Wearables.shared)
addListeners(to: stream) // Steps 2 and 3
try stream.start(deviceIdentifier: deviceIdentifier)
self.stream = stream
}
}
start(deviceIdentifier:) so an invocation that arrives immediately is not missed.invalidWearablesInterface if the SDK is not configured. start(deviceIdentifier:) throws deviceNotFound when no connected device matches, or channelNotConnected when the device has no active connection.start(deviceIdentifier:) again tears down the previous stream first, so it is the way to switch to another device. To cover devices that connect after launch, call it for each device in wearables.devices and then for each update from wearables.devicesStream().VoiceInvocationsStream is @MainActor-isolated.any VoiceInvocation, so downcast to the concrete type.invocationToken = stream.invocationsPublisher.listen { invocation in
guard let launchApp = invocation as? LaunchApp else { return }
navigateToMainScreen()
Task {
_ = await launchApp.responseHandle.sendSuccess(actionOutput: nil)
}
}
LaunchApp also carries the deviceIdentifier of the glasses that produced the invocation.errorToken = stream.errorPublisher.listen { error in log(error.description) }
start(deviceIdentifier:) threw, plus the error publisher.stream?.stop()
stop() is safe to call when no stream is active. After stopping, call start(deviceIdentifier:) again to listen once more.MockGlasses.services.voiceInvocation.let voiceInvocation = mockGlasses.services.voiceInvocation
if voiceInvocation.hasConnectedClients {
_ = voiceInvocation.sendLaunchAppAction()
}
sendLaunchAppAction() returns the request ID of the dispatched action, or nil when no app is connected — so a nil return means your app was not listening, not that the invocation failed.sendIncompleteAction() to dispatch a deliberately malformed action and confirm your app surfaces the error rather than acting on a bogus invocation.| Behavior | Android | iOS |
|---|---|---|
Creating the stream | Wearables.startVoiceInvocationsStream(deviceSelector, dispatcher) | VoiceInvocationsStream(wearables:) |
Starting | Automatic on creation | Explicit start(deviceIdentifier:) |
Device targeting | DeviceSelector | A specific DeviceIdentifier |
Session state | state: StateFlow<SessionState> | Not exposed |
Failure reporting at start | Logged, then surfaced on errors | Thrown from init and start(deviceIdentifier:) |
Invocation type modeling | sealed interface; when is exhaustive | protocol; downcast with as? |
deviceIdentifier on the invocation | Not present | Present on LaunchApp |
Teardown | close() (Closeable) | stop(), restartable |
Detecting a voice launch | isVoiceInvocationsIntent(intent) | Not applicable |