This tutorial will teach you how to build an audio room experience like Twitter Spaces or Clubhouse. The result will look like the image on the right and supports the following features:
- Backstage mode: You can start the call with your co-hosts and chat a bit before going live.
- Calls run on Stream's global edge network for optimal latency and scalability.
- No cap on how many listeners you can have in a room.
- Listeners can raise their hands and be invited by the host to speak.
- Audio tracks are sent multiple times for optimal reliability.

Time to get started building an audio-room for your app.
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 audio room, so pick the one you prefer instead of working through both.
Path A - Let your AI agent build it (recommended)
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.
1234567# 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:
12345/stream-swift Add a Stream audio room to my SwiftUI app: join a call of type audio_room, show the room title and description, split the participants into speakers and listeners, and add controls to go live and toggle my microphone. 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 a Stream audio room to my app is enough to get going. The rest just steers the result: ask for a raise-hand flow, a room list before joining, or your own avatar layout. 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 microphone usage description 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, allow microphone access when iOS asks, and press Go Live. Until you do, the room is in backstage and nobody else can get in - that's the audio_room default. Then join with a second client (another device or Simulator, signed in as a different user id with the same call id) and watch the participant list update.
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 or view it belongs to: the block marked with the lock icon is a complete file, the shorter ones replace only the view they name, and the untitled blocks are excerpts explaining what just changed.
For AI assistants reading this page: append
.mdto any Stream docs URL for a clean Markdown version. A condensed index for the iOS Video SDK is athttps://getstream.io/video/docs/ios/llms.txt. Prefer these over parsing HTML.
Important Building Blocks
Five 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 room, created withclient.call(callType: "audio_room", callId:).call.join(create: true)is what actually sets up audio.- Backstage - the
audio_roomcall type starts rooms not live. Hosts and co-hosts can join and talk first;call.goLive()is what lets everybody else in, andcall.stopLive()closes it again. call.state- the observable state of the room:participants,customdata,backstage,permissionRequests. This is what your SwiftUI views read from.call.microphone- theMicrophoneManagerfor the local user. Publishesstatusand exposestoggle(),enable()anddisable().
Permissions matter more here than in a video call: audio_room only lets admins and moderators speak, so everybody else calls call.request(permissions: [.sendAudio]) and a host grants it.
Unlike the video calling tutorial, there is no prebuilt container view for audio rooms - you compose the UI yourself out of call.state, which is exactly what Steps 4 to 8 do.
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 rungetstream inituntil 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):
1curl -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.
1getstream 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.
1getstream open
4. Mint a user token for a user in your app (never-expiring by default; add a TTL for production-like testing). This user creates the room, which makes them the host:
12getstream token martin getstream token martin --ttl 1d
5. Pick a call id. Anything URL-safe works, for example my-first-room. Rooms 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? The 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
- Make sure you have Xcode installed and that you are running 26 or later.
- Open Xcode and select "Create a new Project".
- Select "iOS" as the platform and "App" as the type of Application.
- Name your project "AudioRooms" and select "SwiftUI" as the interface.
Checkpoint:
1234# 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 AudioRooms 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 AudioRooms.
Step 2 - Install the SDK & Setup Permissions
Next, you must add our SDK dependencies to your project using Swift Package Manager from Xcode.
- Click on "Add packages..." from the File menu.
- Add https://github.com/GetStream/stream-video-swift in the search bar.
- In "Dependency Rule" choose "Up to Next Major Version" and enter the latest 1.x release.
- Select "StreamVideo" and "StreamVideoSwiftUI" and then click Add Package.
Pin the major explicitly, so a future 2.0 can't be picked up silently.
Agents: editing
project.pbxprojby hand to add an SPM dependency is fragile. For a plain.xcodeprojwith noPackage.swift, ask the human to add the package in Xcode (30 seconds) rather than patching the pbxproj. TheInfo.plistkey below is a different matter - edit that in place yourself.
Configure App Permissions
Joining an audio room requires microphone access; you must request permission to use it in your app. Add the following key to the Info.plist file to do this.
Privacy - Microphone Usage Description- "AudioRooms requires microphone access to capture and transmit audio".

Checkpoint: xcodebuild -resolvePackageDependencies succeeds and resolves a 1.x version, import StreamVideo compiles, and the usage-description key shows up in the target's Info tab. Missing that key crashes the app the moment it tries to open the microphone.
Step 3 - Create & Join a Call
Open up AudioRooms/AudioRoomsApp.swift and replace it with this code:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566import SwiftUI import StreamVideo @main struct AudioRoomsApp: App { @State var call: Call @ObservedObject var state: CallState @State private var callCreated: Bool = false private var client: StreamVideo private let apiKey: String = "REPLACE_WITH_API_KEY" private let userId: String = "REPLACE_WITH_USER_ID" private let token: String = "REPLACE_WITH_TOKEN" 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: "audio_room", callId: callId) self.call = call self.state = call.state } var body: some Scene { WindowGroup { VStack { if callCreated { Text("Audio Room \(call.callId) has \(call.state.participantCount) participants") .font(.system(size: 30)) .foregroundColor(.blue) } else { Text("loading...") } }.task { Task { guard !callCreated else { return } try await call.join( create: true, options: .init( members: [ ], custom: [ "title": .string("SwiftUI heads"), "description": .string("Talking about SwiftUI") ] ) ) 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.
Let's review the example above and go over the details.
User setup. First we create a user object. You typically sync your users via a server side integration from your own backend. Alternatively, you can use guest or anonymous users.
1234let user = User( id: userId, name: "Martin" )
Client init. Next we initialize the client by passing the API Key, user and user token.
12345self.client = StreamVideo( apiKey: apiKey, user: user, token: .init(stringLiteral: token) )
Create and join call After the user and client are created, we create a call like this:
123456789101112131415self.call = client.call(callType: "audio_room", callId: callId) try await call.join( create: true, options: .init( members: [ .init(userId: "john_smith"), .init(userId: "jane_doe"), ], custom: [ "title": .string("SwiftUI heads"), "description": .string("Talking about SwiftUI") ] ) )
- This joins and creates a call with the type: "audio_room" and the specified
callId. - The users with id
john_smithandjane_doeare added as members to the call. - We set the
titleanddescriptioncustom fields on the call object.
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.
With valid credentials in place, we can join the call. When you run the app, you'll see the following:

Human checkpoint: the microphone prompt is a system dialog. Agents: build and install the app, then ask the human to launch it and allow microphone access.
Checkpoint: the screen reads Audio Room <call-id> has 1 participants and the Xcode console is free of auth errors. Stuck on loading...? The join never completed - check the console, which usually means the API key and token belong to different apps.
Note that the room is created in backstage: you are in it because you created it, but nobody else can get in yet. Step 4 adds the button that changes that.
Step 4 - Add the Audio Room UI Elements
In this next step, we'll add the following.
- Room title and description.
- Controls to toggle the live mode on/off.
- A list of participants with their speaking status.
Room Title & Description
Let's create the components we need to render this and add them to the main app view.
123456789101112131415161718192021222324252627282930import SwiftUI import StreamVideo struct DescriptionView: View { var title: String? var description: String? var participants: [CallParticipant] var body: some View { VStack { VStack { Text("\(title ?? "")") .font(.title) .frame(maxWidth: .infinity, alignment: .leading) .lineLimit(1) .padding([.bottom], 8) Text("\(description ?? "")") .font(.body) .frame(maxWidth: .infinity, alignment: .leading) .lineLimit(1) .padding([.bottom], 4) Text("\(participants.count) participants") .font(.caption) .frame(maxWidth: .infinity, alignment: .leading) }.padding([.leading, .trailing]) } } }
123456789101112import SwiftUI import StreamVideo struct ParticipantsView: View { var participants: [CallParticipant] var body: some View { Spacer() Text("Participants: TODO").font(.body) Spacer() } }
1234567891011import SwiftUI import StreamVideo struct ControlsView: View { @State var call: Call @ObservedObject var state: CallState var body: some View { Text("Controls: TODO").font(.body) } }
That's it for the basics. Here's how the app Scene should look like now.
1234567891011121314151617181920212223242526272829303132333435363738var body: some Scene { WindowGroup { VStack { if callCreated { DescriptionView( title: call.state.custom["title"]?.stringValue, description: call.state.custom["description"]?.stringValue, participants: call.state.participants ) ParticipantsView( participants: call.state.participants ) Spacer() ControlsView(call: call, state: state) } else { Text("loading...") } }.task { Task { guard !callCreated else { return } try await call.join( create: true, options: .init( members: [ .init(userId: "john_smith"), .init(userId: "jane_doe"), ], custom: [ "title": .string("SwiftUI heads"), "description": .string("Talking about SwiftUI") ] ) ) callCreated = true } } } }
If you run the app now, it should look like this.

The approach is the same for all components. We observe call.state published variables, such as call.state.participants, to determine the call's states and use them to power our UI.
Checkpoint: the room title "SwiftUI heads" and its description render above a participant count, with the two placeholder views below them.
Let's join the audio room from the browser to make this a little more interactive.
Using your own credentials from Option 1? The Join button above joins the shared tutorial room, 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 browser refuses to let you in, and the participant count stays at 1. That's expected - the room is still in backstage. The next section adds the button that opens it.
Backstage & Live Mode Control
As you probably noticed by opening the same room from the browser, audio rooms are not live by default. Regular users can only join an audio room when it is in live mode. Let's expand the ControlView and add a button that controls the backstage or the room.
1234567891011121314import SwiftUI import StreamVideo struct MicButtonView: View { var body: some View { Button { Task { print("handle mic tap") } } label: { Label("", systemImage: "mic.circle").font(.title) } } }
1234567891011121314151617181920212223242526272829import SwiftUI import StreamVideo struct LiveButtonView: View { var call: Call @ObservedObject var state: CallState var body: some View { if state.backstage { Button { Task { try await call.goLive() } } label: { Text("Go Live") } .buttonStyle(.borderedProminent).tint(.green) } else { Button { Task { try await call.stopLive() } } label: { Text("Stop live") } .buttonStyle(.borderedProminent).tint(.red) } } }
1234567891011121314import SwiftUI import StreamVideo struct ControlsView: View { var call: Call @ObservedObject var state: CallState var body: some View { HStack { MicButtonView() LiveButtonView(call: call, state: state) } } }
Now, the app exposes a fake mic control button (more on that later) and a button to toggle live mode on/off. If you try the web demo of the audio room, you should be able to join as a regular user.

Checkpoint: tapping Go Live flips the button to a red Stop live, and the browser tab from the previous section can now join. Tap Stop live and the button flips back. state.backstage is what drives both.
List Participants
As a next step, let's render the actual list of participants and show an indicator when they are speaking. To do this, we will create a ParticipantView and render it from the ParticipantsView.
12345678910111213141516171819202122232425262728import SwiftUI import StreamVideo struct ParticipantView: View { var participant: CallParticipant var body: some View { VStack{ ZStack { Circle() .fill(participant.isSpeaking ? .green : .white) .frame(width: 68, height: 68) AsyncImage( url: participant.profileImageURL, content: { image in image.resizable() .aspectRatio(contentMode: .fit) .frame(maxWidth: 64, maxHeight: 64) .clipShape(Circle()) }, placeholder: { Image(systemName: "person.crop.circle").font(.system(size: 60)) } ) } Text("\(participant.name)") } } }
1234567891011121314import SwiftUI import StreamVideo struct ParticipantsView: View { var participants: [CallParticipant] var body: some View { LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], spacing: 20) { ForEach(participants) { ParticipantView(participant: $0) } } } }
With these changes, things get more interesting. The app now shows a list of all participants connected to the call and displays a small circle next to the ones who are speaking.

Checkpoint: every participant renders as an avatar with their name underneath, and the circle behind whoever is talking turns green. That highlight is participant.isSpeaking, which the SDK updates from the audio level - no wiring needed on your side.
Step 5 - Go live and join from the browser
If you join the call from the browser, the participant list will now update as you open and close the browser tab.
Note how the web interface won't let you share your audio/video. By default, the audio_room call type only allows moderators or admins to speak. Regular participants can request permission. If different defaults make sense for your app, you can edit the call type in the dashboard or create your own.
Checkpoint: with the room live, opening and closing the browser tab moves the participant count between 1 and 2, and the browser participant appears in and disappears from the grid.
Step 6 - Requesting permission to speak
Requesting permission to speak is easy. Let's first have a quick look at how the SDK call object exposes this.
Requesting Permission To Speak
1let response = try await call.request(permissions: [.sendAudio])
Handling Permission Requests
Permission requests are exposed from call.state on the permissionRequests published variable.
1234567if let request = call.state.permissionRequests.first { // reject it request.reject() // grant it try await call.grant(request: request) }
Let's add another view that shows the last incoming request as well as the buttons to grant or reject it.
12345678910111213141516171819202122232425import SwiftUI import StreamVideo struct PermissionRequestsView: View { var call: Call @ObservedObject var state: CallState var body: some View { if let request = state.permissionRequests.first { HStack { Text("\(request.user.name) requested to \(request.permission)") Button { Task { try await call.grant(request: request) } } label: { Label("", systemImage: "hand.thumbsup.circle").tint(.green) } Button(action: request.reject) { Label("", systemImage: "hand.thumbsdown.circle.fill").tint(.red) } } } } }
Here is the updated Scene code that includes it.
123456789101112131415161718192021222324252627282930313233343536373839var body: some Scene { WindowGroup { VStack { if callCreated { DescriptionView( title: call.state.custom["title"]?.stringValue, description: call.state.custom["description"]?.stringValue, participants: call.state.participants ) ParticipantsView( participants: call.state.participants ) Spacer() PermissionRequestsView(call: call, state: state) ControlsView(call: call, state: state) } else { Text("loading...") } }.task { Task { guard !callCreated else { return } try await call.join( create: true, options: .init( members: [ .init(userId: "john_smith"), .init(userId: "jane_doe"), ], custom: [ "title": .string("SwiftUI heads"), "description": .string("Talking about SwiftUI") ] ) ) callCreated = true } } } }
Now when a user requests access to speak the app will look like this.

Checkpoint: raise your hand from the browser tab and the request row appears in the iOS app. Tap the thumbs-up and the browser participant can unmute; tap the thumbs-down and the row disappears without granting anything. More on the model behind this in permissions and moderation.
Step 7 - Add a Microphone Control
You can read & manage the microphone status from the call.microphone published variable.
1234567891011/// Read the microphone's status let isEnabled = call.microphone.status == .enabled /// Toggle between enabled/disabled microphone status. try await call.microphone.toggle() /// Enable the microphone. try await call.microphone.enable() /// Disable the microphone. try await call.microphone.disable()
Let's update the MicButtonView with microphone handling.
123456789101112131415161718import SwiftUI import StreamVideo struct MicButtonView: View { @ObservedObject var microphone: MicrophoneManager var body: some View { Button { Task { try await microphone.toggle() } } label: { Image(systemName: microphone.status == .enabled ? "mic.circle" : "mic.slash.circle") .foregroundColor(microphone.status == .enabled ? .red : .primary) .font(.title) } } }
We can now pass the microphone ObservedObject from the ControlsView.
1234567891011121314import SwiftUI import StreamVideo struct ControlsView: View { var call: Call @ObservedObject var state: CallState var body: some View { HStack { MicButtonView(microphone: call.microphone) LiveButtonView(call: call, state: state) } } }
Checkpoint: the mic button now flips between mic.circle and mic.slash.circle as you tap it, and muting yourself is audible in the browser tab. Nothing happens at all? iOS denied the microphone permission - check Settings, or see camera and microphone.
Step 8 - Render Group Participants
It is common for audio rooms and similar interactive audio/video experiences to show users in separate groups. Let's see how we can update this application to render participants in two separate sections: speakers and listeners.
Building custom layouts is very simple. All we need to do is apply some filtering to the call.participants observable.
12345678// a list of participants, by default this is list is ordered by the ID of the user call.state.participants // Speakers: participants that have an audio track (ie. are allowed to speak and have a mic configured) call.state.participants.filter { $0.hasAudio } // Listeners: participants that do not have an audio track call.state.participants.filter { !$0.hasAudio }
We already have a view to display participants so all we need to do is to create another one. Here's how the scene body looks.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647var body: some Scene { WindowGroup { VStack { if callCreated { DescriptionView( title: call.state.custom["title"]?.stringValue, description: call.state.custom["description"]?.stringValue, participants: call.state.participants ) Text("Speakers") Divider() ParticipantsView( participants: call.state.participants.filter {$0.hasAudio} ) Text("Listeners") Divider() ParticipantsView( participants: call.state.participants.filter {!$0.hasAudio} ) Spacer() PermissionRequestsView(call: call, state: state) ControlsView(call: call, state: state) } else { Text("loading...") } }.task { Task { guard !callCreated else { return } try await call.join( create: true, options: .init( members: [ .init(userId: "john_smith"), .init(userId: "jane_doe"), ], custom: [ "title": .string("SwiftUI heads"), "description": .string("Talking about SwiftUI") ] ) ) try await call.sendReaction(type: "raise-hand", custom: ["mycustomfield": "hello"], emojiCode: ":smile:") callCreated = true } } } }

The sendReaction line at the end of the join is there to show that reactions work on audio rooms too. Drop it if you don't want a raise-hand reaction fired every time somebody joins.
Checkpoint: the grid is split under two headings. You sit under Speakers once you are live and unmuted; a browser participant who has not been granted sendAudio sits under Listeners and moves up the moment you grant their request.
Explore Other Built-in Features
There are a few more exciting features that you can use to build audio rooms.
- Query Calls: You can query calls to easily show upcoming calls, calls that recently finished as well as call previews.
- Reactions & Custom events: Reactions and custom events are supported.
- Recording & Broadcasting: You can record your rooms and broadcast them over HLS or RTMP.
- Chat: Stream's Chat SDKs are fully featured, and you can integrate them in the room.
- Moderation: Moderation capabilities are built into the product.
- Transcriptions and closed captions: Turn what is said in the room into text, live or after the fact.
- Noise cancellation: A plugin that strips background noise out of participants' microphones.
Verify the whole build
12345SCHEME=$(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 - unlike video, an audio room needs no camera. Run it on a device to confirm the full loop: join the room -> press Go Live -> the browser participant appears -> grant their request to speak -> they move from Listeners to Speakers.
Troubleshooting
stream project is not initialized- CLI onboarding not run. Rungetstream initin the project directory first.xcodebuild: error: The project ... does not contain a scheme named ""-$SCHEMEcame back empty, soxcodebuild -listfound no project (wrong directory) or more than one.cdto the folder holding your.xcodeproj, runxcodebuild -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 withgetstream token <user_id>and confirm the API key matches.- App crashes as soon as you join - missing usage description. Add
Privacy - Microphone Usage Description(Step 2). - The browser can't join the room - the room is still in backstage. Press Go Live in the app. This is
audio_room's default and it is deliberate. - No "Go Live" button, or
goLive()fails - your user isn't the room's creator or an admin. Only they can go live; regular participants can't. - The browser participant can't speak - that's the
audio_roomdefault. They requestsendAudioand you grant it fromPermissionRequestsView(Step 6). - 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.
- Mic button does nothing - microphone access was denied. Check Settings, and confirm the usage-description key is present.
Missing package product 'StreamVideoSwiftUI'- package resolution stale. File -> Packages -> Reset Package Caches, or runxcodebuild -resolvePackageDependencies.- Nobody ever shows as speaking -
isSpeakingonly flips for participants who actually publish audio. A listener with nosendAudiopermission never lights up.
Recap
It was fun to see how quickly you can build an audio-room for your app. Please let us know if you ran into any issues. Our team is also happy to review your UI designs and offer recommendations on how to integrate them with Stream.
Let's recap what we've learned.
- You set up a call with
call = client.call(callType: "audio_room", callId: "123"). - The call type
audio_roomcontrols which features are enabled and how permissions are set up. - The
audio_roomby default enables backstage mode, and only allows admins and the creator of the call to join before the call goes live. - When you join a call, realtime communication is set up for audio & video calling with
call.join(). - State objects in
call.stateandcall.state.participantsmake it easy to build your custom UIs. - All calls run on Stream's Global Edge Network of video servers. Being closer to your users improves the latency and reliability of calls. For audio rooms, we use Opus RED and Opus DTX for optimal audio quality.
The SDKs enable you to build audio rooms, video calling and livestreaming in days.
We hope you've enjoyed this tutorial. Please feel free to reach out if you have any suggestions or questions. You can find the code for this tutorial in this GitHub repository.
Next steps
- Room discovery - list upcoming, live and finished rooms with querying calls
- Chat in a room - the Chat iOS SDK integrates with video: chat integration guide
- Other call types - video calling and livestreaming
- Sample app - the complete demo: DemoApp on GitHub
Pricing is flexible with multiple tiers, including a free maker plan for hobby projects and small companies.
Final Thoughts
This is one of our video/voice app tutorials. We built a fully functioning audio room app with our iOS SDK components library. We also showed how easy it is to customize behavior and styles of the audio room app with minimal code changes.
Both the Video SDK for iOS and the API have more features supporting 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, thengetstream skillsandgetstream skills stream-swiftfor 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. - Call type:
audio_room- starts in backstage, and only admins and moderators may send audio. Regular participants requestsendAudioand a host grants it. Both defaults are editable per call type. - Data & config from the CLI:
getstream api <Endpoint> --request '{...}'for users, call types, and call members - iOS integration skill: invoke
/stream-swiftin your agent for SwiftUI/UIKit setup patterns;/stream-docssearches live SDK docs with citations - Docs index for LLMs:
https://getstream.io/video/docs/ios/llms.txt - Markdown endpoints: append
.mdto 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

