Build multi-modal AI applications using our new open-source Vision AI SDK.

iOS Video Calling Tutorial

The following tutorial shows you how to quickly build a Video Calling app leveraging Stream's Video API and the Stream Video iOS components. The underlying API is very flexible and allows you to build nearly any type of video experience.

Prefer to skip the setup? Add the Stream skill and let your AI agent build your iOS video calling app.

This tutorial teaches you how to build a Zoom/Whatsapp-style video calling app in SwiftUI.

  • All calls run on Stream's Global Edge Network for optimal latency & reliability.
  • Permissions give you fine-grained control over who can do what.
  • Video quality and codecs are automatically optimized.
  • Powered by Stream's Video Calling API.

This page works for both humans and AI coding agents: every step is a file operation or a shell command, every step has a verification checkpoint, and the few steps that need a human are explicitly marked.

Choose your path

This tutorial has two paths. In Path A you let your AI agent build the app for you. In Path B you build it yourself, step by step, in Xcode. Both paths end with the same working video call, so pick the one you prefer instead of working through both.

Install the Stream CLI once, then add the skills. This step is required for Path A, not optional - the skills are what give Claude Code, Cursor, or Codex the iOS integration patterns and current SDK APIs, so the agent builds against real docs instead of stale training data.

Terminal (bash)
1
2
3
4
5
6
7
# Install the getstream CLI curl -fsSL https://getstream.io/cli.sh | bash # Install the skills (router, docs, and builder). The iOS pack installs # on demand the first time it's needed, or add it explicitly: getstream skills getstream skills stream-swift

Then ask your agent:

Prompt (markdown)
1
2
3
4
/stream-swift Add Stream Video to my SwiftUI app: create and join a call, render the participants, and show the default call controls. Provision credentials with the CLI (create/select my org and app, mint a token), or fall back to the tutorial demo credentials if I'm not logged in.

Only the first line matters - /stream-swift Add Stream Video calling to my app is enough to get going. The rest just steers the result: name a layout, ask for your own call controls instead of the default ones, add a lobby screen before joining. The skill supports custom controls as well as the defaults, so describe the UI you want rather than the UI the tutorial builds.

Where you come in. The agent writes the code, provisions the credentials, and edits Info.plist for the camera and microphone usage descriptions itself. It hands back to you twice: when getstream init opens your browser to log in and pick an app (new accounts get an organization created in that flow), and when the SPM package needs to be added in Xcode - hand-patching project.pbxproj is fragile, so the agent asks rather than risks it.

Want to get the login out of the way first? Run getstream init before you prompt the agent - it picks up the initialized project from there.

Checkpoint: build and run on a real device - the Simulator has no camera, so it can't produce a local video feed. Grant camera and microphone access when iOS asks, and you should join the call and see your own video.

Skim Important Building Blocks if you want to understand what the agent wrote before you extend it.

Path B - Build it manually

Choose this path if you'd rather write the code yourself, or you want to understand what the SDK is doing before you hand it to an agent. Follow the steps below. Every code block is titled with the file it belongs to: the two whole-app blocks (the ones marked with the lock icon) are the complete file at that point in the tutorial, the shorter ones replace only the part they show, and the untitled blocks are excerpts explaining what just changed.

For AI assistants reading this page: append .md to any Stream docs URL for a clean Markdown version. A condensed index for the iOS Video SDK is at https://getstream.io/video/docs/ios/llms.txt. Prefer these over parsing HTML.

Important Building Blocks

Four pieces, one mental model:

  • StreamVideo - the low-level client. Holds the API key, the user, the token and the websocket connection. Created once, at app launch.
  • Call - a single call, created with client.call(callType:callId:). call.join(create: true) is what actually sets up audio and video.
  • call.state - the observable state of the call: participants, call settings, recording status. This is what your SwiftUI views read from.
  • CallViewModel + CallContainer - the stateful pair that gives you a complete calling screen (incoming, outgoing, active call) with a few lines of code. Swap individual views through a ViewFactory. CallContainer is the SwiftUI view; UIKit apps drive the same CallViewModel through CallViewController instead (SwiftUI vs. UIKit).

You'll get a bare call connected first, then render raw video with VideoCallParticipantView, then swap in the prebuilt calling UI.

Step 0 - Get your credentials

The code in Step 3 needs four values: an API key, a user token, a user id and a call id. There are two ways to get them.

Agents: detect the project shape before touching anything, including the CLI (find . -maxdepth 3 -name "*.xcodeproj" -o -name "Package.swift"). The CLI writes into the project directory, so an Xcode project has to exist first. If the directory is empty with no Xcode project, stop and ask the human to create the app in Xcode (Step 1) - don't scaffold it yourself, and don't run getstream init until it's there.

Option 1 - Your own Stream app, via the Stream CLI

The getstream CLI provisions all of it in one flow. Run these from your project directory.

1. Install the CLI (skip if you did this in Path A):

Terminal (bash)
1
curl -fsSL https://getstream.io/cli.sh | bash

2. Initialize the project. This one command authenticates you, lets you create or select an organization and app, and writes the project credentials. New to Stream? The login flow creates your organization. Already have an org or an app? It lets you pick them.

Terminal (bash)
1
getstream init

Human checkpoint: getstream init opens a browser to authenticate. Agents: run it, then ask the human to finish logging in before continuing. It's required first - token and api commands fail with "stream project is not initialized" until it runs.

3. Grab your API key. This opens the app you just selected in the Stream dashboard, where the API key is shown. Paste it into the code in Step 3.

Terminal (bash)
1
getstream open

4. Mint a user token for a user in your app (never-expiring by default; add a TTL for production-like testing):

Terminal (bash)
1
2
getstream token martin getstream token martin --ttl 1d

5. Pick a call id. Anything URL-safe works, for example my-first-call. Calls are created the first time somebody joins with create: true, so there is nothing to provision up front.

Checkpoint: you have an API key, a token printed by the CLI, the user id you minted it for, and a call id you chose. All four belong to the same app.

Pasting the API key straight into your source is fine for this tutorial - it's a publishable key, not a secret. The user token is the one to be careful with: in production your backend mints it after login and the app fetches it, rather than shipping a hardcoded one.

Option 2 - Pre-filled tutorial credentials, no account

Want to skip account setup entirely? Every code block below marked with the lock icon is filled in for you with working credentials against Stream's shared tutorial environment. Copy the block as-is and it runs.

Swap in your own credentials from Option 1 before building anything real. Tutorial credentials are shared and short-lived.

Step 1 - Create a New SwiftUI Application in Xcode

  1. Ensure you have Xcode installed and are running 26 or later.
  2. Open Xcode and select "Create a new Project".
  3. Select "iOS" as the platform and "App" as the type of Application.
  4. Name your project "VideoCall" and select "SwiftUI" as the interface.

Checkpoint:

Terminal (bash)
1
2
3
4
# Read the scheme out of the project instead of hardcoding a name SCHEME=$(xcodebuild -list | awk '/Schemes:/{f=1; next} f && NF {gsub(/^[ \t]+|[ \t]+$/, ""); print; exit}') xcodebuild -scheme "$SCHEME" -destination 'generic/platform=iOS Simulator' build

Every xcodebuild command on this page resolves the scheme this way, so it works whether you named the project VideoCall or you're adding Stream to an app you already have. It takes the first scheme xcodebuild -list reports - if your project defines several, run xcodebuild -list yourself and pass the one you want. Agents: resolve the scheme from xcodebuild -list; never assume VideoCall.

Step 2 - Install the SDK & Setup Permissions

Next, you must add our SDK dependencies to your project using Swift Package Manager in Xcode.

  1. Click "Add packages..." from the File menu
  2. Add https://github.com/GetStream/stream-video-swift in the search bar.
  3. In "Dependency Rule" choose "Up to Next Major Version" and enter the latest 1.x release.
  4. Select "StreamVideo" and "StreamVideoSwiftUI" and click Add Package.

Pin the major explicitly, so a future 2.0 can't be picked up silently.

Agents: editing project.pbxproj by hand to add an SPM dependency is fragile. For a plain .xcodeproj with no Package.swift, ask the human to add the package in Xcode (30 seconds) rather than patching the pbxproj. The Info.plist keys below are a different matter - edit those in place yourself.

Set Mic & Camera Permissions

Making a video call requires using the device's camera and microphone. Therefore, you need to request permission to use them in your app. You must add the following keys and values by selecting your project's root folder in Xcode and clicking the Info tab.

  • Privacy - Camera Usage Description - "VideoCall requires camera access to capture and transmit video".
  • Privacy - Microphone Usage Description - "VideoCall requires microphone access to capture and transmit audio".

Screenshot shows permissions in the .plist file

Checkpoint: xcodebuild -resolvePackageDependencies succeeds and resolves a 1.x version, import StreamVideo and import StreamVideoSwiftUI compile, and both usage-description keys show up in the target's Info tab. Missing either key crashes the app the moment it tries to open the camera or microphone.

Step 3 - Create & Join a Call

Open up VideoCall/VideoCallApp.swift and replace it with this code:

VideoCall/VideoCallApp.swift (swift)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import SwiftUI import StreamVideo import StreamVideoSwiftUI @main struct VideoCallApp: App { @State var call: Call @ObservedObject var state: CallState @State var callCreated: Bool = false private var client: StreamVideo private let apiKey: String = "REPLACE_WITH_API_KEY" private let token: String = "REPLACE_WITH_TOKEN" private let userId: String = "REPLACE_WITH_USER_ID" private let callId: String = "REPLACE_WITH_CALL_ID" init() { let user = User( id: userId, name: "Martin", // name and imageURL are used in the UI imageURL: .init(string: "https://getstream.io/static/2796a305dd07651fcceb4721a94f4505/a3911/martin-mitrevski.webp") ) // Initialize Stream Video client self.client = StreamVideo( apiKey: apiKey, user: user, token: .init(stringLiteral: token) ) // Initialize the call object let call = client.call(callType: "default", callId: callId) self.call = call self.state = call.state } var body: some Scene { WindowGroup { VStack { if callCreated { Text("Call \(call.callId) has \(call.state.participants.count) participants") .font(.system(size: 30)) .foregroundColor(.blue) } else { Text("loading...") } }.onAppear { Task { guard !callCreated else { return } try await call.join(create: true) callCreated = true } } } } }

If you followed Option 1 in Step 0, replace the four constants with your own values. If you followed Option 2, they're already filled in for you.

Your server-side API typically generates the user token. When a user logs in to your app, you return the user token, which gives them access to the call. We have generated the token for you in the code sample above to make this tutorial easier to follow - never ship the API secret to the app.

Now, when you run the sample app, it will connect successfully. The text will say, "Call ... has 1 participant" (yourself). Let's review what we did in the above code.

Create a user. First, we create a user object. You typically sync these users via a server-side integration from your own backend. Alternatively, you can also use guest or anonymous users.

swift
1
2
3
4
let user = User( id: userId, name: "Martin" )

Initialize the Stream Client: Next, we initialize the client by passing the API Key, user and token.

swift
1
2
3
4
5
self.client = StreamVideo( apiKey: apiKey, user: user, token: .init(stringLiteral: token) )

Create and join a call: After the user and client are created, we create a call like this:

swift
1
2
3
self.call = client.call(callType: "default", callId: callId) try await call.join(create: true)

As soon as you use call.join, the connection for video & audio sets up.

Lastly, the UI is rendered by observing call.state and participants state:

swift
1
call.state.participants.count

You'll find all relevant states for the call in call.state and call.state.participants. The documentation on Call state and Participant state explains this further.

Human checkpoint: the camera and microphone prompts are system dialogs. Agents: build and install the app, then ask the human to launch it and allow camera and microphone access.

Checkpoint: the screen reads Call <call-id> has 1 participants and the Xcode console is free of auth errors. Stuck on loading...? The call never finished joining - check the console, which usually means the API key and token belong to different apps.

Step 4 - Joining a Call from the Web

Let's join the call from your browser to make this a little more interactive.

For testing you can join the call on our web-app: Join Call

On your iOS device, you'll see the text update to 2 participants. Let's keep the browser tab open as you go through the tutorial.

Using your own credentials from Option 1? The Join Call button above joins the shared tutorial call, not yours. Run the app on a second device (or simulator) with a different user id and token, and the same call id. Two clients signed in as the same user count as one participant.

Checkpoint: the participant count reads 2.

Step 5 - Rendering Video

In this next step, we will render your local & remote participant video.

Let's update the body of our VideoCallApp View with the following code.

VideoCall/VideoCallApp.swift (swift)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
var body: some Scene { WindowGroup { VStack { if callCreated { ZStack { ParticipantsView( call: call, participants: call.state.remoteParticipants, onChangeTrackVisibility: changeTrackVisibility(_:isVisible:) ) FloatingParticipantView(participant: call.state.localParticipant) } } else { Text("loading...") } }.onAppear { Task { guard callCreated == false else { return } try await call.join(create: true) callCreated = true } } } } /// Changes the track visibility for a participant (not visible if they go off-screen). /// - Parameters: /// - participant: the participant whose track visibility would be changed. /// - isVisible: whether the track should be visible. private func changeTrackVisibility(_ participant: CallParticipant?, isVisible: Bool) { guard let participant else { return } Task { await call.changeTrackVisibility(for: participant, isVisible: isVisible) } }

We will now create the ParticipantsView. It will contain a vertical list of all participants in the call, apart from the current user. Add a new Swift file ParticipantsView.swift, replace its content with the following sample code and import StreamVideo and import StreamVideoSwiftUI.

The video feeds of the users will be presented with the UI component from our SwiftUI SDK, called VideoCallParticipantView.

VideoCall/ParticipantsView.swift (swift)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
struct ParticipantsView: View { var call: Call var participants: [CallParticipant] var onChangeTrackVisibility: (CallParticipant?, Bool) -> Void var body: some View { GeometryReader { proxy in if !participants.isEmpty { ScrollView { LazyVStack { if participants.count == 1, let participant = participants.first { makeCallParticipantView(participant, frame: proxy.frame(in: .global)) .frame(width: proxy.size.width, height: proxy.size.height) } else { ForEach(participants) { participant in makeCallParticipantView(participant, frame: proxy.frame(in: .global)) .frame(width: proxy.size.width, height: proxy.size.height / 2) } } } } } else { Color.black } } .edgesIgnoringSafeArea(.all) } @ViewBuilder private func makeCallParticipantView(_ participant: CallParticipant, frame: CGRect) -> some View { VideoCallParticipantView( participant: participant, availableFrame: frame, contentMode: .scaleAspectFit, customData: [:], call: call ) .onAppear { onChangeTrackVisibility(participant, true) } .onDisappear{ onChangeTrackVisibility(participant, false) } } }

Like in most video calling apps, the current user will appear in a floating view in the top right corner. Add another Swift file, FloatingParticipantView.swift to the project. Use this sample code for its content and import StreamVideoSwiftUI and import StreamVideo.

VideoCall/FloatingParticipantView.swift (swift)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct FloatingParticipantView: View { var participant: CallParticipant? var size: CGSize = .init(width: 120, height: 120) var body: some View { if let participant = participant { VStack { HStack { Spacer() VideoRendererView(id: participant.id, size: size) { videoRenderer in videoRenderer.handleViewRendering(for: participant, onTrackSizeUpdate: { _, _ in }) } .frame(width: size.width, height: size.height) .clipShape(RoundedRectangle(cornerRadius: 8)) } Spacer() } .padding() } } }

When you run the app, you'll see your local video in a floating video element and the video from your browser. The result should look somewhat like this:

Preview of a call running with the local video floating at the top right.

Note that you should use an actual device to see your local video.

Let's review the changes we made.

We added the changeTrackVisibility in our app and propagated its call to the other subviews we created. When this method is called, we ask the Call object to make the participant's track visible or not visible. This is important when the view goes off-screen (e.g., while scrolling through participants during a call) to reduce energy and data consumption.

swift
1
2
3
4
5
6
7
8
9
VideoCallParticipantView( participant: participant, availableFrame: frame, contentMode: .scaleAspectFit, customData: [:], call: call ) .onAppear { onChangeTrackVisibility(participant, true) } .onDisappear{ onChangeTrackVisibility(participant, false) }

It only displays the video and doesn't add any other UI elements. The video is lazily loaded, and only requested from the video infrastructure if you're actually displaying it. So if you have a video call with 200 participants, and you show only 10 of them, you'll only receive video for 10 participants. This is how software like Zoom and Google Meet make large calls work.

swift
1
FloatingParticipantView(participant: call.state.localParticipant)

FloatingParticipantView renders a display of your own video. It uses VideoRendererView which is the component used by VideoCallParticipantView to simply display the video without adding any other UI elements.

ParticipantsView renders a scrollview of all remoteParticipants.

swift
1
ParticipantsView(participants: call.state.remoteParticipants, onChangeTrackVisibility: changeTrackVisibility(_:isVisible:))

Checkpoint: on a real device, your own video plays in the floating tile at the top right, and the participant you joined with in Step 4 fills the rest of the screen. Black floating tile? The iOS Simulator has no camera - run on a device.

Step 6 - Render a Full Video Calling UI

The above example showed how to use the call state object and SwiftUI to build a basic video UI. For a production version of calling, you'd want a few more UI elements:

  • Indicators of when someone is speaking.
  • Quality of their network.
  • Layout support for >2 participants.
  • Labels for the participant names.
  • Call header and controls.

Stream ships with several SwiftUI components to make this easy. You can customize the UI to:

  • Build your UI components (the most flexible, build anything).
  • Mix and match with Stream's UI Components (speed up how quickly you can build common video UIs).
  • Do basic theming and customization of colors, fonts, etc. This is convenient if you want to build a production-ready calling experience for your app quickly.

The most commonly used UI components are:

  • VideoRendererView: For rendering video and automatically requesting video tracks when needed. Most of the Video components are built on top of this.
  • VideoCallParticipantView: The participant's video + some UI elements for network quality, reactions, speaking etc.
  • ParticipantsGridLayout: A grid of participant video elements.
  • CallControls: A set of buttons for controlling your call, such as changing audio and video states.
  • IncomingCall: UI for displaying incoming and outgoing calls.

The complete list of UI components is available in the docs.

To render a complete calling UI, we'll leverage the CallContainer component. This includes sensible defaults for a call header, video grid, call controls, picture-in-picture, and everything you need to build a video call screen.

Let's update the code in our VideoCall/VideoCallApp.swift.

VideoCall/VideoCallApp.swift (swift)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import SwiftUI import StreamVideo import StreamVideoSwiftUI @main struct VideoCallApp: App { // The app owns the view model, so it's a @StateObject: SwiftUI keeps the // same instance alive across body evaluations. @StateObject var viewModel = CallViewModel() private var client: StreamVideo private let apiKey: String = "REPLACE_WITH_API_KEY" private let token: String = "REPLACE_WITH_TOKEN" private let userId: String = "REPLACE_WITH_USER_ID" private let callId: String = "REPLACE_WITH_CALL_ID" init() { let user = User( id: userId, name: "Martin", // name and imageURL are used in the UI imageURL: .init(string: "https://getstream.io/static/2796a305dd07651fcceb4721a94f4505/a3911/martin-mitrevski.webp") ) // Initialize Stream Video client self.client = StreamVideo( apiKey: apiKey, user: user, token: .init(stringLiteral: token) ) } var body: some Scene { WindowGroup { VStack { if viewModel.call != nil { CallContainer(viewFactory: DefaultViewFactory.shared, viewModel: viewModel) } else { Text("loading...") } }.onAppear { Task { guard viewModel.call == nil else { return } viewModel.joinCall(callType: .default, callId: callId) } } } } }

The result will be:

video preview 02

When you run your app now, you'll see a more polished video UI. It supports reactions, screen sharing, active speaker detection, network quality indicators, etc.

Checkpoint: the call screen shows the participant grid with name labels and network indicators, and the control bar at the bottom toggles your mic and camera. Toggle your mic here and watch the indicator update in the browser tab.

Step 7 - Customize the UI

Two levers, in increasing order of effort: theming changes tokens (colors, fonts, icons, sounds) across every component; a ViewFactory swaps out an individual view while everything else keeps its default.

Theming

Appearance is configured once, when you create the StreamVideoUI object at launch. Update the init() in VideoCallApp.swift:

VideoCall/VideoCallApp.swift (swift)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@StateObject var viewModel = CallViewModel() private var client: StreamVideo private var streamVideoUI: StreamVideoUI init() { let user = User( id: userId, name: "Martin", imageURL: .init(string: "https://getstream.io/static/2796a305dd07651fcceb4721a94f4505/a3911/martin-mitrevski.webp") ) let client = StreamVideo( apiKey: apiKey, user: user, token: .init(stringLiteral: token) ) self.client = client // Colors, fonts, images and sounds all live on `Appearance`. var colors = Colors() let streamBlue = UIColor(red: 0, green: 108.0 / 255.0, blue: 255.0 / 255.0, alpha: 1) colors.tintColor = Color(streamBlue) colors.callBackground = UIColor(red: 0.09, green: 0.09, blue: 0.13, alpha: 1) var fonts = Fonts() fonts.footnoteBold = .footnote // StreamVideoUI is the SwiftUI context object — it must exist before any // SDK view renders, and it's what applies your Appearance. self.streamVideoUI = StreamVideoUI( streamVideo: client, appearance: Appearance(colors: colors, fonts: fonts) ) }

Checkpoint: the call background is darker and the accented controls pick up the Stream blue tint. Full reference: theme.

Swapping a view with a ViewFactory

To change an actual view, conform to ViewFactory and implement only the make… method for the slot you want. Create CustomViewFactory.swift:

VideoCall/CustomViewFactory.swift (swift)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import SwiftUI import StreamVideo import StreamVideoSwiftUI // Our own control bar. The button views are the SDK's, so they keep // working against the CallViewModel — only the arrangement is ours. struct CustomCallControlsView: View { @ObservedObject var viewModel: CallViewModel var body: some View { HStack(spacing: 32) { VideoIconView(viewModel: viewModel) MicrophoneIconView(viewModel: viewModel) ToggleCameraIconView(viewModel: viewModel) HangUpIconView(viewModel: viewModel) } .frame(maxWidth: .infinity) .frame(height: 85) } } // Override one slot; every other view keeps its default implementation. // `ViewFactory` is `@MainActor`, so the singleton is declared `nonisolated` — // exactly how the SDK declares its own `DefaultViewFactory`. final class CustomViewFactory: ViewFactory, @unchecked Sendable { private nonisolated init() {} nonisolated static let shared = CustomViewFactory() func makeCallControlsView(viewModel: CallViewModel) -> some View { CustomCallControlsView(viewModel: viewModel) } }

Then pass the factory in where you create the container - change one line in body:

swift
1
CallContainer(viewFactory: CustomViewFactory.shared, viewModel: viewModel)

That's the whole pattern. The same approach replaces the participant tile (makeVideoParticipantView), the call header (makeCallTopView), the lobby (makeLobbyView), the incoming-call screen (makeIncomingCallView), and more - see customizing views.

Checkpoint: the default control bar is replaced by your four buttons, and each one still toggles the right thing.

Cookbooks

Short, self-contained recipes - each one swaps out a single piece of the call UI.

Participants and video

Layout and controls

Handling the awkward cases

Step 8 - Enable Noise Cancellation

Background noise during a call session is never pleasant for the call participants.

Our SDK provides a plugin that greatly reduces the unwanted noise caught by users' microphones. Read more on how to enable it here.

Verify the whole build

Terminal (bash)
1
2
3
4
5
SCHEME=$(xcodebuild -list | awk '/Schemes:/{f=1; next} f && NF {gsub(/^[ \t]+|[ \t]+$/, ""); print; exit}') xcodebuild -scheme "$SCHEME" \ -destination 'generic/platform=iOS Simulator' \ build

The simulator is enough to prove the app compiles and joins, but it has no camera - install on a real device to confirm the full loop: join the call -> your own video appears -> the browser participant appears -> the controls mute and unmute you on both ends.

Troubleshooting

  • stream project is not initialized - CLI onboarding not run. Run getstream init in the project directory first.
  • xcodebuild: error: The project ... does not contain a scheme named "" - $SCHEME came back empty, so xcodebuild -list found no project (wrong directory) or more than one. cd to the folder holding your .xcodeproj, run xcodebuild -list, and pass a scheme from its output explicitly.
  • Stuck on loading... - the join never completed. Check the Xcode console; an auth error almost always means the API key and token belong to different apps.
  • token is invalid / auth error - token minted for a different app or expired. Re-mint with getstream token <user_id> and confirm the API key matches.
  • App crashes when the call starts - missing usage descriptions. Add Privacy - Camera Usage Description and Privacy - Microphone Usage Description (Step 2).
  • No local video, black floating tile - the iOS Simulator has no camera. Run on a real device.
  • Participant count stays at 1 - the two clients are on different call ids, or both are signed in as the same user id. Two clients with one user id count as one participant.
  • Missing package product 'StreamVideoSwiftUI' - package resolution stale. File -> Packages -> Reset Package Caches, or run xcodebuild -resolvePackageDependencies.
  • Remote video is blank but audio works - track visibility never turned on. Make sure changeTrackVisibility fires from onAppear for each participant view.
  • Custom view slot not applied - factory defined but not injected. Pass it to CallContainer(viewFactory:viewModel:), and theming needs a StreamVideoUI object to exist.

Recap

Please let us know if you encounter issues building a video calling app with our Swift SDK. Our team is also happy to review your UI designs and recommend achieving them with Stream.

To recap what we've learned:

  • You set up a call: (let call = streamVideo.call(callType: "default", callId: "123")).
  • The call type ("default" in the above case) controls which features are enabled and how permissions are set up.
  • When you join a call, real-time communication is set up for audio & video calling: (call.join()).
  • Published objects in call.state and call.state.participants make it easy to build your UI.
  • VideoRendererView is the low-level component that renders video.
  • We've used Stream's Video Calling API, which means calls run on a global edge network of video servers. Being closer to your users improves the latency and reliability of calls. The Swift SDK enables you to build in-app video calling, audio rooms and livestreaming in days.

We hope you've enjoyed this tutorial. Please feel free to contact us if you have any suggestions or questions.

Next steps

Pricing is flexible with multiple tiers, including a free maker plan for hobby projects and small companies.

Final Thoughts

In this video app tutorial, we built a fully functioning iOS video calling app with our iOS SDK component library. We also showed how easy it is to customize the behavior and the style of the iOS video app components with minimal code changes.

Both the video SDK for iOS and the API have plenty more features available to support more advanced use cases.

Machine-readable resources

For AI agents and coding assistants working with this SDK:

  • CLI + skills: curl -fsSL https://getstream.io/cli.sh | bash, then getstream skills and getstream skills stream-swift for the iOS pack. (Alternative: npx skills add GetStream/agent-skills -s stream.)
  • Provisioning: getstream init (auth + create/select org & app) -> getstream open (dashboard, to read the API key) -> getstream token <user_id> (mint a token). The publishable API key goes straight into the source for a tutorial app; the token comes from your backend in production.
  • Data & config from the CLI: getstream api <Endpoint> --request '{...}' for users, call types, and call members
  • iOS integration skill: invoke /stream-swift in your agent for SwiftUI/UIKit setup patterns; /stream-docs searches live SDK docs with citations
  • Docs index for LLMs: https://getstream.io/video/docs/ios/llms.txt
  • Markdown endpoints: append .md to any docs URL for a clean, token-efficient version
  • Source of truth for APIs: the SDK repository - check the pinned version's source rather than assuming APIs from training data

Give us feedback!

Did you find this tutorial helpful in getting you up and running with your project? Either good or bad, we're looking for your honest feedback so we can improve.

Start coding for free

No credit card required.
If you're interested in a custom plan or have any questions, please contact us.