'Hey Siri, Join My Standup': Adding Voice-Activated Call Joining to an iOS Video App With App Intents

App Intents quietly turned Siri from a party trick into a real front door for your app.
Add Voice-Activated Call Joining With App Intents

You are WFH and your daily standup starts at 9:30. In one hand, you have your morning coffee. The other, a toddler who is refusing to wear pants. Wouldn't it be nice to just tell your phone to join the call?

In this tutorial, you'll build exactly that: SwiftUI video calling that joins a Stream Video call by voice, using Apple's App Intents framework.

Say "join my standup" to Siri, and you land directly in a running video call. No unlocking the phone, no finding the app, no tapping through the UI.

The complete project is around 200 lines of Swift. You'll need Xcode 26, a physical iPhone (Siri and App Shortcuts behave differently on the simulator), and a free Stream account.

Setting Up the Project

Create a new iOS App project in Xcode (SwiftUI, Swift). Ours is called streamSiriCall. Then add the Stream Video SDK via Swift Package Manager: File -> Add Package Dependencies and paste:

https://github.com/GetStream/stream-video-swift

Then, you need to add both StreamVideo and StreamVideoSwiftUI products to your app target. The first is the calling engine, and the second is the drop-in call UI, which saves us from building participant tiles, call controls, and picture-in-picture ourselves.

Credentials

For this demo, we'll hardcode the Stream API key, a user, and a token in a small config. Grab your API key from the Stream dashboard, and generate a development token for your demo user with the Stream CLI:

getstream token siri-demo-user

Then we can use that token in our Swift config file:

// Config.swift
import Foundation

enum Config {
    static let apiKey = "your_api_key"

    static let userId = "siri-demo-user"
    static let userName = "Siri Demo User"

    /// Demo-only, non-expiring user token for `userId`.
    /// In production, never ship a static token: fetch short-lived tokens from your
    /// backend via the SDK's `tokenProvider` so they can be refreshed and revoked.
    static let userToken = "your_user_token"

    static let callType = "default"
    static let callId = "standup"
}

A call in Stream is identified by its type plus its ID. The type (default here) is a preconfigured bundle of settings and permissions suited to standard video calls, and the ID is the room itself. Any client that joins default/standup ends up in the same call, which is why a hardcoded ID works fine for a demo.

Static tokens are for tutorials. The token is what authenticates your user to Stream, so when you build a real app, you should pass a tokenProvider closure to the SDK and mint short-lived tokens server-side, where they can be refreshed and revoked.

Creating the Client

The StreamVideo client should exist exactly once. SwiftUI's App is a value type the framework can recreate, so rather than constructing the client inside the app struct, we keep it in a small static holder with a connect helper:

// VideoClient.swift
import Foundation
import StreamVideo

/// Holds the single `StreamVideo` instance for the app.
enum VideoClient {
    static let streamVideo = StreamVideo(
        apiKey: Config.apiKey,
        user: User(id: Config.userId, name: Config.userName),
        token: UserToken(rawValue: Config.userToken)
    )

    /// Connects the user once at app launch.
    static func connectIfNeeded() async throws {
        try await streamVideo.connect()
    }
}

StreamVideo is the low-level client. It holds the user's identity and manages the connection to Stream's backend, and connect() is what actually authenticates and opens that connection. Everything else in the app, including the prebuilt UI, sits on top of this one object.

Building the SwiftUI Video Calling App

The app entry point wraps the client in StreamVideoUI (which wires up the SDK's SwiftUI layer) and connects the user on launch:

// streamSiriCallApp.swift
import SwiftUI
import StreamVideo
import StreamVideoSwiftUI

@main
struct streamSiriCallApp: App {
    // `App` is a value type SwiftUI can recreate, so the SDK wrapper must live in @State.
    @State private var streamVideoUI: StreamVideoUI

    init() {
        _streamVideoUI = State(wrappedValue: StreamVideoUI(streamVideo: VideoClient.streamVideo))
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
                .task {
                    do {
                        try await VideoClient.connectIfNeeded()
                    } catch {
                        print("Stream connect failed: \(error)")
                    }
                }
        }
    }
}

Two things are happening here:

  1. StreamVideoUI wraps our client and sets up the SDK's SwiftUI layer, so the prebuilt views later on know where to find it.
  2. SwiftUI treats the App struct as a value it can recreate whenever it likes, so the wrapper lives in @State, which survives those recreations.

The .task connects the user as soon as the window appears; in a demo, printing the error is all the failure handling we need.

ContentView is deliberately minimal. It's a manual join button plus one modifier that brings in the entire call UI:

// ContentView.swift
import SwiftUI
import StreamVideo
import StreamVideoSwiftUI

struct ContentView: View {
    @StateObject private var callViewModel = CallViewModel()

    var body: some View {
        VStack(spacing: 20) {
            Image(systemName: "video.fill")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text("Stream Siri Call")
                .font(.title2)
                .bold()
            Button {
                callViewModel.joinCall(callType: Config.callType, callId: Config.callId)
            } label: {
                Label("Join \u{201C}\(Config.callId)\u{201D}", systemImage: "phone.arrow.up.right.fill")
                    .padding(.horizontal, 8)
                    .padding(.vertical, 4)
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
        .modifier(CallModifier(viewModel: callViewModel))
        .alert("Call error", isPresented: $callViewModel.errorAlertShown) {
            Button("OK", role: .cancel) { callViewModel.error = nil }
        } message: {
            Text(callViewModel.error?.localizedDescription ?? "")
        }
    }
}

CallViewModel is the SDK's observable object for call state. It knows how to join and leave calls, tracks participants, and publishes errors, which is what the alert at the bottom listens for. Tapping the button just asks it to join the hardcoded standup call.

CallModifier does the interesting work here. It watches that view model, and whenever a call becomes active, it presents Stream's full call experience over your view: video tiles, mute and camera controls, the participant list. When the call ends, it steps back out of the way. You write none of that.

Finally, because this is a calling app, Info.plist needs microphone and camera usage descriptions, and we declare the audio background mode so an ongoing call keeps its audio session when backgrounded:

<key>UIBackgroundModes</key>
<array>
    <string>audio</string>
</array>
<key>NSCameraUsageDescription</key>
<string>streamSiriCall requires camera access in order to capture and transmit video</string>
<key>NSMicrophoneUsageDescription</key>
<string>streamSiriCall requires microphone access in order to capture and transmit audio</string>

The two usage strings are the text iOS shows in the permission prompts the first time the app asks for the camera and microphone.

Run the app and tap the button to confirm the video works before we add Siri.

Stream Siri Call app pre-call screen with a "Join standup" button
Active Stream video call showing a participant tile labeled Siri Demo User
Building your own app? Get access to our Livestream or Video Calling API and launch in days!

Adding the App Intent

App Intents is Apple's framework for exposing app actions to the system. Siri, Shortcuts, Spotlight, and (increasingly) Apple Intelligence all consume the same definitions. An intent is a struct: metadata, parameters, and an async perform() method.

Here's ours:

// JoinCallIntent.swift
import AppIntents

struct JoinCallIntent: AppIntent {
    static let title: LocalizedStringResource = "Join Call"
    static let description = IntentDescription("Joins a Stream video call by ID.")

    static let openAppWhenRun = true

    @Parameter(title: "Call ID", default: "standup")
    var callId: String

    func perform() async throws -> some IntentResult & ProvidesDialog {
        // The system has already foregrounded the app; let the UI own the
        // join so the standard call screen appears.
        let id = callId
        await MainActor.run { PendingJoin.shared.callId = id }
        return .result(dialog: "Opening your \(callId) call.")
    }
}

Three details to notice:

  1. openAppWhenRun = true tells iOS to bring your app to the foreground before calling perform(). By the time the intent runs, the UI exists and can take over the join.
  2. @Parameter makes the call ID a first-class input. In the Shortcuts app, users can wire any call ID into it; via Siri, it falls back to the default (standup).
  3. The dialog in the result is what Siri says out loud. "Opening your standup call." is literally the reply you'll hear.

The intent's only job is to hand the call ID to the UI, and the handoff is a small observable object. The intent and the view never reference each other directly; they just share this one published property. Since PendingJoin feeds the UI, it's confined to the main actor, which is why perform() hops over with MainActor.run to set it:

// PendingJoin.swift
import Combine
import Foundation

/// Hands a call ID from `JoinCallIntent` to `ContentView`, which
/// performs the join once visible.
@MainActor
final class PendingJoin: ObservableObject {
    static let shared = PendingJoin()
    @Published var callId: String?
}

ContentView subscribes and joins the moment a pending ID arrives. It's one extra modifier on the view we already built:

.onReceive(PendingJoin.shared.$callId) { pendingCallId in
    // JoinCallIntent opened the app and left us a call ID to join.
    guard let pendingCallId else { return }
    PendingJoin.shared.callId = nil
    callViewModel.joinCall(callType: Config.callType, callId: pendingCallId)
}

Clearing the value right after reading it matters. @Published replays its current value to new subscribers, so a stale ID left in place could re-join the call if the view ever re-subscribes. Setting it back to nil makes the handoff one-shot.

Because the app is frontmost when the join happens, the audio session activates normally, and permission prompts can appear if needed. A call needs a foreground app to live in, so opening the app first is the right pattern for any voice-initiated join. The total voice-to-video time is a second or two.

Registering Siri Phrases

An intent alone only shows up in the Shortcuts app. To get spoken Siri phrases with no user setup, register App Shortcuts:

// AppShortcuts.swift
import AppIntents

struct StreamSiriCallShortcuts: AppShortcutsProvider {
    static var appShortcuts: [AppShortcut] {
        AppShortcut(
            intent: JoinCallIntent(),
            phrases: [
                "Join my \(.applicationName)",
                "Start my \(.applicationName)",
                "Join the standup in \(.applicationName)",
                "Start the standup in \(.applicationName)"
            ],
            shortTitle: "Join Standup",
            systemImageName: "video.fill"
        )
    }
}

The four phrasings give Siri more natural language to match against. They're static strings baked in at compile time, so pick your variations up front; you can't build them at runtime. shortTitle and systemImageName are the tile Shortcuts and Spotlight show for the action.

Every phrase must include \(.applicationName). Apple enforces this so apps can't squat on generic phrases like "join my call." With the display name we set in Info.plist, the placeholder resolves to "Stream Standup", so the spoken phrase becomes "Join my Stream Standup." The phrases register automatically the first time the app runs; no Siri setup screen, no user opt-in.

Trying It Out

  1. Build and run on a physical iPhone. Launch the app once so the App Shortcut phrases register, and grant camera/microphone access.
  2. Background the app.
  3. Say: "Hey Siri, join my Stream Standup." Siri answers with the dialog from the intent, and the app opens straight into the call screen.
  4. Watch the result from the other side: open the same call as a second participant using Stream's Video demo or your dashboard, and see yourself pop in.

What We Built and What's Next

We built a voice-activated video calling app in about 200 lines of Swift. The Stream Video SwiftUI SDK provided the entire call experience via CallViewModel and CallModifier, and App Intents plus App Shortcuts made "join my Stream Standup" a spoken command with no user configuration.

The bigger takeaway is that this covers your whole communication stack, not just video. An intent that foregrounds the app and hands an identifier to the UI works the same whether that identifier names a call, an audio room, a chat channel, or a livestream. Siri becomes an entry point to all of them.

From here you could:

  • Make the call ID dynamic with AppEntity so Siri can offer your recent calls by name
  • Wire the same handoff to Stream Chat so "message my team" opens a channel with the keyboard ready
  • Expose a "start my livestream" intent that puts a creator live without touching the screen
  • Add CallKit plus push notifications for real incoming-call ringing

Voice joining looks like a headline feature, and you now know it's a couple hundred lines. Sign up for a free Stream account, swap in your own API key and call IDs, and put a Siri phrase on the call your users join most.

Add Chat to my app: getstream.io/SKILL.md

The fastest way to build with Stream. Start a new project or improve an existing one. Full CLI and documentation integration out of the box.


Ask your agent:

/stream Build me a Social App with Feeds and Moderation.
/stream Any livestream calls running?