Build a voice assistant
Build a wake-word voice assistant on Meta Ray-Ban smart glasses with glasses.assistant.start. A phone-side wake phrase opens the conversation; the model owns turn-taking and intent parsing; you write tool bodies that read and act on your app's state, add a vision tool for "what am I looking at", and let the conversation sleep on intent. Runs on the Extentos managed gateway with no API key in your app.
Camera needs the Meta vendor module. com.extentos:glasses carries no
vendor SDK, so add implementation("com.extentos:glasses-meta") alongside it —
see install §1. Without it your build still
succeeds and voice still works, but capabilities.camera is false and every
capture returns an error. The SDK logs a warning at startup when it spots that
combination.
This guide builds a working voice assistant: a wake phrase opens a conversation, the model calls tools that read and mutate your app's own state (route stats, a clip library, the camera), a vision tool answers "what am I looking at," and the conversation sleeps when the user is done. A phone-side wake phrase (on-device recognition) opens the conversation; the model handles turn-taking, intent parsing, and confirmation speech — you only write the tool bodies.
For the full API and the wake/sleep state machine, read the assistant runtime first. This page is the task walkthrough.
glasses.assistant.*ships on both platforms — incom.extentos:glassessince 1.4.0 (SDK install) and in theExtentosSwift package — a standard dependency, no snapshot setup. Kotlin leads the snippets here, with the Swift counterpart in a fold under each. The fields and behaviour are the same on both; a handful of spellings differ, and those are called out where they appear rather than left for you to discover at the compiler.
1. Bootstrap
The assistant needs the microphone, and RECORD_AUDIO is a runtime permission your app owns — the library never requests it for you. So the order matters, and it is the part people get wrong:
Create the ExtentosGlasses handle in Application.onCreate, but start the assistant only after the grant. Creating the handle early is fine and cheap; starting a session before the grant leaves you with an assistant that is running and deaf.
class MyApp : Application() {
lateinit var glasses: ExtentosGlasses
override fun onCreate() {
super.onCreate()
// Cheap: resolves the transport, wires the clients. No mic yet.
// No OpenAI key anywhere — the assistant runs on the Extentos managed
// gateway (there is no option to run it on your own OpenAI account).
glasses = ExtentosGlasses.create(ExtentosConfig(applicationContext = this))
}
}class MainActivity : ComponentActivity() {
private val micPermission = registerForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted -> if (granted) onMicGranted() }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) {
onMicGranted()
} else {
micPermission.launch(Manifest.permission.RECORD_AUDIO)
}
}
private fun onMicGranted() {
// Only now: the foreground service (so the mic survives backgrounding)
// and the assistant handler.
GlassesForegroundService.start(this)
// Your handler — step 2 defines it. routeTracker / library are your own app state.
RunAssistantHandler((application as MyApp).glasses, routeTracker, library).start()
}
}Do not start the assistant or the foreground service from Application.onCreate.
On first launch the grant cannot exist that early, and Android 14+ rejects a
microphone-type foreground service started without it. See
lifecycle.
If you render the packaged ExtentosConnectionPage it handles this flow for you as part of pairing — but a voice-only app has no connection page, so the sequence above is yours to write.
The same bootstrap in Swift
iOS needs no foreground service — UIBackgroundModes: ["audio"] in your Info.plist covers background listening. Two things differ from Android and both matter:
usedCapabilitiespicks the transport. Leave it empty and.autoresolves to real glasses, so a voice app dies withNoEligibleDeviceon a phone with none. Declare[.microphone, .speaker].- The microphone grant is yours.
requestSpeechRecognitionAuthorization()covers speech recognition only, not audio capture.
import AVFAudio
import GlassesCore
import SwiftUI
@main
struct MyApp: App {
let glasses: any ExtentosGlasses
init() {
glasses = Extentos.create(config: ExtentosConfig(
usedCapabilities: [.microphone, .speaker]
))
// `debug` is left at its false default — setting it true opens a pending
// browser-simulator socket and waits for the MCP bridge to bind it.
}
var body: some Scene {
WindowGroup { ContentView(glasses: glasses) }
}
}func startAssistant(_ glasses: any ExtentosGlasses) async {
// AVAudioApplication is iOS 17+; on an iOS 16 target use
// AVAudioSession.sharedInstance().requestRecordPermission { … } instead.
let mic = await AVAudioApplication.requestRecordPermission()
let speech = await Extentos.requestSpeechRecognitionAuthorization()
guard mic, speech else { return } // surface your own explanation
await RunAssistantHandler(glasses: glasses).start()
}If your app also uses the camera
The tools, the session block and the wake phrase are unchanged. The bootstrap is not — one thing moves and two things are added, all consequences of the same fact: TransportChoice.Auto picks the transport inside create(...) and keeps it for the life of the handle. Whatever is true at that moment is what your app gets.
What moves: create(...) leaves Application.onCreate. In the voice bootstrap above it can run at app start, because nothing it depends on needs a grant. A camera app can't do that — Auto needs BLUETOOTH_CONNECT in hand to see the glasses, and on first launch no grant exists that early. So the handle becomes lateinit and is built from the permission callback instead. Keeping the eager create() is the mistake this section exists to prevent: it resolves to the audio baseline, capture returns errors forever, and nothing throws.
1. Grant BLUETOOTH_CONNECT before create(...), not after. The Meta transport claims the session by checking whether Meta glasses are bonded to the phone, and reading the bonded set needs BLUETOOTH_CONNECT on Android 12+. Without the grant the check can't answer, Auto moves down to the audio baseline, and capabilities.camera reports false for the rest of the process — voice works, capture doesn't. Restarting the app after the grant fixes it, but you don't want the first run to be the broken one.
2. Give create(...) an activityProvider. Meta's registration handoff needs a live Activity to present its consent UI, and the SDK requests BLUETOOTH_CONNECT and CAMERA at runtime through that same Activity. It defaults to null, and with no connection page nothing else supplies one.
class MyApp : Application() {
lateinit var glasses: ExtentosGlasses
private val topActivity = java.util.concurrent.atomic.AtomicReference<Activity?>()
override fun onCreate() {
super.onCreate()
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
override fun onActivityResumed(activity: Activity) { topActivity.set(activity) }
override fun onActivityPaused(activity: Activity) {
if (topActivity.get() === activity) topActivity.set(null)
}
// remaining callbacks: no-op
override fun onActivityCreated(a: Activity, b: Bundle?) {}
override fun onActivityStarted(a: Activity) {}
override fun onActivityStopped(a: Activity) {}
override fun onActivitySaveInstanceState(a: Activity, b: Bundle) {}
override fun onActivityDestroyed(a: Activity) {}
})
}
/** Called from the Activity once the permissions below are granted. */
fun initGlasses() {
if (::glasses.isInitialized) return
glasses = ExtentosGlasses.create(
ExtentosConfig(
applicationContext = this,
activityProvider = { topActivity.get() },
environment = if (BuildConfig.DEBUG) ExtentosEnvironment.DEVELOPMENT
else ExtentosEnvironment.PRODUCTION,
)
)
}
}class MainActivity : ComponentActivity() {
private val permissions = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) { result ->
// Create only after the answer, whatever it was — a denied BLUETOOTH_CONNECT
// still resolves correctly (to the audio baseline), it just isn't a surprise.
val app = application as MyApp
app.initGlasses()
Log.i("Extentos", "transport=${app.glasses.transportChosen} camera=${app.glasses.capabilities.camera}")
if (result[Manifest.permission.RECORD_AUDIO] == true) onMicGranted()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
permissions.launch(
arrayOf(
Manifest.permission.RECORD_AUDIO,
Manifest.permission.CAMERA,
Manifest.permission.BLUETOOTH_CONNECT,
)
)
}
private fun onMicGranted() {
GlassesForegroundService.start(this)
// Your handler — step 2 defines it.
RunAssistantHandler((application as MyApp).glasses, routeTracker, library).start()
}
}Then assert what you got, once, at startup — this is the check that turns a silent capability loss into a one-line log:
Log.i("Extentos", "transport=${glasses.transportChosen} camera=${glasses.capabilities.camera}")A camera app expects REAL_META / true. SYSTEM_AUDIO / false means Auto fell through to the audio baseline — either the com.extentos:glasses-meta dependency is missing, the glasses aren't bonded, or BLUETOOTH_CONNECT wasn't granted before create(...).
With the base dependency there are no model files or model paths to manage — the assistant is available immediately and runs end-to-end over the gateway's WebSocket. (On-device models are an opt-in you add deliberately: two extra modules and a register() call. See local models.) See the managed gateway for the AI plumbing.
2. Start the session and register tools
The whole assistant is one block. Declare instructions, register tools, and pick a greeting. Leave the provider as AssistantProvider.Managed() to take your dashboard's model + voice (defaults gpt-realtime-2 / alloy).
import com.extentos.glasses.core.ExtentosGlasses
import com.extentos.glasses.core.RuntimeEvent
import com.extentos.glasses.core.VideoClip
import com.extentos.glasses.core.VideoConfig
import com.extentos.glasses.core.valueOrNull
import com.extentos.glasses.core.assistant.AssistantEvent
import com.extentos.glasses.core.assistant.AssistantError
import com.extentos.glasses.core.assistant.AssistantException
import com.extentos.glasses.core.assistant.AssistantProvider
import com.extentos.glasses.core.assistant.AssistantSession
import com.extentos.glasses.core.assistant.Greeting
import com.extentos.glasses.core.assistant.ToolResult
import com.extentos.glasses.core.assistant.tool
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlin.time.Duration.Companion.seconds
class RunAssistantHandler(
private val glasses: ExtentosGlasses,
private val routeTracker: RouteTracker, // your app state
private val library: ClipLibrary, // your app state
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
) {
private var session: AssistantSession? = null
private var activeVideo: Deferred<*>? = null
fun start() {
scope.launch {
session = glasses.assistant.start(provider = AssistantProvider.Managed()) {
instructions = """
You are a running companion. Help with route stats and capture
moments. Speak briefly — they're running. Don't narrate what
you're doing; just do it and confirm. When the user clearly
wants to stop talking, call end_conversation.
""".trimIndent()
// The SDK speaks this greeting automatically on every wake,
// generated out-of-band from memory (never continues the prior chat).
greeting = Greeting.Custom(
"Greet the runner warmly in one short sentence and ask how you can help."
)
// Deterministic backup sleep after 30s of user silence.
sleepAfterSilence(30.seconds)
// Read tools — instant data the model reads aloud.
tool("get_route_remaining", "How much of the planned route is left, in km.") {
ToolResult.Ok("${routeTracker.kmRemaining()} km remaining")
}
tool("get_average_pace", "The runner's current average pace in minutes per km.") {
ToolResult.Ok("${routeTracker.avgPaceMinKm()} min per km")
}
// Action tools — side effects on your own state. The model
// manages the take/stop pair from conversational context.
tool("take_video", "Start recording a video clip of the runner's view.") {
if (activeVideo?.isActive == true) {
return@tool ToolResult.Err("a recording is already in progress")
}
activeVideo = scope.async {
glasses.camera.captureVideo(VideoConfig(maxDurationSeconds = 30))
}
ToolResult.Ok("recording started")
}
tool("stop_video", "Stop the current video recording.") {
val capture = activeVideo ?: return@tool ToolResult.Err("nothing was recording")
activeVideo = null
glasses.camera.stopVideo() // resumes the await naturally
@Suppress("UNCHECKED_CAST")
val clip = (capture as Deferred<com.extentos.glasses.core.ExtentosResult<VideoClip, *>>)
.await().valueOrNull() ?: return@tool ToolResult.Err("video capture failed")
library.add(clip)
ToolResult.Ok("video saved")
}
}
// Wake mechanism — the same voice-trigger system as everywhere.
// Defaults to VoiceScope.WhenDormant so it won't double-fire mid-chat.
glasses.voice.onPhrase("hey coach") { session?.wake() }
}
}
}assistant.start(...) throws — it's the exception to the "results never
throw" rule. Capability calls return ExtentosResult, but an open-time
failure raises AssistantException wrapping an AssistantError. In the bare
scope.launch { … } above that would kill the coroutine and take the app with
it, and the most common trigger is the ordinary one: a sideloaded build
reaching the gateway without a baked project key gives you NoApiKey. Wrap it:
session = try {
glasses.assistant.start(provider = AssistantProvider.Managed()) { /* … */ }
} catch (e: AssistantException) {
Log.e("MyApp", "assistant didn't start: ${e.error}", e)
return@launch // surface it in your UI; don't let it propagate
}AssistantException and AssistantError live in
com.extentos.glasses.core.assistant. Variants and their meanings are in the
error reference.
The same session in Swift
Same fields, same behaviour — four spellings differ. The config block is a closure taking the builder (so $0.), sleepAfterSilence takes a TimeInterval in seconds rather than a Duration, tool results are .ok / .err, and tool labels its description.
import GlassesCore
final class RunAssistantHandler {
private let glasses: any ExtentosGlasses
private var session: (any AssistantSession)?
init(glasses: any ExtentosGlasses) { self.glasses = glasses }
func start() async {
do {
session = try await glasses.assistant.start(
provider: .managed() // .managed(model: "local-auto") to run on-device
) {
$0.instructions = """
You are a running companion. Help with route stats and capture
moments. Speak briefly — they're running.
"""
$0.greeting = .custom(directive: "Greet in one short sentence.")
$0.sleepAfterSilence(30) // seconds, not a Duration
$0.endOnIntent = true
$0.tool("log_split", description: "Record a lap split for the current run.") {
.ok("split recorded")
}
}
} catch let error as AssistantError {
// start() throws on iOS too. .noApiKey on a sideloaded build means the
// gateway couldn't identify the caller — see the Info.plist project key.
print("assistant didn't start: \(error)")
return
} catch {
return
}
// No `firesWhen` on Swift — the wake phrase can fire mid-conversation,
// so gate re-wakes yourself if that matters.
_ = glasses.voice.onPhrase(phrase: "hey coach", label: "Wake", stops: []) { [weak self] in
try? await self?.session?.wake()
}
}
}A vision tool is the same shape; note includeImage labels both arguments and prompt has no default on Swift:
$0.tool("describe_scene", description: "Describe what the user is looking at.") { [weak self] in
guard let self else { return .err("handler went away") }
let result = await self.glasses.camera.capturePhoto()
guard case .success(let photo) = result, let uri = photo.uri else {
return .err("couldn't take a photo right now")
}
try? await self.glasses.assistant.activeSession?.includeImage(uri: uri, prompt: nil)
return .ok("looking")
}A few things the model does for you here, that you'd otherwise hand-wire: it decides which tool to call from each description, speaks a "let me check…" filler while a tool runs (suppress per-tool with blocking = true), and ends the conversation via the hidden end_conversation tool (endOnIntent defaults true) when the user wraps up — no rigid "goodbye" phrase required.
Clean stop pattern. To stop the recording, call
glasses.camera.stopVideo()andawait()the in-flight capture — it resumes with a partial clip. Don'tcancel()theDeferred: Kotlin's cancelled state is sticky andawait()would throw even when a partial exists.
3. Add a vision tool
session.includeImage(uri) adds a photo to the conversation and auto-triggers a spoken response in the model's voice. Reach the running session from a tool body via glasses.assistant.activeSession (non-null while a tool dispatches). Keep "describe" and "save" as two tools so the model can disambiguate — and call both back-to-back when the user wants both.
import com.extentos.glasses.core.assistant.orToolError
tool(
"describe_scene",
"Describe what the runner is looking at without saving the photo. " +
"Call for 'what do you see' / 'describe this' / 'tell me about this'.",
) {
val photo = glasses.camera.capturePhoto().orToolError { return@tool it }
val uri = photo.uri ?: return@tool ToolResult.Err("photo had no uri")
glasses.assistant.activeSession?.includeImage(uri)
ToolResult.Ok("looking")
}orToolError { return@tool it } unwraps the capture, and on failure short-circuits the tool with a ToolResult.Err that carries the failure's actionable message — so the model relays a real reason instead of a generic "camera failed". If the wearer paused the camera (a temple tap), the model hears "tap the right temple of your glasses to resume the camera" and can tell the user exactly that. It's a thin helper over the typed CaptureError; pattern-match the ExtentosResult yourself if you want custom copy.
Camera tools need the simulator browser tab attached when you test them — see step 5.
4. Capture transcripts (optional)
If you want the user's words for notes, captions, or a journal, subscribe to the shared runtime-event stream. Assistant events carry verbatim transcripts (yours to govern; document retention in your privacy policy).
glasses.runtime.events
.filterIsInstance<RuntimeEvent.Assistant>()
.onEach { evt ->
(evt.event as? AssistantEvent.UserSpoke)?.let { notes.append(it.transcript) }
}
.launchIn(scope)5. Test it in the simulator
The agent-driven loop closes without a human or hardware. Drive the wake with the real wake path, then inject a user utterance, then assert the tool fired:
// Wake exactly like a real user saying the phrase:
await injectTranscript({ sessionId, text: "hey coach" });
// ... wait for assistant.session_started in getEventLog(filter: "voice"), then:
const inj = await injectAssistantUtterance({ sessionId, text: "how much further?" });
await assertToolCalled({ sessionId, name: "get_route_remaining", sinceCursor: inj.watchCursor });injectAssistantUtterancedrives both theMockprovider (deterministic, $0) and the realOpenAiprovider with the same call — pick via a build flavor, neveruseMockin handler code.- The session must be Active when you inject — wake it first and watch for
assistant.session_started. - Always thread
inj.watchCursorintoassertToolCalled({ sinceCursor })— the model fires the tool 0.5–2 s later, and anchoring the wait before the inject avoids a false "tool not called." - Camera/vision tools (
describe_scene) need the browser tab attached — callensureSimulatorBrowser({ sessionId })first, and budget a longertimeoutMs(camera + upload + reasoning).
Lifecycle and transcripts land on the simulator's voice event-log chip (an assistant error climbs to errors). The ai chip is only for customer-side BYOK calls wrapped in glasses.observability.aiCall(...) — don't watch it for assistant events. See the MCP tools reference for the full agent-test surface.
Sim fidelity. The simulator runs the same library code as production with only the transport and audio path swapped, so it's designed to behave identically to hardware. That fidelity is under active validation on Android right now — treat the sim as the iteration loop and a real-hardware run as the final gate, not a guarantee.
Where to go next
- Render on the glasses screen. On a Ray-Ban Display, a tool can show results visually instead of (or alongside) speaking — see render on the display; gate on
glasses.display.isAvailable. - Tune the model.
AssistantProvider.Managed(reasoningEffort = …), mid-sessionsetReasoningEffort/updateInstructions, andhistoryCompactionfor long conversations — all in the assistant runtime. - Persist memory across sessions.
persistentMemory = true(opt-in, consent-gated) — see memory.
Related
The assistant runtime
Build a voice assistant on smart glasses with glasses.assistant — wake/sleep, tools, vision, barge-in, memory, on the managed AI gateway. Kotlin and Swift.
The managed AI gateway
How AI runs in an Extentos app — the assistant routes voice AI through the managed gateway. Content relayed, never stored; metered. The assistant always runs on the managed gateway; there is no bring-your-own-key option.
Voice triggers
Wire a voice command on the glasses to an action in your app. Works on Meta Ray-Ban via the phone's speech recognizer over Bluetooth. Phrases auto-surface on the connection page and the simulator's click-to-fire panel.
Render on the display
Render UI on glasses with a screen — photo capture, browsable card lists via glasses.display, and full-surface views. One tree, three vendors: Meta Ray-Ban Display, Android XR Display Glasses, and Brilliant Labs Halo and Frame.
Capabilities
The Extentos capability vocabulary — the vendor-agnostic SDK primitives (audio, camera, voice, assistant, display, hardware events) your handler subscribes to.
Voice triggers
Wire a voice command on the glasses to an action in your app. Works on Meta Ray-Ban via the phone's speech recognizer over Bluetooth. Phrases auto-surface on the connection page and the simulator's click-to-fire panel.
Capture a photo
Take a still photo on the Meta Ray-Ban camera from your handler code, handle CaptureError cleanly, and pipe the image into a vision LLM. Uses glasses.camera.capturePhoto() and the Photos URI helpers.