<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Stream</title>
    <description>The latest articles on DEV Community by Stream (getstreamhq).</description>
    <link>https://dev.to/getstreamhq</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F10516%2Fc06afb0d-1f23-4445-b790-2f42471563d1.png</url>
      <title>DEV Community: Stream</title>
      <link>https://dev.to/getstreamhq</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/getstreamhq"/>
    <language>en</language>
    <item>
      <title>What Is Shadow Banning, and How Does It Work as a Moderation Technique?</title>
      <dc:creator>Sarah Lindauer</dc:creator>
      <pubDate>Mon, 26 Oct 2026 17:22:03 +0000</pubDate>
      <link>https://dev.to/getstreamhq/what-is-shadow-banning-and-how-does-it-work-as-a-moderation-technique-3m4</link>
      <guid>https://dev.to/getstreamhq/what-is-shadow-banning-and-how-does-it-work-as-a-moderation-technique-3m4</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;Shadow banning limits a user's reach without telling them. They can keep posting and see their own content as normal, while other people see it less or not at all.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Platforms use it mostly against spam accounts and bots, because a banned spammer opens a new account and a shadow-banned one never learns anything changed.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;A shadow ban only works if every read path applies the same server-side check, including search, push notifications, and channel previews.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;&lt;em&gt;It does not change behavior. Users told why their content was removed break the rules less often, so shadow bans suit spam and bots, and everyone else should be told what they did.&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;a href="https://getstream.io/glossary/shadow-ban/" rel="noopener noreferrer"&gt;Shadow banning&lt;/a&gt; is a moderation technique that reduces a user's or their content's visibility without clearly telling them a restriction has been applied. The user can keep posting, and everything looks normal from their own account. Other people see their content less often, or not at all.&lt;/p&gt;

&lt;p&gt;Why bother with this technique? Why not just ban the user outright? Because a bad actor can keep acting badly when they can see they’ve been banned. A spammer whose account gets suspended opens a new one and starts again. Under a shadow ban, they never find out. They keep posting, nobody sees it, and it can take days to realize the links have stopped getting clicks.&lt;/p&gt;

&lt;p&gt;The cost is transparency. A user whose reach collapses can't tell whether they were shadow-banned, caught by an algorithm change, or posted something nobody cared about, which is why the term gets attached to every dip in engagement on every &lt;a href="https://getstream.io/blog/social-media-moderation/" rel="noopener noreferrer"&gt;social media platform&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Does a Shadow Ban Work?
&lt;/h2&gt;

&lt;p&gt;The platform accepts the content as normal, stores it, and then leaves it out when it builds what other people see. Nothing changes on the author's side. The request that created the post returned success, and the post appears in their own feed and on their profile. The restriction applies on the read side, when someone else's feed, channel, search results, or notifications are assembled.&lt;/p&gt;

&lt;p&gt;Online communities have used shadow banning since the 1980s, then under names like hellbanning, stealth banning and ghost banning. &lt;a href="https://blog.codinghorror.com/suspension-ban-or-hellban/" rel="noopener noreferrer"&gt;Jeff Atwood describes the intended effect on trolls&lt;/a&gt;: nobody ever responds, so they get bored and leave.&lt;/p&gt;

&lt;p&gt;On social media platforms, the term now covers a wider set of restrictions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Keep a post out of recommendations, trending pages and algorithmic feeds, so it only reaches existing followers
&lt;/li&gt;
&lt;li&gt;Drop an account from search results and hashtag pages
&lt;/li&gt;
&lt;li&gt;Rank its posts and comments much lower
&lt;/li&gt;
&lt;li&gt;Collapse its replies behind a "show more" control
&lt;/li&gt;
&lt;li&gt;Hide its comments from everyone except the author
&lt;/li&gt;
&lt;li&gt;Stop sending notifications when the account interacts with someone&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The restriction can apply to an entire account, a single post, a feature like search, or a set period. X's &lt;a href="https://help.x.com/en/rules-and-policies/enforcement-options" rel="noopener noreferrer"&gt;enforcement options&lt;/a&gt; show the range of what's possible on one platform. A restricted post can be excluded from search, trends, and the For You timeline; limited to the author's profile; downranked in replies; or have likes and reposts switched off. Since 2023, a post restricted this way carries a label, so the author and everyone else can see that its reach was limited.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Shadow Ban Someone Instead of Banning Them Outright?
&lt;/h2&gt;

&lt;p&gt;Because it works on spam accounts and bots, as long as the chat is busy enough that one user getting no replies looks normal. &lt;a href="https://getstream.io/moderation/docs/api/flag-mute-ban/" rel="noopener noreferrer"&gt;Stream moderation docs&lt;/a&gt; give livestreams as the example. In a channel with six people, the silence gives it away.&lt;/p&gt;

&lt;p&gt;Reduced visibility also suits content that isn’t bad enough to delete. A post that only just violates guidelines, or one carrying misinformation that breaks no rule, can be demoted instead. TikTok does this with a notice. A video can stay on your profile and still be &lt;a href="https://support.tiktok.com/en/safety-hc/account-and-user-safety/content-violations-and-bans" rel="noopener noreferrer"&gt;ineligible for the For You page&lt;/a&gt;, with the reason in its analytics and an appeal button.&lt;/p&gt;

&lt;p&gt;How it has played out on the big platforms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reddit&lt;/strong&gt; ran shadow bans as its only account-level punishment until 2015, when it called them &lt;a href="https://techcrunch.com/2015/11/11/reddit-account-suspensions/" rel="noopener noreferrer"&gt;“great for dealing with bots/spam rings, but woefully inadequate for real human beings”&lt;/a&gt; and switched to suspensions that state the reason and the duration. People had been posting for weeks, wondering why nobody answered them.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instagram’s&lt;/strong&gt; Restrict feature is a shadow ban on one relationship. A restricted person’s comments on your posts are visible only to them, and Instagram built it after &lt;a href="https://about.fb.com/news/2019/10/empowering-our-community-to-stand-up-to-bullying/" rel="noopener noreferrer"&gt;young people said&lt;/a&gt; they were reluctant to block or report peers who bullied them.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Facebook&lt;/strong&gt; hides some rule-breaking comments and deletes others, warning the author. A &lt;a href="https://arxiv.org/abs/2210.10454" rel="noopener noreferrer"&gt;study of 412 million comments&lt;/a&gt; found that deletion reduced how often the author broke community guidelines afterward, while hiding had no significant effect.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What a shadow ban doesn’t do is change behavior. A &lt;a href="https://shagunjhaver.com/research/articles/jhaver-2019-transparency/" rel="noopener noreferrer"&gt;study of 32 million Reddit posts&lt;/a&gt; found that users given a reason for a removal were less likely to have posts removed later. If you want someone to post differently, tell them what they did.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Triggers a Shadow Ban?
&lt;/h2&gt;

&lt;p&gt;A shadow ban is an enforcement action, and a separate decision triggers it. The decision can come from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An automated classifier that scores a message, image, or video against the platform's content moderation policies and crosses a threshold.
&lt;/li&gt;
&lt;li&gt;Spam signals such as the same link posted hundreds of times, an unusually high posting rate, a brand-new account with no profile photo, or a pattern of other users muting and blocking the account.
&lt;/li&gt;
&lt;li&gt;User reports, once enough of them land on one account.
&lt;/li&gt;
&lt;li&gt;Repeated community guidelines violations, where earlier strikes were warnings or removals, and the next step is reduced distribution.
&lt;/li&gt;
&lt;li&gt;A human moderator who reviewed the account and decided a quiet restriction is the right call.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On Stream, rules are the automated route. A &lt;a href="https://getstream.io/moderation/docs/node/configuration/rules/" rel="noopener noreferrer"&gt;rule&lt;/a&gt; combines conditions, a threshold, and an action. The spam rule in our docs fires when a user posts five messages labeled as scams or platform bypass within an hour, or fifty messages of any kind in that hour, and the action can be a ban with &lt;code&gt;shadow_ban&lt;/code&gt; set to true and a duration attached, or a &lt;code&gt;shadow_block&lt;/code&gt; on the content itself. A cooldown period stops the rule from re-firing the moment a moderator lifts the ban.&lt;/p&gt;

&lt;p&gt;The manual route is a moderator clicking shadow ban in the dashboard, or your backend making the &lt;a href="https://getstream.io/moderation/docs/node/content-moderation/flag-mute-ban/" rel="noopener noreferrer"&gt;same call&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;moderation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ban&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;target_user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user_to_shadow_ban&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;banned_by_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;moderatorId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;channel_cid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;livestream:game-night&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Repeated promo links&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1440&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Leave out &lt;code&gt;channel_cid&lt;/code&gt;, and the ban covers the whole app. &lt;code&gt;timeout&lt;/code&gt; is in minutes, so this one expires after a day, and the reason is stored with the ban so whoever looks at it later knows why it exists.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Does It Take to Enforce a Shadow Ban Properly?
&lt;/h3&gt;

&lt;p&gt;Every read path must apply the same check, and the check must run on the server. Hiding a message in the main message list does nothing for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;a href="https://getstream.io/glossary/push-notifications/" rel="noopener noreferrer"&gt;push notification&lt;/a&gt; that already went out with its text
&lt;/li&gt;
&lt;li&gt;The search index that returns it
&lt;/li&gt;
&lt;li&gt;The channel preview that shows it as the last message
&lt;/li&gt;
&lt;li&gt;The history request that returns it when the user pulls to refresh.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each is a separate code path, and the restriction has to hold on every one of them, or the reader sees the content and the author gets a hint something is off.&lt;/p&gt;

&lt;p&gt;The decision is keyed to who is asking:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh7ystu6q33irhx46y4kt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh7ystu6q33irhx46y4kt.png" alt="Shadow banning diagram" width="800" height="1179"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In Stream Chat, messages from a shadow-banned user carry &lt;code&gt;shadowed: true&lt;/code&gt; for everyone except the author, &lt;a href="https://getstream.io/moderation/docs/api/flag-mute-ban/" rel="noopener noreferrer"&gt;whose copy looks normal&lt;/a&gt; even in the raw API response. Your app hides the flagged messages for everyone else. &lt;a href="https://getstream.io/moderation/docs/node/content-moderation/flag-mute-ban/" rel="noopener noreferrer"&gt;queryBannedUsers&lt;/a&gt; returns who issued each ban, the reason, the expiry, and whether it was a shadow ban.&lt;/p&gt;

&lt;p&gt;In the EU, Article 17 of the &lt;a href="https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX%3A32022R2065" rel="noopener noreferrer"&gt;Digital Services Act&lt;/a&gt; requires a statement of reasons for any visibility restriction, demotion included. The one exception is deceptive high-volume commercial content, that is, spam.&lt;/p&gt;

&lt;h3&gt;
  
  
  How Can You Tell If an Account Has Been Shadow Banned?
&lt;/h3&gt;

&lt;p&gt;From the outside, you can’t be certain, only narrow it down. X’s own list of &lt;a href="https://help.x.com/en/rules-and-policies/x-reach-limited" rel="noopener noreferrer"&gt;reasons a post might not be seen&lt;/a&gt; runs from rule enforcement to other users muting you, ranking, experiments, and technical problems, and they all look the same from the author’s side. Search “shadow banned” alongside LinkedIn, TikTok, or Instagram and you’ll find people certain that’s why their posts aren’t showing up.&lt;/p&gt;

&lt;p&gt;What you can check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open the post’s direct link logged out or from a second account. If it loads, the post is still public, and any restriction is on distribution.
&lt;/li&gt;
&lt;li&gt;Search for the account from that second account. Reachable by link but missing from search points to a search restriction.
&lt;/li&gt;
&lt;li&gt;Check the platform’s status page. Instagram’s Account Status shows whether a professional account is &lt;a href="https://techcrunch.com/2022/12/07/instagrams-new-transparency-tools-will-tell-you-if-your-content-is-ineligible-to-be-recommended/" rel="noopener noreferrer"&gt;eligible for recommendation&lt;/a&gt; on the Explore page, and TikTok flags a video that’s ineligible for the For You feed in its analytics.
&lt;/li&gt;
&lt;li&gt;Compare with similar accounts. If they dropped the same week, the algorithm changed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Twitter said in 2018 that it &lt;a href="https://blog.x.com/en_us/topics/company/2018/Setting-the-record-straight-on-shadow-banning" rel="noopener noreferrer"&gt;didn't shadow ban&lt;/a&gt;, and the Twitter Files released in 2022 showed &lt;a href="https://gizmodo.com/elon-musk-twitter-files-bari-weiss-part-two-shadow-ban-1849876635" rel="noopener noreferrer"&gt;search and trends blocklists&lt;/a&gt; that it called visibility filtering, and its critics called content suppression. A &lt;a href="https://www.ndss-symposium.org/wp-content/uploads/2026-s718-paper.pdf" rel="noopener noreferrer"&gt;study of 40 million tweets&lt;/a&gt; found that tweets with external links got as little as an eighth of the visibility of tweets without them, and even then the authors couldn’t prove the recommendation algorithm was the cause, because they can’t inspect it.&lt;/p&gt;

&lt;p&gt;If you’re running the community, keep shadow bans for spam accounts and bots, put an expiry on every one, and tell everyone else what they did. On Stream, both are the same &lt;code&gt;ban&lt;/code&gt; call with &lt;code&gt;shadow&lt;/code&gt; set one way or the other, and the &lt;a href="https://getstream.io/moderation/docs/node/content-moderation/review-queue/" rel="noopener noreferrer"&gt;review queue&lt;/a&gt; and &lt;a href="https://getstream.io/moderation/docs/node/content-moderation/appeals/" rel="noopener noreferrer"&gt;appeals&lt;/a&gt; sit in the same dashboard.  &lt;/p&gt;

</description>
      <category>moderation</category>
      <category>shadowbanning</category>
      <category>aimoderation</category>
    </item>
    <item>
      <title>How Does a Message Queue Power Reliable Chat Delivery?</title>
      <dc:creator>Sarah Lindauer</dc:creator>
      <pubDate>Mon, 26 Oct 2026 16:58:42 +0000</pubDate>
      <link>https://dev.to/getstreamhq/how-does-a-message-queue-power-reliable-chat-delivery-27lc</link>
      <guid>https://dev.to/getstreamhq/how-does-a-message-queue-power-reliable-chat-delivery-27lc</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;A message queue is a buffer between services. A producer publishes a message, the broker stores it, and a consumer processes it and sends an acknowledgment, so neither service has to be available when the other runs.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;A queue gives each message to one consumer, and pub/sub gives a copy to every subscriber. Chat delivery uses both.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Delivery is at-least-once, so a unique key on recipient ID and message ID makes a redelivered message harmless. The outbox pattern keeps the saved message and its queue event in sync.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Queues keep order per key, so the conversation ID is the key. A failing message blocks the ones behind it in that conversation.&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Connections drop. Servers restart. The friend you just messaged is away from their desk.&lt;/p&gt;

&lt;p&gt;Chat messages are asynchronous by nature. You send one now, and it gets read later, whether that's in two seconds or tomorrow morning. The backend in between is a distributed system, and it works the same way. Each service does its part and passes the message along without waiting for the next one.&lt;/p&gt;

&lt;p&gt;A &lt;a href="https://getstream.io/glossary/message-queuing/" rel="noopener noreferrer"&gt;message queue&lt;/a&gt; is where that handoff happens. If a connection drops or a server restarts mid-process, the message stays in the queue, and the next attempt picks it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is a Message Queue?
&lt;/h2&gt;

&lt;p&gt;A message queue is a buffer that services use for asynchronous communication. One service publishes messages to the queue, and the queue holds them until another service consumes and acknowledges them. The two services never call each other directly, so neither has to be available when the other one runs.&lt;/p&gt;

&lt;p&gt;A message passes through these components:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;producer&lt;/strong&gt; creates a message and publishes it to the broker. Its request ends when the broker confirms it has stored the message.
&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;message broker&lt;/strong&gt; is the server that hosts the queue. It persists each message and delivers it to a consumer, either by pushing it over an open connection or by answering the consumer's poll.
&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;consumer&lt;/strong&gt; is the service that does the work. It receives a message from the broker, processes it, and sends an acknowledgment back.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbwfocmzn758d5cin01qk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbwfocmzn758d5cin01qk.png" alt="How a message queue delivers a chat message" width="798" height="97"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The publisher confirm and the consumer acknowledgment are &lt;a href="https://www.rabbitmq.com/docs/confirms#relation" rel="noopener noreferrer"&gt;independent&lt;/a&gt;. A confirm tells you the broker has the message, and says nothing about whether any consumer has processed it.&lt;/p&gt;

&lt;p&gt;Message brokers can be self-hosted or hosted:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Self-hosted: RabbitMQ, IBM MQ, Apache ActiveMQ, Apache Kafka
&lt;/li&gt;
&lt;li&gt;Hosted: Amazon SQS, Google Pub/Sub, Azure Service Bus&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your code connects to a broker over a protocol such as AMQP (which RabbitMQ uses) or through an API such as JMS (which Java applications use with IBM MQ).&lt;/p&gt;

&lt;p&gt;In a microservices architecture, a new chat message can fan out to several services, such as delivery, push notifications, moderation, and search indexing. If the &lt;a href="https://getstream.io/chat/" rel="noopener noreferrer"&gt;chat API&lt;/a&gt; called each one directly, one slow service would hold up every send. With a queue, the API can enqueue a message and return, keeping the services decoupled.&lt;/p&gt;

&lt;p&gt;A queue is also scalable on the consumer side. When messages arrive faster than they're processed, they wait in the queue, and you &lt;a href="https://www.rabbitmq.com/tutorials/tutorial-two-python" rel="noopener noreferrer"&gt;add more consumers to work through the backlog&lt;/a&gt;. The broker balances the load by giving each message to one consumer, so throughput increases without changing the producer.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Is a Message Queue Different From Pub/Sub?
&lt;/h2&gt;

&lt;p&gt;A message queue delivers each message to exactly one consumer, while publish/subscribe (pub/sub) delivers a copy to every subscriber. &lt;/p&gt;

&lt;p&gt;Chat delivery needs both messaging patterns. A new message must reach several services, and a single queue would send it to only one. So each message goes through both:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The backend publishes one MessageCreated event.
&lt;/li&gt;
&lt;li&gt;Delivery, push notifications, and search indexing each subscribe to that event and get a copy in their own queues.
&lt;/li&gt;
&lt;li&gt;A pool of workers shares each queue, so one worker handles each copy.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is an event-driven architecture. The chat API doesn't know which services consume the event, so you can add a subscriber, such as analytics, without changing the API.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Does a Message Queue Deliver a Chat Message?
&lt;/h2&gt;

&lt;p&gt;The chat API commits the message to its database, a relay publishes it to the message queue, and a delivery worker consumes it and writes it to the recipient’s inbox. Say Alice sends Bob “Can you send me the deployment notes?” from her phone, and Bob has the app open on his laptop. The message goes through these steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Alice’s app generates a UUID for the message before sending anything and reuses that ID as the idempotency key on every resend.
&lt;/li&gt;
&lt;li&gt;The chat API inserts the message and an outbox row in a single database transaction, then responds to Alice.
&lt;/li&gt;
&lt;li&gt;A relay publishes the outbox row to the broker, keyed by conversation ID, and marks the row as published once it receives the publisher confirm.
&lt;/li&gt;
&lt;li&gt;The broker delivers the message to one delivery worker and keeps it, unacknowledged, until that worker reports back.
&lt;/li&gt;
&lt;li&gt;The worker inserts an inbox row for Bob, keyed on recipient ID and message ID, commits, and then acknowledges the message. In a group conversation, this step fans out, creating one inbox row per member.
&lt;/li&gt;
&lt;li&gt;The worker sends the message to the real-time gateway, which holds Bob’s WebSocket connection and pushes the message to his laptop.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The JSON body of Alice’s request carries the ID her app generated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"019a1d6e-7c2b-7f10-9a44-3b5e2c8d1f07"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"conversation_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"chat-42"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sender_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"alice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Can you send me the deployment notes?"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If Alice’s connection drops before the response arrives, her app resends the same body. The message ID is the primary key, so the second insert conflicts with the first, and the API returns the message it already stored. The Stream iOS SDK &lt;a href="https://getstream.io/chat/docs/sdk/ios/basics/offline-support/" rel="noopener noreferrer"&gt;queues a send that fails offline&lt;/a&gt; and retries it when the device comes back online, even if the app was killed in between.&lt;/p&gt;

&lt;p&gt;Step 2 is the transactional &lt;a href="https://microservices.io/patterns/data/transactional-outbox.html" rel="noopener noreferrer"&gt;outbox pattern&lt;/a&gt;. Inserting the message and then publishing to the broker would be a dual write, with no transaction spanning the database and the broker. If the process crashed after the insert and before the publish, Bob’s delivery pipeline would never receive a message that Alice can see in her history. The outbox pattern stores the event in the same transaction as the message, so the event is sent if and only if the transaction commits.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sender_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(:&lt;/span&gt;&lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'chat-42'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'alice'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1842&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;outbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(:&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'MessageCreated'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alice’s request returns as soon as this transaction commits. Everything after it is asynchronous messaging between backend services, so a slow or restarting worker delays Bob’s copy without failing Alice’s send.&lt;/p&gt;

&lt;p&gt;In queue terms, the relay in step 3 is the producer and the delivery worker is the consumer. The relay finds new outbox rows in one of these ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;a href="https://microservices.io/patterns/data/polling-publisher.html" rel="noopener noreferrer"&gt;polling publisher&lt;/a&gt; queries the outbox table at intervals, which works with any SQL database.
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://microservices.io/patterns/data/transaction-log-tailing.html" rel="noopener noreferrer"&gt;Transaction log tailing&lt;/a&gt;, a form of change data capture, reads the database’s own log, such as the Postgres WAL or the MySQL binlog, and publishes each outbox insert it finds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each row becomes one message, with the row’s JSON as the payload and headers that carry metadata such as the message ID and a timestamp.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftc1o5o464d5vw9k0b9op.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftc1o5o464d5vw9k0b9op.png" alt="Message queue diagram" width="552" height="1999"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The relay waits for the publisher confirm because a client that has written a publish to its socket &lt;a href="https://www.rabbitmq.com/docs/confirms#publisher-confirms" rel="noopener noreferrer"&gt;can’t assume the message reached the server&lt;/a&gt;. It can still crash after the confirm arrives and before it marks the row as published, so that it may publish the same event twice. That’s acceptable, because the worker’s insert in step 5 is written so that a second run changes nothing.&lt;/p&gt;

&lt;p&gt;The worker’s ack in step 5 only proves that a backend service finished its write. Knowing whether Bob’s laptop received the message, or whether Bob read it, takes separate events sent from his device.&lt;/p&gt;

&lt;p&gt;In Stream Chat, a message is &lt;a href="https://getstream.io/chat/docs/react/message-delivery-and-read-status/" rel="noopener noreferrer"&gt;sent when it reaches our server, delivered when the recipient’s device confirms it&lt;/a&gt; with a message.delivered event, and read when the recipient marks the channel read. Delivery tracking is off by default, and you turn it on per channel type with delivery_events.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Happens When a Worker Crashes or a Message Is Delivered Twice?
&lt;/h2&gt;

&lt;p&gt;The broker delivers the message again, so your write must be safe to run twice. If a worker crashes or loses its connection before it acknowledges a message, the broker gives that message to another worker. This is at-least-once delivery, and it’s where a queue’s fault tolerance comes from.&lt;/p&gt;

&lt;p&gt;Each message broker has its own trigger for redelivery:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Broker&lt;/th&gt;
&lt;th&gt;Redelivers when&lt;/th&gt;
&lt;th&gt;Default timer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;RabbitMQ&lt;/td&gt;
&lt;td&gt;The &lt;a href="https://www.rabbitmq.com/docs/confirms#automatic-requeueing" rel="noopener noreferrer"&gt;AMQP channel or connection closes&lt;/a&gt;, or the worker nacks with requeue&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://www.rabbitmq.com/docs/consumers#acknowledgement-timeout" rel="noopener noreferrer"&gt;30-minute ack timeout&lt;/a&gt; on quorum queues&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon SQS&lt;/td&gt;
&lt;td&gt;The &lt;a href="https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html" rel="noopener noreferrer"&gt;visibility timeout expires&lt;/a&gt; before the message is deleted&lt;/td&gt;
&lt;td&gt;30 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kafka consumer group&lt;/td&gt;
&lt;td&gt;A new consumer takes over the partition and &lt;a href="https://kafka.apache.org/42/design/design/" rel="noopener noreferrer"&gt;starts from the last saved position&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Pub/Sub&lt;/td&gt;
&lt;td&gt;The &lt;a href="https://docs.cloud.google.com/pubsub/docs/subscription-properties" rel="noopener noreferrer"&gt;ack deadline passes&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;10 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Say a worker commits Bob’s inbox row and dies before its &lt;code&gt;ack&lt;/code&gt; reaches the broker. The broker can’t see your database. It only knows the ack never arrived, so it gives the message to a second worker, which runs the same insert:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;recipient_inbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recipient_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'bob'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'chat-42'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1842&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recipient_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTHING&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The unique key on &lt;code&gt;(recipient_id, message_id)&lt;/code&gt; makes the second insert do nothing; the second worker &lt;code&gt;ack&lt;/code&gt;s, and Bob sees one message. The ack has to come after the commit. A worker that &lt;code&gt;ack&lt;/code&gt;s first and crashes before committing tells the broker the work is done, while Bob has no inbox row.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fek6to3usjq3tss19f8pc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fek6to3usjq3tss19f8pc.png" alt="Diagram Showing What Happens When a Worker Crashes or a Message Is Delivered Twice" width="800" height="672"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The alternative is at-most-once delivery. With RabbitMQ’s automatic acknowledgment mode, a message counts as delivered as soon as it’s sent, &lt;a href="https://www.rabbitmq.com/docs/confirms#acknowledgement-modes" rel="noopener noreferrer"&gt;trading safety for higher throughput&lt;/a&gt;, and a worker that crashes loses it. Broker features labeled “exactly once,” such as &lt;a href="https://kafka.apache.org/42/configuration/producer-configs/" rel="noopener noreferrer"&gt;Kafka’s idempotent producer&lt;/a&gt; and &lt;a href="https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/FIFO-queues-exactly-once-processing.html" rel="noopener noreferrer"&gt;SQS FIFO deduplication&lt;/a&gt;, only prevent a producer from retrying and writing the message twice. They don’t cover the worker’s database write, so the unique key is still needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do Message Queues Keep Chat Messages in Order?
&lt;/h2&gt;

&lt;p&gt;Queues keep order per key, so you give every message in a conversation the same key. Chat needs a stable order inside each conversation, and it doesn't matter how messages from chat-42 and chat-91 interleave. Each broker has its own name for that key:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Kafka uses the record key. Records with the same key go to the same partition, so set the key to the conversation ID.
&lt;/li&gt;
&lt;li&gt;In Amazon SQS FIFO queues (first-in, first-out), messages that share a MessageGroupId are processed in strict order.
&lt;/li&gt;
&lt;li&gt;Google Pub/Sub has an ordering key, but it only takes effect when you enable &lt;a href="https://docs.cloud.google.com/pubsub/docs/subscription-properties" rel="noopener noreferrer"&gt;message ordering&lt;/a&gt; on the subscription.
&lt;/li&gt;
&lt;li&gt;In RabbitMQ, a consistent hash exchange sends &lt;a href="https://github.com/rabbitmq/rabbitmq-server/blob/main/deps/rabbitmq_consistent_hash_exchange/README.md" rel="noopener noreferrer"&gt;all messages with the same routing key to the same queue&lt;/a&gt;, and a &lt;a href="https://www.rabbitmq.com/docs/consumers#single-active-consumer" rel="noopener noreferrer"&gt;single active consumer&lt;/a&gt; on each queue processes them one at a time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With strict ordering, a message that keeps failing blocks every message behind it. In an SQS FIFO queue, &lt;a href="https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html" rel="noopener noreferrer"&gt;later messages in the same group are held back&lt;/a&gt; while one is in flight, so the conversation stalls until the failing message is moved to the dead-letter queue.&lt;/p&gt;

&lt;p&gt;A Kafka partition blocks the same way, because a consumer group's &lt;a href="https://kafka.apache.org/42/design/design/" rel="noopener noreferrer"&gt;position in each partition is a single integer&lt;/a&gt; and it can't skip a failing record and come back to it later. Moving the record to a separate retry topic lets the partition continue, and the newer messages are then processed ahead of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do You Need to Build This Delivery Pipeline Yourself?
&lt;/h2&gt;

&lt;p&gt;Building it means running message queues, an outbox relay, delivery workers, and a sync service for as long as your app has chat. If you'd rather have your engineers working on the rest of your app, &lt;a href="https://getstream.io/chat/" rel="noopener noreferrer"&gt;Stream Chat&lt;/a&gt; runs the server side on &lt;a href="https://getstream.io/chat/docs/go-golang/architecture-and-benchmark/" rel="noopener noreferrer"&gt;our Go backend and global edge network&lt;/a&gt;, which we've &lt;a href="https://getstream.io/blog/scaling-chat-5-million-concurrent-connections/" rel="noopener noreferrer"&gt;benchmarked&lt;/a&gt; at 5 million concurrent connections in one channel, and our SDKs handle the client side.&lt;/p&gt;

&lt;p&gt;When your own backend needs chat events, our &lt;a href="https://getstream.io/chat/docs/node/webhooks_overview/" rel="noopener noreferrer"&gt;webhooks retry failed deliveries and can send events to SQS or SNS&lt;/a&gt;. You can set up any of our SDKs from the &lt;a href="https://getstream.io/chat/docs/" rel="noopener noreferrer"&gt;Chat docs&lt;/a&gt;.  &lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>What Actually Happens When Android's Hardware Encoder Hits SVC</title>
      <dc:creator>Pratim Mallick</dc:creator>
      <pubDate>Wed, 21 Oct 2026 03:57:53 +0000</pubDate>
      <link>https://dev.to/getstreamhq/android-hardware-vp9-accepts-spatial-svc-then-drops-the-layers-17gd</link>
      <guid>https://dev.to/getstreamhq/android-hardware-vp9-accepts-spatial-svc-then-drops-the-layers-17gd</guid>
      <description>&lt;p&gt;A livestream can look perfectly healthy and still get flagged as broken. That's the situation I kept running into on Android, and it took a while to figure out why.&lt;/p&gt;

&lt;p&gt;I work on the video team at Stream, where I spend most of my time on the Android SDK. Video calls and live streams on Stream use a system called scalable video coding (SVC), which lets a phone encode video once and lets the server deliver different resolutions to viewers based on their bandwidth. It's an efficient design, especially for mobile, where every extra encode costs battery and CPU.&lt;/p&gt;

&lt;p&gt;The setup I want to walk through here started simple, with one publisher, one viewer, and everything worked as expected. Then a second viewer joined, and the quality score on that stream dropped and never recovered. Nothing about the stream had visibly changed.&lt;/p&gt;

&lt;p&gt;That single symptom sent me down a debugging path with two convincing false leads before I found the actual cause, which turned out to be &lt;strong&gt;a limitation baked into Android's hardware video encoders themselves&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This piece is about what that debugging process looked like.&lt;/p&gt;

&lt;h2&gt;
  
  
  What SVC actually is
&lt;/h2&gt;

&lt;p&gt;To back up for a second on how SVC actually works: a single encoded stream carries multiple layers inside it. Spatial layers are different resolutions, something like 180p, 360p, and 720p. Each spatial layer also carries temporal layers, which are different frame rates, something like 7.5, 15, and 30 fps.&lt;/p&gt;

&lt;p&gt;The publisher encodes all of this once. The server, in Stream's case the SFU, looks at each subscriber's bandwidth and forwards only the layers that subscriber can handle.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc8fp6z0fitkau14oimdo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc8fp6z0fitkau14oimdo.png" alt=" " width="800" height="301"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Simulcast&lt;/strong&gt; solves the same problem differently.&lt;/p&gt;

&lt;p&gt;The publisher encodes three separate streams at three separate resolutions, and the server picks which stream to forward. It works, but it costs three encoders' worth of CPU and battery on the sending device. On mobile, where compute and battery are both limited, SVC is the better fit, assuming the encoder can actually produce the layers it's asked for.&lt;/p&gt;

&lt;p&gt;That assumption turned out to be the entire problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup that worked
&lt;/h2&gt;

&lt;p&gt;Here's where the debugging starts.&lt;/p&gt;

&lt;p&gt;One publisher on Android, one subscriber on the other end. The SFU tells the publisher to send a single spatial layer with three temporal layers inside it, a mode we call L1T3. The hardware VP9 encoder handles this without issue. Network conditions were good, the stream looked good, and the SFU scored it well. This was the baseline.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo4gjmgny0po2vu7nku21.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo4gjmgny0po2vu7nku21.png" alt=" " width="800" height="260"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The moment it broke
&lt;/h2&gt;

&lt;p&gt;Then a second subscriber joined the call. With two subscribers now needing different resolutions, the SFU asked the publisher to switch modes, this time requesting three spatial layers and three temporal layers, a mode called L3T3_KEY.&lt;/p&gt;

&lt;p&gt;As soon as that switch happened, the SFU's quality score for the stream dropped and never recovered.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwdpfpwrh2hlq8ga97sh0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwdpfpwrh2hlq8ga97sh0.png" alt=" " width="800" height="272"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The obvious thing to check first was the SFU's scoring system, which grades every published stream. This stream scored badly the moment the encoder switched configurations, and stayed bad for the rest of the call.&lt;/p&gt;

&lt;p&gt;Something about producing three layers instead of one was breaking the stream, but at that point I had no idea what.&lt;/p&gt;

&lt;h2&gt;
  
  
  False lead #1: the encoder thrashing loop
&lt;/h2&gt;

&lt;p&gt;The first thing I noticed in the logs was that the hardware encoder was getting torn down and recreated over and over, roughly every 200 milliseconds. That kind of churn is never good, so I dug into why it was happening.&lt;/p&gt;

&lt;p&gt;Android's hardware VP9 encoder can't change its resolution or its buffer type while running. Any time WebRTC's quality scaler asks it to adjust resolution, the encoder has to shut down completely and start over, rather than adjusting in place. Software encoders don't have this restriction. Hardware encoders do.&lt;/p&gt;

&lt;p&gt;Here's the loop that creates.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh1998kym2vg1ekyginzt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh1998kym2vg1ekyginzt.png" alt=" " width="800" height="276"&gt;&lt;/a&gt;&lt;br&gt;
The encoder starts at 720p. It gets torn down and rebuilt for buffer type reasons. When it comes back up, it has a pile of frames waiting, which throws off the bandwidth estimator's read on the connection. The quality scaler decides 720p isn't sustainable and drops the encoder to 540p. Now the quantization parameter is calibrated for 720p bitrate but running at 540p, so the scaler tries to climb back to 720p. That triggers another full teardown and rebuild. The whole cycle repeats every five to ten seconds, and what you see as a viewer is the publisher's resolution oscillating between 720p and 540p.&lt;/p&gt;

&lt;p&gt;I confirmed this by turning off the quality scaler entirely, using WebRTC's degradation preference setting. That stopped the resolution oscillation completely. But the SFU still scored the stream as poor. The thrashing loop was real, and we fixed it, but it wasn't the reason the stream kept getting marked as low quality.&lt;/p&gt;
&lt;h2&gt;
  
  
  False lead #2: the phantom bitrate
&lt;/h2&gt;

&lt;p&gt;The next clue came from the stats. WebRTC reported the stream's target bitrate at around 71 kbps, while the encoder was clearly outputting close to 1.4 Mbps at 720p. That gap looked like it explained the quality problem on its own.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxpk7894s5smelcsoqsti.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxpk7894s5smelcsoqsti.png" alt=" " width="800" height="301"&gt;&lt;/a&gt;&lt;br&gt;
It turned out to be a reporting quirk in libWebRTC, not a real problem.&lt;/p&gt;

&lt;p&gt;WebRTC calculates target bitrate using an index that assumes simulcast, where three separate streams each have their own SSRC (a unique stream identifier), and the target bitrate for a given layer comes from summing values across those indexes. In SVC, there's only one stream and one SSRC, so the index is always zero. That means WebRTC's target bitrate calculation always lands on the base layer's slice of the total bitrate, around 70 kbps, regardless of what the encoder is actually sending.&lt;/p&gt;

&lt;p&gt;The wrong number comes from SendStatisticsProxy in libWebRTC. It maps each SSRC to a simulcast index, then reports the bitrate of that spatial layer as the target bitrate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// send_statistics_proxy.cc
// Store target bitrates per substream stats.
for (auto&amp;amp; [ssrc, substream] : stats_.substreams) {
  std::optional&amp;lt;size_t&amp;gt; simulcast_index;
  for (size_t i = 0; i &amp;lt; rtp_config_.ssrcs.size(); ++i) {
    if (rtp_config_.ssrcs[i] == ssrc) {
      simulcast_index = i;
      break;
    }
  }
  if (!simulcast_index.has_value()) {
    substream.target_bitrate = std::nullopt;
    continue;
  }
  substream.target_bitrate =
      DataRate::BitsPerSec(allocation.GetSpatialLayerSum(*simulcast_index));
  if (substream.target_bitrate == DataRate::Zero()) {
    substream.target_bitrate = std::nullopt;
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The function it calls, GetSpatialLayerSum, adds up all the temporal layers on one spatial layer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// video_bitrate_allocation.cc
// Get the sum of all the temporal layer for a specific spatial layer.
uint32_t VideoBitrateAllocation::GetSpatialLayerSum(
    size_t spatial_index) const {
  RTC_CHECK_LT(spatial_index, kMaxSpatialLayers);
  return GetTemporalLayerSum(spatial_index, kMaxTemporalStreams - 1);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is correct for simulcast. SSRC 0, 1, and 2 map to index 0, 1, and 2, so S0, S1, and S2 each get their own target bitrate.&lt;/p&gt;

&lt;p&gt;It is wrong for VP9 SVC. There is only one SSRC, so simulcast_index is always 0. The stat becomes GetSpatialLayerSum(0), which covers the S0 layer only. S1 and S2 never count toward the target bitrate. On L3T3, that S0 slice is the ~71 kbps I saw.&lt;/p&gt;

&lt;p&gt;Once I understood the formula, the mismatch made sense.&lt;/p&gt;

&lt;p&gt;The encoder was sending the full bitrate as intended. The stats weren't built to report it correctly for a single-SSRC stream. Another dead end, but a useful one to know if you're ever debugging SVC stats and the numbers don't add up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real root cause
&lt;/h2&gt;

&lt;p&gt;With both of those explanations ruled out, I checked whether the encoder was actually producing three layers in the first place.&lt;/p&gt;

&lt;p&gt;It wasn't.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbicwknmr2p6ynwqgvhql.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbicwknmr2p6ynwqgvhql.png" alt=" " width="800" height="260"&gt;&lt;/a&gt;&lt;br&gt;
The hardware VP9 encoder was only ever producing one layer, the highest one requested, and giving that single layer the entire bitrate. It never produced the two lower spatial layers. The SFU expected three layers because it had asked for L3T3, got one flat 720p stream instead, and marked the stream as poor, which was the accurate read of what was happening.&lt;/p&gt;

&lt;p&gt;To rule out a device-specific issue, we tested across Samsung and Pixel devices, covering both Qualcomm and Tensor chipsets. Every device showed the same behavior. Hardware VP9 encoding on Android has this limitation across the platform, confirmed on every device and chipset we tested, regardless of brand.&lt;/p&gt;

&lt;p&gt;Software VP9, running through libvpx, doesn't have this problem. It produces all three spatial layers correctly and doesn't need to tear itself down to change resolution. The tradeoff is CPU and battery, which is a real cost, and the reason nobody wants to run software encoding by default in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this looks like at scale
&lt;/h2&gt;

&lt;p&gt;To see how much this actually mattered outside of a controlled test, we looked at real production data from one of Stream's livestreaming customers using VP9 across platforms.&lt;/p&gt;

&lt;p&gt;In that data, Android streams using the hardware VP9 encoder were consistently scored as poor. Streams using the VP9 software did noticeably better, and the remaining problems stemmed from CPU and battery limits rather than missing layers.&lt;/p&gt;

&lt;p&gt;Neither path fully solves the problem.&lt;/p&gt;

&lt;p&gt;Hardware can't produce the layers SVC needs. Software can, but not without a real device cost. And because quality scores show up in the UI as network indicators, most people watching would assume the publisher had a bad connection.&lt;/p&gt;

&lt;p&gt;In most of these cases, the network was fine. The hardware just wasn't capable of what was being asked of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can you detect this ahead of time?
&lt;/h2&gt;

&lt;p&gt;Given all of this, the natural next question is whether you can detect this ahead of time and avoid asking a device to do something its encoder can't handle.&lt;/p&gt;

&lt;p&gt;Android's MediaCodec APIs let you query a fair amount about a given hardware encoder: supported resolutions, bitrates, profile levels, and temporal layering schemas. Android 17 (API 37) added a new method, getSupportedLayeringSchemas(), which sounded promising. In practice, it only returns temporal schemas like webrtc.svc.l1tN. There's no API that reports whether a hardware encoder can produce spatial layers.&lt;/p&gt;

&lt;p&gt;That means there's no way to check in advance whether a given device's hardware VP9 encoder actually supports the SVC mode you're about to ask it for. The only way to find out is to ask it and inspect the resulting bitstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves us: broadcast mode
&lt;/h2&gt;

&lt;p&gt;Given that neither hardware nor software VP9 fully solves this on Android, we've been building an alternative approach on the SFU side, which we're calling broadcast mode. It's still in development and not yet in production.&lt;/p&gt;

&lt;p&gt;The idea is to stop asking the publisher's device to produce multiple layers at all. Instead, the publisher sends one clean stream at whatever hardware codec and resolution the device handles best. The server then transcodes that single stream into the quality ladder viewers need, generating the lower resolutions server-side instead of on the device.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fazf6shnkxg1tepeccv4e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fazf6shnkxg1tepeccv4e.png" alt=" " width="800" height="260"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This removes the CPU drain, the missing spatial layers, and the encoder recreation problem, since the phone only has to do one thing it's already good at. The tradeoff moves to the server, where transcoding adds real compute cost. That's not something we'd turn on for every use case, but it fits broadcast-style setups with one publisher and many viewers well, which is exactly the case where this problem shows up most.&lt;/p&gt;

&lt;p&gt;We're planning to make this available as an opt-in SDK option for livestream and broadcast use cases once it's ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;A few things I'd want any developer building live video on Android to take away from this.&lt;/p&gt;

&lt;p&gt;Hardware VP9 on Android only supports temporal SVC. Spatial layers are silently dropped, not degraded, dropped.&lt;br&gt;
Software VP9 gives you real SVC, but the CPU and battery cost is real too.&lt;br&gt;
There's currently no API on Android that lets you check spatial SVC support ahead of time. You find out from the bitstream itself.&lt;br&gt;
If you're seeing SVC quality problems on Android that seem to show up only once more than one viewer joins, check this first.&lt;/p&gt;

</description>
      <category>webrtc</category>
      <category>video</category>
      <category>android</category>
      <category>vp9</category>
    </item>
    <item>
      <title>Inside Stream's Real-Time Architecture for Trading &amp; Investing Apps</title>
      <dc:creator>Sarah Lindauer</dc:creator>
      <pubDate>Fri, 25 Sep 2026 20:10:55 +0000</pubDate>
      <link>https://dev.to/getstreamhq/inside-streams-real-time-architecture-for-trading-investing-apps-7dg</link>
      <guid>https://dev.to/getstreamhq/inside-streams-real-time-architecture-for-trading-investing-apps-7dg</guid>
      <description>&lt;p&gt;TL;DR&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;Trading apps run two real-time systems: a transaction plane (orders, execution, ledger) and an experience plane (chat, feeds, video). Users judge speed by the second one.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Stream Feeds caches fanout-on-read results as a materialized feed with per-source delta cursors, holding ~15ms p50 read latency even at 4.5M concurrent users during simulated market-open load.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Stream Chat's edge network and offline-first SDKs deliver sub-50ms API responses and scale to 5M people in a single channel.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Stream Video's SFU cascading handles a 100K-viewer stream at 225 Gbps with 0% packet loss, comfortably under the 150ms threshold for natural conversation.&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Trading has zero tolerance for latency. Exchanges measure their matching engines in microseconds, brokers measure order handling in milliseconds, and firms pay for co-location just to shave a few hundred microseconds off the round-trip to the exchange. Users bring the same expectation to the app on their phone. When a stock moves, the quote has to change immediately.&lt;/p&gt;

&lt;p&gt;Stream's &lt;a href="https://getstream.io/activity-feeds/" rel="noopener noreferrer"&gt;Feeds&lt;/a&gt;, &lt;a href="https://getstream.io/chat/" rel="noopener noreferrer"&gt;Chat&lt;/a&gt;, and &lt;a href="https://getstream.io/video/" rel="noopener noreferrer"&gt;Video&lt;/a&gt; are built for the speed of &lt;a href="https://getstream.io/chat/solutions/fintech/" rel="noopener noreferrer"&gt;a trading app&lt;/a&gt;. They sit alongside the trading system rather than inside it. &lt;strong&gt;That same speed has to hold through the bursts&lt;/strong&gt;, like a market open, an earnings release, or a sharp move in a widely held stock, when every real-time feature gets hit at once. &lt;/p&gt;

&lt;p&gt;This piece breaks down the real-time architecture trading and investing apps need. Here's how the three hold together as one system on the other side of a trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Kinds of Real-Time Inside One App
&lt;/h2&gt;

&lt;p&gt;A trading app contains two kinds of real-time infrastructure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The transaction plane&lt;/strong&gt; handles market data, order management, risk checks, routing, execution, and the ledger. It cares about ordering, idempotency, auditability, and exact financial state. A duplicated or reordered execution report is a serious bug.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The experience plane&lt;/strong&gt; handles activity feeds, notifications, chat, presence, and live video. It cares about global fanout, low perceived latency, media quality, and clean recovery when a phone drops off the network for a minute. In Stream's terms, that's three products (Feeds, Chat, and Video) that a trading platform has to get working together.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The end user doesn't see that split. Both planes show up in the same interface. A price move updates a quote in the transaction plane, and the same move triggers a price alert, a run of community posts, and an analyst going live in the experience plane.&lt;/p&gt;

&lt;p&gt;You can have the best transaction plane in the market, but the experience plane is what puts that data in front of the user in real time. If the alert arrives late or the chat lags, they'll assume your trading is slow too.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvz2dfq5g4t19whnh0op8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvz2dfq5g4t19whnh0op8.png" alt="Trading app experience plane" width="800" height="1062"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The backend is authoritative. The experience plane's job is to take data from the backend and put it in front of the user, with enough context to be useful. &lt;/p&gt;

&lt;p&gt;Say a user has an open limit order on a stock they follow, and the price hits their limit during a busy morning. In the transaction plane, the sequence looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The market-data path receives the trades that carry the price to the user's limit and updates the cached quote.
&lt;/li&gt;
&lt;li&gt;The venue executes the resting limit order and sends an execution report back to the broker.
&lt;/li&gt;
&lt;li&gt;The OMS moves the order from working to filled, after checking that it hasn't already processed that execution ID.
&lt;/li&gt;
&lt;li&gt;The ledger records the cash movement and the new position, and the portfolio read model is rebuilt from those entries.
&lt;/li&gt;
&lt;li&gt;The backend publishes domain events. &lt;code&gt;OrderFilled&lt;/code&gt; for this user, and &lt;code&gt;PriceThresholdCrossed&lt;/code&gt; for everyone whose watchlist alert sits at that price.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The transaction plane is tailor-made for this. It is both correct and fast. A duplicated execution report or an out-of-order update is a financial error, so it's built around idempotency and strict ordering. The experience plane then turns those events into what the user sees:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The &lt;code&gt;OrderFilled&lt;/code&gt; event becomes an activity in the user's portfolio timeline and a notification on their phone, showing the price and quantity recorded in the ledger.
&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;PriceThresholdCrossed&lt;/code&gt; event becomes a price alert for every user who has set one at that level, which, at a widely watched price, means thousands of alerts at once.
&lt;/li&gt;
&lt;li&gt;The user opens the stock's community channel. Recent messages load instantly from on-device storage, and new ones arrive via WebSocket as others react to the same move.
&lt;/li&gt;
&lt;li&gt;An analyst on the platform starts a live briefing on the move. That one doesn't come from the trading backend at all. The analyst starts the session from their own tools, a notification goes out to everyone who follows them, and the user joins the video session with the channel's chat alongside it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;From the user's side, this all happened at once.&lt;/strong&gt; The fill confirmation, the price alert, the discussion, and the briefing appeared within the same minute and &lt;strong&gt;looked like a single product&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Underneath, the transaction plane ensured the fill was real and the numbers were right&lt;/strong&gt;, and the experience plane ensured the user saw it while it still mattered. Neither one can do the other's job. The feed never decides whether the order filled, and the OMS was never built to fan a message out to two million phones. &lt;/p&gt;

&lt;p&gt;Effectively, you have two different feeds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;market-data feed&lt;/strong&gt; carries sequenced quotes, trades, and book updates with gap recovery, and it belongs to the transaction plane.
&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;activity feed&lt;/strong&gt; carries alerts, posts, research, and portfolio events. The feeds discussed here are the second kind.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source event&lt;/th&gt;
&lt;th&gt;What the user sees&lt;/th&gt;
&lt;th&gt;Real-time feature&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PriceThresholdCrossed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Watchlist or price alert&lt;/td&gt;
&lt;td&gt;Notification feed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OrderFilled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Portfolio activity item&lt;/td&gt;
&lt;td&gt;Activity feed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ResearchPublished&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Personalized research timeline&lt;/td&gt;
&lt;td&gt;Activity feed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AnalystPosted&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Community post and discussion&lt;/td&gt;
&lt;td&gt;Activity feed and chat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AdvisorMessageSent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Private client conversation&lt;/td&gt;
&lt;td&gt;Chat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MarketBriefingStarted&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Live commentary with chat alongside&lt;/td&gt;
&lt;td&gt;Video and chat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;KYCSessionRequested&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Identity-review call&lt;/td&gt;
&lt;td&gt;Video&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The trading backend decides what happened, and the experience plane's job is distribution. That means getting the event to the right people, in the right feed, channel, or call, quickly enough that it's still useful when it arrives. &lt;/p&gt;

&lt;p&gt;How that holds up under trading-hours load is &lt;strong&gt;where the architecture matters&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Counts as Fast in Each Plane
&lt;/h2&gt;

&lt;p&gt;The user cares that the app keeps up with the market. That is the standard. But under the hood, &lt;strong&gt;we have two systems operating on two different latency timescales.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In the transaction plane, the reference point is other machines. The venue matches orders in microseconds, and the quote on the user's screen updates as fast as the market data feed can deliver. The user isn't competing with any of that, and doesn't need to. What they need is to see what happened as soon as possible, so they can decide what to do while the move is still playing out. The quote already gets that treatment. The alert, the chat, and the video have to get it too.&lt;/p&gt;

&lt;p&gt;In the experience plane, the question is simpler. Did the alert, feed, chat message, or video frame arrive before the person holding the phone noticed any delay? Two well-established numbers tell you where users start to feel a delay:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Though &lt;a href="https://www.nngroup.com/articles/response-times-3-important-limits/" rel="noopener noreferrer"&gt;Nielsen Norman Group's response-time limits&lt;/a&gt; date from 1993, they are still the reference everyone uses. Around 0.1 seconds feels instantaneous. Around 1 second is noticeable, but the user's thought flow remains intact. They're general usability findings, not trading targets, and they're the right test for feed reads and optimistic UI updates, where the app is trying to look like it responded before it finished the work.
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.itu.int/rec/T-REC-G.114-200305-I" rel="noopener noreferrer"&gt;ITU-T Recommendation G.114&lt;/a&gt; provides telecom planning guidance on one-way delay in a conversation. It treats 150 ms one-way as acceptable for most conversational voice, and notes that highly interactive tasks can suffer well below that. It's planning guidance, not a threshold where delay suddenly becomes noticeable, and it's the number that matters for an advisor call.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's how the layers stack up:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Measured against&lt;/th&gt;
&lt;th&gt;What fast looks like&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Exchange matching and market-data distribution&lt;/td&gt;
&lt;td&gt;Other exchanges and other participants&lt;/td&gt;
&lt;td&gt;Microseconds to low milliseconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Broker order handling&lt;/td&gt;
&lt;td&gt;The venue and the market-data feed&lt;/td&gt;
&lt;td&gt;Milliseconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Feed and chat APIs&lt;/td&gt;
&lt;td&gt;The person reading the screen&lt;/td&gt;
&lt;td&gt;Low milliseconds per request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The app's interface&lt;/td&gt;
&lt;td&gt;The person reading the screen&lt;/td&gt;
&lt;td&gt;Under 0.1 s to feel instant, under 1 s to keep flow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Voice and video&lt;/td&gt;
&lt;td&gt;The person in the conversation&lt;/td&gt;
&lt;td&gt;Under 150 ms one-way, with low jitter and loss&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Everything in the experience plane gets judged against the bottom three rows, and it gets judged hardest at market open, when all of those systems are under the most load at the same time.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Activity Feeds Handle Fanout at Market Open
&lt;/h2&gt;

&lt;p&gt;In a trading app, most of what happens ends up &lt;a href="https://getstream.io/blog/build-activity-feed/" rel="noopener noreferrer"&gt;in a feed&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Price and watchlist alerts that the trading backend fires.
&lt;/li&gt;
&lt;li&gt;The user's own order and portfolio activity.
&lt;/li&gt;
&lt;li&gt;Research from the analysts and instruments they follow.
&lt;/li&gt;
&lt;li&gt;Posts, comments, and reactions from other investors.
&lt;/li&gt;
&lt;li&gt;Per-ticker, per-sector, or per-topic activity.
&lt;/li&gt;
&lt;li&gt;Ranked “for you” style commentary.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of those is a &lt;a href="https://getstream.io/glossary/fan-out/" rel="noopener noreferrer"&gt;fanout&lt;/a&gt; problem. One event goes in, and it has to come out across many different feeds, each a different mix of what that user follows. That's hard to scale, and you’ll only find it fails at market open.&lt;/p&gt;

&lt;h3&gt;
  
  
  Push Versus Pull Fanout
&lt;/h3&gt;

&lt;p&gt;There are two basic ways to build a feed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;With &lt;strong&gt;fanout on write&lt;/strong&gt;, or push, every new activity gets copied into each follower's feed the moment it's created. Reads are quick, because the feed is already sitting there. The cost lands on the write, and it grows with the follower count. If an account has two million followers, every post is two million writes. That's &lt;a href="https://getstream.io/blog/fan-out-activities-followers/" rel="noopener noreferrer"&gt;the celebrity problem&lt;/a&gt;.
&lt;/li&gt;
&lt;li&gt;With &lt;strong&gt;fanout on read&lt;/strong&gt;, or pull, you store the activity once and build the user's feed when they open it. Writes are trivial. Reads do all the work, pulling and merging activities from everyone the user follows, and that gets slower the more they follow.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1z7kypywdtmvbqyqnu2b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1z7kypywdtmvbqyqnu2b.png" alt="Push vs pull fanout" width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;An investing app has both problems at once. A well-known analyst, a fund, or the feed for a heavily traded ticker can have millions of followers, so pure push makes every one of their posts expensive. Pure pull moves the cost onto reads instead, and reads arrive all at once at 9:30 when everyone opens the app to see what happened overnight.1&lt;/p&gt;

&lt;h3&gt;
  
  
  How the Materialized Feed Works in Stream
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://getstream.io/activity-feeds/docs/" rel="noopener noreferrer"&gt;Stream Feeds API&lt;/a&gt; uses fanout-on-read, but caches the result. Each activity is stored once, and when a feed is read, the API fetches what's changed since that feed's last read and merges it into what's already cached. That cached result is the materialized feed.&lt;/p&gt;

&lt;p&gt;Here's &lt;a href="https://getstream.io/activity-feeds/docs/javascript/architecture/" rel="noopener noreferrer"&gt;how it works&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each activity is stored once, on write, with no per-follower copying.
&lt;/li&gt;
&lt;li&gt;When a feed is read, the API fetches only what's changed since that feed's last read and merges it into what's already cached, rather than rebuilding the feed from scratch.
&lt;/li&gt;
&lt;li&gt;While it's building the top N items, it keeps track of which followed feeds have nothing recent enough to make the cut, so it stops checking them.
&lt;/li&gt;
&lt;li&gt;The fields used for ranking and aggregation are pulled from the activity record rather than requiring the full object. Aggregation copies the fields it needs into the feed operation itself, and ranking works off a lightweight internal activity summary that holds just the ranking fields.
&lt;/li&gt;
&lt;li&gt;Aggregation keys get computed on write, so aggregated feeds open quickly. Ranked feeds can't be fully precomputed, so a fast expression parser re-ranks them at read time.
&lt;/li&gt;
&lt;li&gt;The cached result is stored in TiKV with a cursor per followed source, so a later read only pulls what's new since that cursor. TiKV also handles the multi-Raft clustering and rebalancing we used to run ourselves.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Fanout on write&lt;/th&gt;
&lt;th&gt;Fanout on read&lt;/th&gt;
&lt;th&gt;Materialized feed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Write cost&lt;/td&gt;
&lt;td&gt;High, one copy per follower&lt;/td&gt;
&lt;td&gt;Low, a single write&lt;/td&gt;
&lt;td&gt;Low, a single write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read cost&lt;/td&gt;
&lt;td&gt;Low, the feed is precomputed&lt;/td&gt;
&lt;td&gt;High, assembled at read time&lt;/td&gt;
&lt;td&gt;Low, cached with delta fetching&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Celebrity accounts&lt;/td&gt;
&lt;td&gt;Millions of writes per activity&lt;/td&gt;
&lt;td&gt;No problem&lt;/td&gt;
&lt;td&gt;No problem — nothing is copied per follower, same as fanout on read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inactive users&lt;/td&gt;
&lt;td&gt;Wasted writes to feeds nobody reads&lt;/td&gt;
&lt;td&gt;Nothing wasted&lt;/td&gt;
&lt;td&gt;Nothing wasted — same as fanout on read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read latency&lt;/td&gt;
&lt;td&gt;~1 to 5 ms&lt;/td&gt;
&lt;td&gt;~100 to 500 ms or more&lt;/td&gt;
&lt;td&gt;~10 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Reads then go through a few layers of caching before they touch storage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Redis client-side caching keeps hot data in the API process itself, which saves a round trip to Redis on busy feeds.
&lt;/li&gt;
&lt;li&gt;Ristretto and singleflight handle cache stampedes. When a hot key expires, one request rebuilds it, and everyone else waits for that result instead of piling onto the database.
&lt;/li&gt;
&lt;li&gt;For shared ranked feeds with very high read volume, there's an opt-in &lt;a href="https://getstream.io/activity-feeds/docs/node/hot-feed-cache/" rel="noopener noreferrer"&gt;hot feed cache&lt;/a&gt; that serves one cached ordering to every reader. A “most discussed tickers today” feed at market open is exactly what it's for.
&lt;/li&gt;
&lt;li&gt;On the WebSocket side, servers use cuckoo filters to cheaply check whether a user is even connected before doing any work to push an update.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What the Feeds Benchmark Measured
&lt;/h3&gt;

&lt;p&gt;Say we have an app with 100 million users, and 4.5 million of them are online at the same time, reading and writing feeds at up to 1,500 requests per second. That's a market-open crowd for a large retail broker. &lt;a href="https://getstream.io/activity-feeds/docs/javascript/architecture/" rel="noopener noreferrer"&gt;We ran that test&lt;/a&gt; against a dataset of 500 million activities and 200 million follow relationships:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Feed reads came back in about 15 ms at p50.
&lt;/li&gt;
&lt;li&gt;Feed writes took about 31 ms at p50.
&lt;/li&gt;
&lt;li&gt;p99 stayed under 140 ms at the heaviest setting.
&lt;/li&gt;
&lt;li&gt;Every request succeeded.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the alert or the post is in the user's feed well inside the 0.1 seconds where a person starts to notice a delay, and it stays that way when everyone opens the app at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Chat Stays Fast at the Edge
&lt;/h2&gt;

&lt;p&gt;In a trading app, chat is where everyone ends up at the same moment. The channel next to an earnings call, the ticker community during a sharp move, or the support inbox at market open. A message has to get from one person's phone to everyone in the room before the market moves on. &lt;/p&gt;

&lt;p&gt;This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The message has to reach a server that can act on it without the sender having to pay for intercontinental round trips first.
&lt;/li&gt;
&lt;li&gt;One message has to reach millions of people in the same channel within milliseconds, at the moment they all joined.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A user in Singapore talking to an origin in Virginia pays around 200 ms for a round trip, and a new TLS connection needs several of those before the first message goes anywhere. So a Stream client connects to the &lt;a href="https://getstream.io/blog/chat-edge-infrastructure/" rel="noopener noreferrer"&gt;nearest edge server&lt;/a&gt; instead. The handshake, authentication, and rate limiting all occur within a few milliseconds, and traffic flows from the edge to the origin over already-open connections — encrypted HTTP/2 between regions, and plain HTTP/1.1 for the last hop within the origin's own region.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3xhj11zedi3lwg910g71.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3xhj11zedi3lwg910g71.png" alt="How chat stays fast at the edge" width="800" height="115"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The API itself often responds in under 50 ms. On the device, SDKs keep recent channels and messages in local storage, so the app opens on its last state, and a sent message appears immediately while the request finishes in the background.&lt;/p&gt;

&lt;p&gt;The chat next to a widely watched earnings call can have millions of people in it, and every message has to reach them all. Servers use cuckoo filters to skip users who aren't connected to them, serialize each message once, and send it to each recipient individually, with the message's header and body packed together in a single call. Clients on networks that block WebSockets can &lt;a href="https://getstream.io/blog/long-polling-vs-websockets/" rel="noopener noreferrer"&gt;fall back to HTTP long polling&lt;/a&gt;, though that's opt-in.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the Chat Benchmark Measured
&lt;/h3&gt;

&lt;p&gt;Say we have 5 million people in one channel, joining at about 4,000 per second, with three messages a second going out to all of them. That's the chat for a stock everyone owns at the moment the results come out. &lt;a href="https://getstream.io/chat/docs/react/architecture-and-benchmark/" rel="noopener noreferrer"&gt;We ran that test&lt;/a&gt; on a single channel:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Around 15 million message deliveries per second at peak.
&lt;/li&gt;
&lt;li&gt;Average message send latency remained well under 40 ms.
&lt;/li&gt;
&lt;li&gt;No loss of stability for the whole run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So sending a message into the biggest room in the app stays fast even at full scale, and the room keeps working when the whole user base joins it at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Video Handles a Whole Audience Joining at Once
&lt;/h2&gt;

&lt;p&gt;Video in a trading app is either a small call or a large broadcast. One-to-one advisor consultations and KYC calls need to stay under 150 ms one-way with reliable audio. Earnings commentary and analyst briefings have a few publishers and the whole user base watching, usually with chat alongside. &lt;/p&gt;

&lt;p&gt;Serving the broadcast involves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pushing more bandwidth than a single server can handle to viewers on three continents.
&lt;/li&gt;
&lt;li&gt;Handling thousands of joins in the same few seconds, because a briefing starts the moment the results come out.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Forwarding One Stream to 100,000 Viewers
&lt;/h3&gt;

&lt;p&gt;WebRTC was designed for &lt;a href="https://getstream.io/resources/projects/webrtc/architectures/p2p/" rel="noopener noreferrer"&gt;peer-to-peer communication&lt;/a&gt;, where each publisher sends a separate copy of their stream to each other participant. That stops working past a handful of people. With a &lt;a href="https://getstream.io/resources/projects/webrtc/architectures/sfu/" rel="noopener noreferrer"&gt;selective forwarding unit&lt;/a&gt; (SFU), each publisher uploads once, and the SFU forwards streams to whoever needs them, so each phone's connection count drops from O(n²) to O(n).&lt;/p&gt;

&lt;p&gt;A 100,000-viewer 1080p stream needs more than 200 Gbps, which is more than one SFU can serve, so we &lt;a href="https://getstream.io/resources/projects/webrtc/architectures/sfu-cascading/" rel="noopener noreferrer"&gt;cascade&lt;/a&gt;. The publisher's video goes to one SFU, which then forwards it to other SFUs in other regions, each serving around a thousand viewers in their area. Viewers only subscribe to the streams visible on screen, and &lt;a href="https://getstream.io/resources/projects/webrtc/architectures/simulcast/" rel="noopener noreferrer"&gt;simulcast&lt;/a&gt; lets each viewer receive the quality their connection can handle.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftu4drdeo34oh136sweo7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftu4drdeo34oh136sweo7.png" alt="Forwarding One Stream to Thousands of Viewers" width="800" height="550"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;SFU capacity scales in steps based on how quickly it's needed, so a sharp rise in load can double or triple the number of SFUs running. Join-time thundering-herd protection prevents a burst of simultaneous joins from overwhelming signaling. For advisors and institutional users on locked-down corporate networks, TURN runs on our SFU servers over TCP on port 443, with a dedicated TURN network behind it and static IPs.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the Video Benchmark Measured
&lt;/h3&gt;

&lt;p&gt;Say we have 100,000 people watching one analyst's 1080p stream, joining at 10,000 per minute, with peaks of 600 per second, spread across six regions in the US and Europe. &lt;a href="https://getstream.io/video/docs/api/architecture-and-benchmark/" rel="noopener noreferrer"&gt;We ran that test&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;225 Gbps of peak traffic across 132 cascading SFUs.
&lt;/li&gt;
&lt;li&gt;A stable 30 fps for the whole run, with 0% packet loss and 4 ms jitter.
&lt;/li&gt;
&lt;li&gt;Zero API failures.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the briefing starts the moment the results come out, the whole audience is in within seconds, and everyone's watching at full quality.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where This Leaves the Build Decision
&lt;/h2&gt;

&lt;p&gt;None of this changes who owns the trading system. Orders, positions, balances, and execution remain with your platform or brokerage infrastructure provider, and nothing in the experience plane can assert financial state.&lt;/p&gt;

&lt;p&gt;The question is &lt;a href="https://getstream.io/blog/build-vs-buy/" rel="noopener noreferrer"&gt;whether you build the experience plane yourself&lt;/a&gt;. Running it in-house involves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Global WebSocket fanout with edge termination and regional failover.
&lt;/li&gt;
&lt;li&gt;Feed materialization and cache-stampede protection that holds at market open.
&lt;/li&gt;
&lt;li&gt;Offline storage and delta sync across iOS, Android, and web.
&lt;/li&gt;
&lt;li&gt;SFU cascading, simulcast, TURN, and autoscaling sized for join spikes you can't schedule.
&lt;/li&gt;
&lt;li&gt;Capacity in several regions for load that arrives in bursts a few times a day.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of those is a general problem, and we've already solved each of them at a scale most trading apps won't reach. Your team keeps its time for the parts of the app that carry financial risk, and the alerts, chat, and video run on infrastructure that's been tested with 4.5 million users online, 5 million people in a single channel, and 100,000 people watching a single stream.&lt;/p&gt;

&lt;p&gt;The user judges your app by whether it keeps up with the market, and the experience plane is the part they see. If the alert arrives late or the chat lags, they'll assume the trading is slow too, however fast the order path underneath it is.&lt;/p&gt;

&lt;p&gt;If you're building or replacing the feeds, chat, or video in a trading app, start with our &lt;a href="https://getstream.io/activity-feeds/docs/javascript/architecture/" rel="noopener noreferrer"&gt;Feeds&lt;/a&gt;, &lt;a href="https://getstream.io/chat/docs/react/architecture-and-benchmark/" rel="noopener noreferrer"&gt;Chat&lt;/a&gt;, and &lt;a href="https://getstream.io/video/docs/api/architecture-and-benchmark/" rel="noopener noreferrer"&gt;Video&lt;/a&gt; architecture pages and read the benchmarks against your own peak concurrency. Or &lt;a href="https://getstream.io/contact/" rel="noopener noreferrer"&gt;talk to our team&lt;/a&gt; about what your market open looks like.&lt;/p&gt;

</description>
      <category>tradingapp</category>
      <category>apparchitecture</category>
      <category>mobileapp</category>
      <category>realtimearchitecture</category>
    </item>
    <item>
      <title>Build a Live Virtual Classroom with React and Stream Video</title>
      <dc:creator>Sarah Lindauer</dc:creator>
      <pubDate>Fri, 21 Aug 2026 22:28:28 +0000</pubDate>
      <link>https://dev.to/getstreamhq/build-a-live-virtual-classroom-with-react-and-stream-video-332d</link>
      <guid>https://dev.to/getstreamhq/build-a-live-virtual-classroom-with-react-and-stream-video-332d</guid>
      <description>&lt;p&gt;Live classes are now table stakes for &lt;a href="https://getstream.io/chat/solutions/ed-tech/" rel="noopener noreferrer"&gt;education products&lt;/a&gt;. &lt;/p&gt;

&lt;p&gt;Whether you’re running a tutoring marketplace, a corporate training platform, or a complete online school, sooner or later a stakeholder asks for “a Zoom inside our app,” but with the rules of a classroom. The teacher controls the room, students ask to speak instead of talking over each other, and everything works on the phone a student actually has in their hand.&lt;/p&gt;

&lt;p&gt;Building that from raw WebRTC means months of work on signaling, SFUs, device handling, and reconnection logic before you write a single classroom feature. &lt;/p&gt;

&lt;p&gt;In this tutorial, you’ll build it in an afternoon with &lt;a href="https://getstream.io/video/sdk/react/" rel="noopener noreferrer"&gt;Stream’s React Video SDK&lt;/a&gt;: a virtual classroom where a teacher runs the class from a laptop, and students join from their phones, with hand-raising, a moderated microphone, and a live roster.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You’ll Build
&lt;/h2&gt;

&lt;p&gt;Here’s what &lt;strong&gt;the finished product&lt;/strong&gt; looks like, with the teacher on the left and the students on the right. &lt;/p&gt;

      Live virtual classroom demo

&lt;p&gt;This includes: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two roles with real permissions&lt;/strong&gt;. The teacher can mute any student or the whole room, and Stream’s backend enforces it. Students can’t grant themselves the same powers by poking at the client.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raise hand&lt;/strong&gt;. Students tap a button; the teacher sees a queue ordered by who asked first, and can lower any hand from the roster.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A classroom mic policy&lt;/strong&gt;. Students join muted by default and unmute when called on.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A live roster&lt;/strong&gt;. Showing everyone’s mic and camera state in real time.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mobile-ready student view&lt;/strong&gt;. One responsive UI that renders full-screen on a phone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You get video layouts, screen sharing, device selection, and dominant-speaker detection from the SDK’s built-in components, so the code you write is almost entirely classroom logic.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Worth knowing if you're handling student data: Stream is &lt;strong&gt;SOC 2 Type II, ISO 27001, HIPAA, DPF, and GDPR compliant&lt;/strong&gt;, so the privacy paperwork a school district asks for is already sorted. &lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Node 20+
&lt;/li&gt;
&lt;li&gt;A free &lt;a href="https://dashboard.getstream.io/" rel="noopener noreferrer"&gt;Stream account&lt;/a&gt; — create an app in the dashboard and grab your API key and API secret
&lt;/li&gt;
&lt;li&gt;Working knowledge of React&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Project Setup
&lt;/h2&gt;

&lt;p&gt;Scaffold a Vite React app and install the two Stream SDKs plus a minimal backend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm create vite@latest classroom &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nt"&gt;--template&lt;/span&gt; react
&lt;span class="nb"&gt;cd &lt;/span&gt;classroom
npm &lt;span class="nb"&gt;install&lt;/span&gt; @stream-io/video-react-sdk @stream-io/node-sdk express cors dotenv concurrently
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; @vitejs/plugin-basic-ssl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You need two SDKs because there are two sides to authentication:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;@stream-io/video-react-sdk&lt;/code&gt; runs in the browser.
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@stream-io/node-sdk&lt;/code&gt; runs on your server, where your API secret lives. The secret must never be shipped to the client, as anyone who holds it can impersonate any user in your app.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;code&gt;basic-ssl&lt;/code&gt; plugin enables HTTPS in dev. You won't need it on localhost, but you will when you test on a real phone. Browsers only expose the camera and microphone to secure origins.&lt;/p&gt;

&lt;p&gt;Put your credentials in &lt;code&gt;.env&lt;/code&gt; at the project root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;STREAM_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your_api_key
&lt;span class="nv"&gt;STREAM_API_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your_api_secret
&lt;span class="nv"&gt;PORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3001
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 1: The Token Server
&lt;/h2&gt;

&lt;p&gt;Every user connects to Stream with a token your backend mints. This is also the natural place to decide who gets to run the classroom, because call permissions in Stream are attached to server-assigned roles, not to anything the client claims about itself.&lt;/p&gt;

&lt;p&gt;Create &lt;code&gt;server/index.js&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dotenv/config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;cors&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cors&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;StreamClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stream-io/node-sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STREAM_API_KEY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;apiSecret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STREAM_API_SECRET&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StreamClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;apiSecret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;cors&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CALL_TYPE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;default&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TOKEN_TTL_SECONDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/join&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;callId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;callId&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;teacher&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;student&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;name, callId and role (teacher|student) are required&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsertUsers&lt;/span&gt;&lt;span class="p"&gt;([{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}]);&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CALL_TYPE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;callId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOrCreate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;created_by_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;custom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;classroom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateCallMembers&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;update_members&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;teacher&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;host&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateUserToken&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;validity_in_seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TOKEN_TTL_SECONDS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;callId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;join failed:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Could not join the classroom. Check the server logs.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;port&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PORT&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;3001&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Token server running on &amp;lt;http://localhost&amp;gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The line that makes the whole classroom work is &lt;code&gt;updateCallMembers&lt;/code&gt;. Stream’s built-in &lt;code&gt;default&lt;/code&gt; call type includes a &lt;code&gt;host&lt;/code&gt; role that carries capabilities such as &lt;code&gt;mute-users&lt;/code&gt; and &lt;code&gt;update-call-permissions&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;By adding the teacher as a member with &lt;code&gt;role: 'host'&lt;/code&gt;, and students as plain &lt;code&gt;user&lt;/code&gt;s, you’ve established the classroom’s power structure server-side. Later, when the teacher’s client calls &lt;code&gt;muteUser()&lt;/code&gt;, Stream’s backend checks this membership before acting. A student sending the same request gets rejected, no matter what UI they’ve hacked together.&lt;/p&gt;

&lt;p&gt;In production, you’d derive &lt;code&gt;role&lt;/code&gt; from your own auth system (the session of the logged-in teacher) rather than trusting the request body (here, we’re taking it from the join form to keep the focus on the video features).&lt;/p&gt;

&lt;p&gt;Wire up the dev workflow in &lt;code&gt;package.json,&lt;/code&gt; so one command runs both processes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dev"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"concurrently -n server,web &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;npm run server&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;vite&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dev:lan"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"LAN=1 concurrently -n server,web &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;npm run server&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;vite&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"server"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node server/index.js"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"web"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"vite"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;code&gt;vite.config.js&lt;/code&gt;, proxy &lt;code&gt;/api&lt;/code&gt; to the token server, and when the &lt;code&gt;LAN&lt;/code&gt; flag is set, serve over HTTPS on all network interfaces so a phone on your Wi-Fi can join:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// vite.config.js&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;react&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@vitejs/plugin-react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;basicSsl&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@vitejs/plugin-basic-ssl&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="c1"&gt;// LAN=1 serves over HTTPS on all interfaces so phones on the same network&lt;/span&gt;
&lt;span class="c1"&gt;// can join — browsers only allow camera/mic access on secure origins.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LAN&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;react&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;lan&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;basicSsl&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[])],&lt;/span&gt;
  &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lan&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;localhost&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;proxy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:3001&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: Connecting to the Call
&lt;/h2&gt;

&lt;p&gt;The client flow is: &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Exchange a name and role for a token
&lt;/li&gt;
&lt;li&gt;Create a &lt;code&gt;StreamVideoClient&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Join the call
&lt;/li&gt;
&lt;li&gt;Hand everything to the SDK’s provider components:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/App.jsx&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;StreamVideo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;StreamVideoClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;StreamCall&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;StreamTheme&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stream-io/video-react-sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;JoinScreen&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./components/JoinScreen&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Classroom&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./components/Classroom&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;App&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setSession&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// { apiKey, token, userId, name, role, callId }&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setClient&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setCall&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;videoClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StreamVideoClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;classroomCall&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;videoClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;default&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;callId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// Students join muted so a full class doesn't turn into crosstalk;&lt;/span&gt;
    &lt;span class="c1"&gt;// they ask to speak with the raise-hand button instead.&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;student&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;classroomCall&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;microphone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;classroomCall&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;videoClient&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;setCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;classroomCall&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;classroomCall&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;leave&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt;
      &lt;span class="nx"&gt;videoClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnectUser&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nf"&gt;setClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;setCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;JoinScreen&lt;/span&gt; &lt;span class="nx"&gt;onJoin&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;setSession&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;;
&lt;/span&gt;  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;loading-screen&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nx"&gt;Joining&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;callId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/p&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;StreamVideo&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;StreamTheme&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;StreamCall&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Classroom&lt;/span&gt; &lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;callId&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;callId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;onLeave&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;        &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/StreamCall&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/StreamTheme&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/StreamVideo&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;classroomCall.microphone.disable()&lt;/code&gt; before &lt;code&gt;join()&lt;/code&gt; is the entire “students join muted” feature. The SDK’s device manager applies the setting before any audio is published, so students never broadcast that first half-second of background noise.
&lt;/li&gt;
&lt;li&gt;The cleanup function leaves the call and disconnects the user, so navigating away doesn’t strand a ghost participant in the room.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;JoinScreen&lt;/code&gt; is an ordinary form with name, class code, and a teacher/student radio that POSTs to &lt;code&gt;/api/join&lt;/code&gt; and passes the response up.&lt;/p&gt;

&lt;p&gt;Don’t forget the Stream SDK stylesheet in &lt;code&gt;src/main.jsx&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/main.jsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stream-io/video-react-sdk/dist/css/styles.css&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./index.css&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: The Teacher’s View
&lt;/h2&gt;

&lt;p&gt;The teacher gets a laptop-oriented layout with the video stage, a side panel with the roster and raised-hand queue, and a control bar. &lt;/p&gt;

&lt;p&gt;Almost all of the heavy lifting is SDK components:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/components/TeacherView.jsx&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;SpeakerLayout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;ToggleAudioPublishingButton&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;ToggleVideoPublishingButton&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;ScreenShareButton&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;useCall&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;useCallStateHooks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stream-io/video-react-sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useRaisedHands&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../hooks/useRaisedHands&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ParticipantsPanel&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./ParticipantsPanel&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;TeacherView&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;callId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onLeave&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCall&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useParticipantCount&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallStateHooks&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;participantCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useParticipantCount&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raisedHands&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRaisedHands&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;teacher-view&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;class-header&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;span&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;live-dot&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="nx"&gt;aria&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
          &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;h1&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;callId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/h1&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;        &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;        &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;span&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;participant-count&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;participantCount&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/span&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/header&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;teacher-main&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stage&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;SpeakerLayout&lt;/span&gt; &lt;span class="nx"&gt;participantsBarPosition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bottom&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;        &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ParticipantsPanel&lt;/span&gt; &lt;span class="nx"&gt;raisedHands&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;raisedHands&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;footer&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;control-bar&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ToggleAudioPublishingButton&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ToggleVideoPublishingButton&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ScreenShareButton&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;button&lt;/span&gt;
          &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bar-button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
          &lt;span class="nx"&gt;onClick&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;muteAllUsers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;audio&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
          &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Mute every student's microphone&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
        &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="nx"&gt;Mute&lt;/span&gt; &lt;span class="nx"&gt;all&lt;/span&gt;
        &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/button&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;        &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;button&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bar-button leave&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="nx"&gt;onClick&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;onLeave&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="nx"&gt;End&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt;
        &lt;span class="err"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;button&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/footer&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SpeakerLayout&lt;/code&gt; gives you dominant-speaker switching, a scrollable filmstrip of everyone else, and automatic screen-share handling. When the teacher clicks the &lt;code&gt;ScreenShareButton&lt;/code&gt; to present slides, the layout promotes the shared screen without any code from you.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl6bry9xalzwemaj0fvsu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl6bry9xalzwemaj0fvsu.png" alt="Live virtual classroom screenshot" width="800" height="466"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;call.muteAllUsers('audio')&lt;/code&gt; is the “settle down, everyone” button. It’s a server-side operation. Stream stops the audio at the backend, and every muted client’s UI updates to reflect it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: The Roster, With Per-Student Mute
&lt;/h2&gt;

&lt;p&gt;The roster is where Stream’s reactive state is ideal. &lt;code&gt;useParticipants()&lt;/code&gt; re-renders whenever anyone joins, leaves, or changes what they’re publishing, so mic/cam indicators are always live:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/components/ParticipantsPanel.jsx&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;SfuModels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useCall&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useCallStateHooks&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stream-io/video-react-sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ParticipantsPanel&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;raisedHands&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCall&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useParticipants&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallStateHooks&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;participants&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useParticipants&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;hands&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lowerHand&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;raisedHands&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;aside&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;participants-panel&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;section&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hand-queue&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;h2&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nx"&gt;Raised&lt;/span&gt; &lt;span class="nx"&gt;hands&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/h2&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;hands&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;empty-note&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nx"&gt;No&lt;/span&gt; &lt;span class="nx"&gt;hands&lt;/span&gt; &lt;span class="nx"&gt;up&lt;/span&gt; &lt;span class="nx"&gt;right&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/p&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;        &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ol&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;hands&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;hand&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
              &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;li&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;hand&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
                &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;span&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hand-badge&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="nx"&gt;aria&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;✋&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/span&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;                &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;span&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hand-name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;hand&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/span&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;                &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;button&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;chip-button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="nx"&gt;onClick&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;lowerHand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hand&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
                  &lt;span class="nx"&gt;Lower&lt;/span&gt; &lt;span class="nx"&gt;hand&lt;/span&gt;
                &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/button&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;              &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/li&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;            &lt;span class="p"&gt;))}&lt;/span&gt;
          &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/ol&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;        &lt;span class="p"&gt;)}&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/section&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;section&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;roster&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;h2&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nx"&gt;Everyone&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/h2&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;        &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ul&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;participants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;micOn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;publishedTracks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SfuModels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TrackType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AUDIO&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;camOn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;publishedTracks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SfuModels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TrackType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VIDEO&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handUp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hands&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
              &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;li&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
                &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;span&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;roster-name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
                  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
                  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isLocalParticipant&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; (you)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
                  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handUp&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;span&lt;/span&gt; &lt;span class="nx"&gt;aria&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hand raised&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;✋&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/span&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;}
&lt;/span&gt;                &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/span&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;                &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;span&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;roster-state&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
                  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;span&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;micOn&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;on&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;off&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;micOn&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Mic on&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Muted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/span&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;                  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;span&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;camOn&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;on&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;off&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;camOn&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cam on&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cam off&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/span&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;                &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/span&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;                &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isLocalParticipant&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;micOn&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
                  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;button&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;chip-button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="nx"&gt;onClick&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;muteUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;audio&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
                    &lt;span class="nx"&gt;Mute&lt;/span&gt;
                  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/button&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;                &lt;span class="p"&gt;)}&lt;/span&gt;
              &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/li&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;            &lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="p"&gt;})}&lt;/span&gt;
        &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/ul&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/section&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/aside&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A participant’s &lt;code&gt;publishedTracks&lt;/code&gt; tells you exactly what media they’re sending right now, which is more trustworthy than tracking mute state yourself. And the per-student Mute button is one line: &lt;code&gt;call.muteUser(p.userId, 'audio')&lt;/code&gt;. Because the teacher holds the &lt;code&gt;host&lt;/code&gt; role from Step 1, Stream authorizes it; the student’s device stops publishing audio, and their own UI flips to muted, instantly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw95fvd3gjwxwt00aj5c9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw95fvd3gjwxwt00aj5c9.png" alt="Roster in a live virtual classroom" width="681" height="1488"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Hand Raising with Custom Events
&lt;/h2&gt;

&lt;p&gt;Stream doesn’t ship a “raise hand” button, but it ships something better: &lt;code&gt;sendCustomEvent()&lt;/code&gt;, a real-time channel for everyone on the call to send any JSON payload you like. &lt;/p&gt;

&lt;p&gt;That means hand raising is your feature, with your rules. Here, that means a queue ordered by who asked first, which either side can clear.&lt;/p&gt;

&lt;p&gt;The whole feature is one hook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/hooks/useRaisedHands.js&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useCall&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useCallStateHooks&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stream-io/video-react-sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useRaisedHands&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCall&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useLocalParticipant&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallStateHooks&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;localParticipant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useLocalParticipant&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;hands&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setHands&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;([]);&lt;/span&gt; &lt;span class="c1"&gt;// [{ userId, name }] in raise order&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;localUserId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localParticipant&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handIsRaised&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hands&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;localUserId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unsubscribe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;custom&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;custom&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hand-raised&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raiser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
        &lt;span class="nf"&gt;setHands&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;raiser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;raiser&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hand-lowered&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nf"&gt;setHands&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;unsubscribe&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="c1"&gt;// Re-broadcast for late joiners so they see hands already in the air.&lt;/span&gt;
  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;handIsRaised&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unsubscribe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;call.session_participant_joined&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendCustomEvent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hand-raised&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;unsubscribe&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handIsRaised&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raiseHand&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;localParticipant&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;setHands&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;localUserId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;localUserId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;localParticipant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;localUserId&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendCustomEvent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hand-raised&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;localParticipant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;localUserId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lowerHand&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localUserId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nf"&gt;setHands&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
      &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendCustomEvent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hand-lowered&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;localUserId&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;hands&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handIsRaised&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;raiseHand&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lowerHand&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should notice three design decisions here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The queue preserves order&lt;/strong&gt;. New hands append to the array, and the teacher’s panel numbers them so “who was first?” is never a judgment call.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;hand-lowered&lt;/code&gt; carries a &lt;code&gt;userId&lt;/code&gt;&lt;/strong&gt;. When a student lowers their own hand, the target defaults to the sender. When the teacher lowers a student’s hand, the event names the student, and because the student’s client processes the same event, the student's “✋ Lower hand” button automatically resets to “Raise hand.” One event type, both directions.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Late joiners get synced&lt;/strong&gt;. Custom events are fire-and-forget, so someone who joins after a hand has been raised would miss it. The second effect handles this. Whenever a new participant joins, anyone who has raised a hand quietly rebroadcasts. The &lt;code&gt;prev.some()&lt;/code&gt; dedupe check makes replays harmless.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 6: The Student’s View Designed for a Phone
&lt;/h2&gt;

&lt;p&gt;Students in a live class are overwhelmingly on phones, so the student UI is built phone-first. &lt;/p&gt;

&lt;p&gt;The teacher’s video is big, and there’s a thumb-reachable control bar with one prominent amber raise-hand button:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/components/StudentView.jsx&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;SpeakerLayout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;ToggleAudioPublishingButton&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;ToggleVideoPublishingButton&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;useCallStateHooks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stream-io/video-react-sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useRaisedHands&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../hooks/useRaisedHands&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;StudentView&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;callId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onLeave&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useParticipantCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useMicrophoneState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallStateHooks&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;participantCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useParticipantCount&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;isMute&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMicrophoneState&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;handIsRaised&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;raiseHand&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lowerHand&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRaisedHands&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;student-view&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;class-header compact&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;span&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;live-dot&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="nx"&gt;aria&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
          &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;h1&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;callId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/h1&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;        &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;        &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;span&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;participant-count&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;participantCount&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/span&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/header&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isMute&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;muted-note&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nx"&gt;You&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;re muted — raise your hand to ask a question.&amp;lt;/p&amp;gt;}

      &amp;lt;div className="stage"&amp;gt;
        &amp;lt;SpeakerLayout participantsBarPosition="bottom" /&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;footer className="control-bar"&amp;gt;
        &amp;lt;ToggleAudioPublishingButton /&amp;gt;
        &amp;lt;ToggleVideoPublishingButton /&amp;gt;
        &amp;lt;button
          className={`hand-button${handIsRaised ? &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="nx"&gt;raised&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; : &lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;}`}
          onClick={() =&amp;gt; (handIsRaised ? lowerHand() : raiseHand())}
        &amp;gt;
          ✋ {handIsRaised ? &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="nx"&gt;Lower&lt;/span&gt; &lt;span class="nx"&gt;hand&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; : &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="nx"&gt;Raise&lt;/span&gt; &lt;span class="nx"&gt;hand&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;}
        &amp;lt;/button&amp;gt;
        &amp;lt;button className="bar-button leave" onClick={onLeave}&amp;gt;
          Leave
        &amp;lt;/button&amp;gt;
      &amp;lt;/footer&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;useMicrophoneState().isMute&lt;/code&gt; drives the “You’re muted” banner, and it remains accurate regardless of the reason the student is muted. They joined that way, they muted themselves, or the teacher muted them from the roster.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpy89q36moo5rpnkheunt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpy89q36moo5rpnkheunt.png" alt="Student's view designed for a phone in virtual classroom" width="800" height="1731"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Because it’s the same React SDK across desktop and mobile browsers, “mobile support” is a CSS problem (not an architectural one). &lt;/p&gt;

&lt;p&gt;The stylesheet does two things. Below 700px, the student view goes edge-to-edge full-screen (with &lt;code&gt;env(safe-area-inset-bottom)&lt;/code&gt; padding so the controls clear the iPhone home indicator):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.phone-frame&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;390px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;800px&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;94vh&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="m"&gt;#000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;44px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;700px&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.phone-frame&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;&lt;span class="n"&gt;dvh&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you’re &lt;a href="https://getstream.io/blog/app-store-google-play/" rel="noopener noreferrer"&gt;shipping to app stores&lt;/a&gt; rather than the mobile web, the same call, roles, and custom events apply across Stream’s &lt;a href="https://getstream.io/video/sdk/react-native/" rel="noopener noreferrer"&gt;React Native&lt;/a&gt;, &lt;a href="https://getstream.io/video/sdk/ios/" rel="noopener noreferrer"&gt;iOS&lt;/a&gt;, and &lt;a href="https://getstream.io/video/sdk/android/" rel="noopener noreferrer"&gt;Android&lt;/a&gt; SDKs. A student on the native app and one on the mobile web join the same classroom.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;For a quick single-machine test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;a href="http://localhost:5173" rel="noopener noreferrer"&gt;http://localhost:5173&lt;/a&gt;, join as &lt;strong&gt;Teacher&lt;/strong&gt;, then open a second tab and join as a &lt;strong&gt;Student&lt;/strong&gt; (the student renders inside a phone frame so you can see the mobile layout without leaving your desk).&lt;/p&gt;

&lt;p&gt;For the real thing, with the teacher on your laptop and students on actual phones, start in LAN mode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run dev:lan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is where the &lt;code&gt;basic-ssl&lt;/code&gt; plugin earns its keep. If you serve plain HTTP to another device, students can join the call, but &lt;code&gt;getUserMedia&lt;/code&gt; fails silently on their phones: no camera, no mic, and no obvious error. Over HTTPS, it just works. &lt;/p&gt;

&lt;p&gt;On each phone, open &lt;code&gt;https://&amp;lt;your-computer's-LAN-IP&amp;gt;:5173&lt;/code&gt; (find the IP with &lt;code&gt;ipconfig getifaddr en0&lt;/code&gt; on macOS or &lt;code&gt;hostname -I&lt;/code&gt; on Linux), accept the self-signed certificate warning, join as a &lt;strong&gt;Student&lt;/strong&gt;, and allow camera and mic access.&lt;/p&gt;

&lt;p&gt;Then run the class:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Student taps &lt;strong&gt;✋ Raise hand&lt;/strong&gt; → the teacher's queue shows them, numbered.
&lt;/li&gt;
&lt;li&gt;Teacher calls on them; student unmutes and asks their question.
&lt;/li&gt;
&lt;li&gt;Teacher hits &lt;strong&gt;Lower hand&lt;/strong&gt;, then &lt;strong&gt;Mute&lt;/strong&gt; on the roster. The student's phone will flip back to muted on its own.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mute all&lt;/strong&gt; when the whole back row forgets they're live.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Where To Take It Next
&lt;/h2&gt;

&lt;p&gt;This is a solid starting point. Natural next steps for a production classroom:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stricter mic policy.&lt;/strong&gt; Instead of trusting students to stay muted, revoke the &lt;code&gt;send-audio&lt;/code&gt; capability with &lt;code&gt;call.updateUserPermissions()&lt;/code&gt; and grant it only when the teacher calls on a raised hand, turning the hand queue into a true request-to-speak flow.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Class chat.&lt;/strong&gt; &lt;a href="https://getstream.io/chat/" rel="noopener noreferrer"&gt;Stream’s Chat SDK&lt;/a&gt; shares the same user tokens, so a channel per classroom is a small addition.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recording and attendance.&lt;/strong&gt; &lt;code&gt;call.startRecording()&lt;/code&gt; captures the lesson; call session events to give you join/leave timestamps for attendance.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Breakout groups.&lt;/strong&gt; Each group is just another call; move students between call IDs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You built a working virtual classroom in a few hundred lines of React code: server-enforced teacher moderation, an ordered raise-hand queue via custom events, and a student experience that's at home on a phone. &lt;/p&gt;

&lt;p&gt;None of it required touching WebRTC internals. The Stream SDK handled the media layer, so every line you wrote was classroom logic.&lt;/p&gt;

&lt;p&gt;Ready to add live classes to your own product? Create a &lt;a href="https://beta.dashboard.getstream.io/login/" rel="noopener noreferrer"&gt;free Stream account&lt;/a&gt;, grab your API keys, and you can have this running against your own app before your next standup. The &lt;a href="https://getstream.io/video/docs/react/" rel="noopener noreferrer"&gt;React Video SDK&lt;/a&gt; docs cover everything this tutorial touched on, including call types, permissions, custom events, and where to go deeper.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How to Build an AI Voice Agent with Python and WebRTC: Step-by-Step Guide</title>
      <dc:creator>Sarah Lindauer</dc:creator>
      <pubDate>Fri, 21 Aug 2026 22:15:17 +0000</pubDate>
      <link>https://dev.to/getstreamhq/how-to-build-an-ai-voice-agent-with-python-and-webrtc-step-by-step-guide-5ge4</link>
      <guid>https://dev.to/getstreamhq/how-to-build-an-ai-voice-agent-with-python-and-webrtc-step-by-step-guide-5ge4</guid>
      <description>&lt;p&gt;Voice should be the default interface for AI. It is the sci-fi dream: talk into your watch and have an assistant answer your questions, book your movie tickets, and remember your dog's birthday.&lt;/p&gt;

&lt;p&gt;That future isn’t quite here yet, but AI voice agents are already embedded in everyday systems. Think customer support lines, language tutors, &lt;a href="https://getstream.io/blog/drive-thru-voice-ai/" rel="noopener noreferrer"&gt;drive-thru ordering&lt;/a&gt;, and in-app copilots. Each lets you talk instead of type, using the same thing under the hood: an &lt;a href="https://getstream.io/video/voice-ai/" rel="noopener noreferrer"&gt;AI voice agent&lt;/a&gt;. &lt;/p&gt;

&lt;p&gt;A few years ago, such an agent would have taken an entire team to build and encompassed a full codebase. Now, if you’re a Python developer, building one is a single-file problem.&lt;/p&gt;

&lt;p&gt;So that’s what we’ll do here. &lt;strong&gt;We’ll build a working voice agent&lt;/strong&gt; you can talk to in your browser during a Stream call. But we also want to help you grok the concepts, so we’ll cover the architecture voice agents share, explain why the transport layer, not the AI, is the hard part, and walk through the design decisions that separate an agent that feels like a conversation from one that feels like a voicemail system.&lt;/p&gt;

&lt;p&gt;By the end, you’ll have a ~60-line Python script you can run, understand, and adapt for your own use case.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is an AI Voice Agent?
&lt;/h2&gt;

&lt;p&gt;An AI voice agent is a program that takes part in a live audio conversation as a full participant. &lt;/p&gt;

&lt;p&gt;It listens to a continuous stream of a person's speech, uses a language model to decide how to respond, and answers in synthesized speech, &lt;a href="https://getstream.io/blog/realtime-ai-agents-latency/" rel="noopener noreferrer"&gt;in real time&lt;/a&gt;, taking turns the way a person would.&lt;/p&gt;

&lt;p&gt;That definition rules out most voice features you've already seen. A voicemail transcriber runs speech-to-text in batch after the recording ends; an agent transcribes a live stream while you're still talking. A "read this aloud" button is one-directional TTS. And the classic voice assistant interaction (press button, say command, wait, hear result) is really a request/response API with audio attached; one utterance in, one utterance out, session over.&lt;/p&gt;

&lt;p&gt;A voice agent, by contrast, holds a single continuous two-way session for the length of the call. Being a real conversation partner inside that session comes down to four specific behaviors:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;a href="https://visionagents.ai/ai-technologies/turn-detection" rel="noopener noreferrer"&gt;&lt;strong&gt;Turn detection&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;.&lt;/strong&gt; Deciding when you've finished your turn versus paused mid-thought, sometimes called endpointing. A half-second of silence after "so what I'm thinking is" means keep listening; the same silence after "what do you think?" means respond.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Barge-in.&lt;/strong&gt; Listening even while it's speaking, over a full-duplex audio channel, so when you cut in, it can stop mid-sentence, discard the unspoken rest of its reply, and yield the turn.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency.&lt;/strong&gt; Keeping the gap between your last word and its first under about a second, ideally much less, or the exchange stops feeling live. (The next section puts hard numbers on this.)
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State.&lt;/strong&gt; Carrying the conversation history forward, so "no, the second one" resolves against options it listed a minute ago.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Get any of these wrong and users notice immediately, not as a bug, but as a vague sense that they're talking to a machine that isn't listening. &lt;/p&gt;

&lt;p&gt;The rest of this article is, one way or another, about engineering these four behaviors: the pipeline that produces the words, and the transport and design decisions that make the timing work.&lt;/p&gt;


    
  Voice agent demo


&lt;h2&gt;
  
  
  The Architecture: STT → LLM → TTS, in a Loop
&lt;/h2&gt;

&lt;p&gt;Almost every voice agent is built from the same three components:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Speech-to-text (STT)&lt;/strong&gt; transcribes the user’s audio into text as they speak.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A large language model (LLM)&lt;/strong&gt; takes the transcript (plus conversation history and instructions) and generates a response.
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://getstream.io/blog/text-to-speech/" rel="noopener noreferrer"&gt;&lt;strong&gt;Text-to-speech&lt;/strong&gt;&lt;/a&gt; &lt;strong&gt;(TTS)&lt;/strong&gt; converts that response into audio that the user hears.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These run as a continuous loop for the duration of the call, all riding on a transport layer that moves audio between the user’s microphone and your Python process:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnz2g5ya70m41qrs5fpav.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnz2g5ya70m41qrs5fpav.png" alt="Architecture for STT to LLM to TTS" width="800" height="239"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each stage of this pipeline is a solved problem. &lt;/p&gt;

&lt;p&gt;STT providers like &lt;a href="https://getstream.io/blog/edge-speech-deepgram-fish/" rel="noopener noreferrer"&gt;Deepgram&lt;/a&gt; or &lt;a href="https://getstream.io/blog/elevenlabs-scribe-v2-realtime/" rel="noopener noreferrer"&gt;ElevenLabs&lt;/a&gt; transcribe streaming audio with word-level timestamps in real time. Any modern LLM can hold up its end of a conversation. TTS voices from providers like ElevenLabs are close enough to human that the difference rarely matters.&lt;/p&gt;

&lt;p&gt;So, if the pieces are all commodity, why aren’t voice agents trivial to build?&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Real-Time Transport Matters
&lt;/h2&gt;

&lt;p&gt;In human conversation, the gap between one person finishing and the other starting is about 200 - 300 milliseconds. Anything beyond roughly a second reads as hesitation, and anything beyond two, people start saying &lt;em&gt;“Hello? Are you there?”&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Now add up the voice agent loop. The user’s audio has to travel from their microphone to your server, get transcribed, wait for the LLM’s first tokens, get synthesized back into audio, and travel back to the user’s speaker. Every one of those stages takes time, and the user experiences the sum.&lt;/p&gt;

&lt;p&gt;That budget math has two consequences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Everything must stream&lt;/strong&gt;. You can’t wait for the full transcript before calling the LLM, or the full LLM response before starting TTS. Each stage has to start producing output while consuming input. What matters is time-to-first-audio, not time-to-complete-response.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The network hops can’t be an afterthought&lt;/strong&gt;. If STT, LLM, and TTS each cost 200 - 400ms and you can’t do much about it, the transport is where your remaining budget lives. Sending audio over a request/response protocol like HTTP, or even &lt;a href="https://getstream.io/blog/websocket-sse/" rel="noopener noreferrer"&gt;WebSockets&lt;/a&gt;, which run over TCP, means head-of-line blocking. One delayed packet holds up everything behind it while the connection waits for retransmission. For live audio, it’s disastrous because a 200ms-old audio packet is worthless. Better to drop it and stay current.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is exactly the problem WebRTC was built for. It runs audio over UDP with &lt;a href="https://getstream.io/glossary/codec/" rel="noopener noreferrer"&gt;codecs&lt;/a&gt; and &lt;a href="https://getstream.io/glossary/jitter-buffer/" rel="noopener noreferrer"&gt;jitter buffers&lt;/a&gt; designed for one goal: keep latency minimal and quality acceptable, in that order of priority. It’s the same technology underneath &lt;a href="https://getstream.io/blog/google-meet-clone/" rel="noopener noreferrer"&gt;Google Meet-style video calls&lt;/a&gt;, and it’s the reason a voice agent can respond in under a second while a WebSocket-based one struggles.&lt;/p&gt;

&lt;p&gt;But WebRTC alone only solves the protocol. There’s still geography. If your user is in Singapore and your server is in Virginia, physics adds 200ms+ of round trip before anything else happens. This is where &lt;a href="https://getstream.io/blog/announcing-stream-video/" rel="noopener noreferrer"&gt;Stream’s global edge network&lt;/a&gt; comes in. The user connects to the nearest of Stream’s edge servers, and audio travels the long haul over Stream’s optimized backbone rather than the public internet. Your agent joins the call the same way, from wherever it’s running.&lt;/p&gt;

&lt;p&gt;The practical upshot is that you shouldn’t build any of it. Not the transport, the edge routing, the codecs, or jitter buffers. You should get it as infrastructure and spend your time on what your agent actually does. That’s the design philosophy behind &lt;a href="https://getstream.io/vision-agents/" rel="noopener noreferrer"&gt;Vision Agents&lt;/a&gt;, Stream’s open-source Python framework for real-time voice and video AI, which is what we’ll use to build ours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the Agent
&lt;/h2&gt;

&lt;p&gt;Time to write some code. The finished agent is a single file, and we’ll walk through all of it. You’ll need Python 3.10–3.13, &lt;a href="https://docs.astral.sh/uv/" rel="noopener noreferrer"&gt;uv&lt;/a&gt;, and API keys from four providers (all have free tiers):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://getstream.io/dashboard/" rel="noopener noreferrer"&gt;Stream&lt;/a&gt; for the WebRTC transport
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://console.deepgram.com/" rel="noopener noreferrer"&gt;Deepgram&lt;/a&gt; for speech-to-text
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://aistudio.google.com/" rel="noopener noreferrer"&gt;Google AI Studio&lt;/a&gt; for the Gemini LLM
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://elevenlabs.io/" rel="noopener noreferrer"&gt;ElevenLabs&lt;/a&gt; for text-to-speech&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these is swappable. Prefer OpenAI or Anthropic for the LLM, or Cartesia for TTS? Or &lt;a href="https://getstream.io/blog/grok4-fish-audio/" rel="noopener noreferrer"&gt;xAI’s Grok-4 for the LLM&lt;/a&gt; paired with Fish Audio for TTS? It’s a one-line change.&lt;/p&gt;

&lt;h3&gt;
  
  
  Project Setup
&lt;/h3&gt;

&lt;p&gt;Create a project and install Vision Agents with the plugins we need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;ai-voice-agent &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;ai-voice-agent
uv init
uv add &lt;span class="s2"&gt;"vision-agents[getstream,deepgram,gemini,elevenlabs]"&lt;/span&gt; python-dotenv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then create a &lt;code&gt;.env&lt;/code&gt; file with your keys:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;STREAM_API_KEY=your_stream_api_key&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;STREAM_API_SECRET=your_stream_api_secret&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;DEEPGRAM_API_KEY=your_deepgram_api_key&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;GOOGLE_API_KEY=your_google_api_key&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;ELEVENLABS_API_KEY=your_elevenlabs_api_key&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Agent
&lt;/h3&gt;

&lt;p&gt;Here’s the complete agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# agent.py
&lt;/span&gt;
&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;A real-time AI voice agent: STT -&amp;gt; LLM -&amp;gt; TTS over WebRTC.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dotenv&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;load_dotenv&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;vision_agents.core&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AgentLauncher&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Runner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;vision_agents.plugins&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;deepgram&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;elevenlabs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gemini&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;getstream&lt;/span&gt;

&lt;span class="nf"&gt;load_dotenv&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;INSTRUCTIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You are a friendly voice assistant. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You are on a live audio call, so keep responses short and conversational — &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;one or two sentences unless the user asks for detail. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Never use markdown, lists, or emoji: everything you say is spoken aloud.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="c1"&gt;# The transport layer: joins the call over WebRTC via Stream's
&lt;/span&gt;        &lt;span class="c1"&gt;# edge network, so audio hops to the nearest edge server instead
&lt;/span&gt;        &lt;span class="c1"&gt;# of crossing the world to a single origin.
&lt;/span&gt;        &lt;span class="n"&gt;edge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;getstream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Edge&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="c1"&gt;# How the agent appears to other participants on the call.
&lt;/span&gt;        &lt;span class="n"&gt;agent_user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Voice Assistant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;INSTRUCTIONS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="c1"&gt;# The pipeline: swap any of these three for another provider
&lt;/span&gt;        &lt;span class="c1"&gt;# without touching the rest of the file.
&lt;/span&gt;        &lt;span class="n"&gt;stt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;deepgram&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;STT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;eager_turn_detection&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;gemini&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;LLM&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;tts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;elevenlabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TTS&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;join_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;call_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;call_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;call&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;call_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# Speak first so the user knows the agent is live.
&lt;/span&gt;        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;simple_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Greet the user briefly and ask how you can help.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;# Keep handling turns until the call ends.
&lt;/span&gt;        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nc"&gt;Runner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AgentLauncher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;create_agent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;create_agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;join_call&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;join_call&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;cli&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That’s an entire AI voice agent. Let’s break down what each part does.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Instructions
&lt;/h3&gt;

&lt;p&gt;This is a standard LLM system prompt, but with voice-specific constraints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;INSTRUCTIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
   &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You are a friendly voice assistant. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
   &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You are on a live audio call, so keep responses short and conversational — &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
   &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;one or two sentences unless the user asks for detail. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
   &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Never use markdown, lists, or emoji: everything you say is spoken aloud.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Text-chat habits are wrong for audio. An LLM that answers with a five-bullet markdown list produces TTS output that reads the asterisks aloud. Telling the model it’s speaking and to use speech patterns (e.g., short sentences) matters more than any fancy prompt engineering you’ll do here. When you adapt this agent, this string defines your agent’s actual job.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Agent Definition
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
   &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
       &lt;span class="n"&gt;edge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;getstream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Edge&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
       &lt;span class="n"&gt;agent_user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Voice Assistant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
       &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;INSTRUCTIONS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;stt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;deepgram&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;STT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;eager_turn_detection&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
       &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;gemini&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;LLM&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
       &lt;span class="n"&gt;tts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;elevenlabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TTS&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
   &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the architecture diagram from earlier, expressed as six keyword arguments:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;edge=getstream.Edge()&lt;/code&gt; is the transport layer.&lt;/strong&gt; It handles WebRTC session negotiation, connects to Stream’s edge network, publishes the agent’s audio track to the call, and subscribes to the user’s audio track. Basically, everything from the “Why transport matters” section above is condensed into this single line.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;agent_user&lt;/code&gt; is the agent’s identity on the call.&lt;/strong&gt; It shows up as a named participant, just like a human would.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;stt&lt;/code&gt;, &lt;code&gt;llm&lt;/code&gt;, and &lt;code&gt;tts&lt;/code&gt; are the three pipeline stages.&lt;/strong&gt; Each is a plugin; Vision Agents wires them together, so audio flows from the call into Deepgram, transcripts flow into Gemini, and Gemini’s streaming output flows into ElevenLabs and back onto the call.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;eager_turn_detection=True&lt;/code&gt; flag on the STT is a latency optimization, which we’ll talk about in the design decisions below.&lt;/p&gt;

&lt;h3&gt;
  
  
  Joining a Call
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;agent.join(call)&lt;/code&gt; is an async context manager:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;join_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;call_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;call_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;call&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;call_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;simple_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Greet the user briefly and ask how you can help.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside the &lt;code&gt;with&lt;/code&gt; block the agent is live on the call, and when the block exits it cleans up the connection.&lt;/p&gt;

&lt;p&gt;The first thing we do is &lt;code&gt;simple_response(...)&lt;/code&gt;. This sends a prompt to the LLM and speaks the result, so the agent greets the user instead of sitting in silence waiting to be spoken to. Then &lt;code&gt;agent.finish()&lt;/code&gt; hands control to the framework’s conversation loop: listen, transcribe, respond, speak, repeat, until the call ends.&lt;/p&gt;

&lt;h3&gt;
  
  
  Run It
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv run agent.py run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first launch takes a few extra seconds. Vision Agents warms up the pipeline components and downloads a small local voice activity detection model (Silero VAD) it uses to tell speech from silence. Then it creates the call, prints the join link, and opens Stream's demo UI in your browser for you:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj88r3519tx63r1yp2u7i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj88r3519tx63r1yp2u7i.png" alt="Vision Agents joining a call when building an AI voice agent" width="800" height="348"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Open the link if the browser didn't launch on its own, allow microphone access, and say hello. You join under a randomly assigned demo name (we drew “Electric Empress”), and the agent sits alongside you as Voice Assistant, the identity we set in &lt;code&gt;agent_user&lt;/code&gt;. It speaks first because &lt;code&gt;join_call&lt;/code&gt; fires that &lt;code&gt;simple_response&lt;/code&gt; greeting before the conversation loop starts:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg1vjfh51u071rasu506o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg1vjfh51u071rasu506o.png" alt="Screenshot of working voice assistant" width="800" height="469"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Note the latency readout in the call's top bar, 21 ms on our run. That's the transport slice of the latency budget below, measured live rather than estimated.&lt;/p&gt;

&lt;p&gt;Two things are worth trying before you move on:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Watch the terminal while you ask a question&lt;/strong&gt;, and you'll see the loop from the architecture section run in order: transcript finalized, response tokens streaming in, synthesized audio heading back out.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interrupt the agent mid-answer.&lt;/strong&gt; It stops talking and hands you the turn, and the next section explains where both behaviors come from.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Key Design Decisions
&lt;/h2&gt;

&lt;p&gt;The script works, but understanding why it works and where the knobs are is what lets you adapt it. It helps to see a full turn end-to-end first. Here's everything that happens between your voice and the agent's:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdoyumycb1tj2i5lu5jqm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdoyumycb1tj2i5lu5jqm.png" alt="Diagram showing key design decisions when making ai voice agent" width="799" height="403"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Everything in this section is about the timing inside that diagram, from when the turn gets declared over, to what happens when you talk over the reply, to where the milliseconds go. &lt;/p&gt;

&lt;p&gt;Four decisions matter most.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Turn Detection: Knowing When to Speak
&lt;/h3&gt;

&lt;p&gt;The hardest question in voice AI isn’t &lt;em&gt;“what should the agent say?”&lt;/em&gt; It’s &lt;em&gt;“when should it start saying it?”&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Humans signal the end of a turn with intonation, grammar, and rhythm. A naive agent that responds after every pause will interrupt you mid-thought (“So I was thinking… [AGENT STARTS TALKING]”). One that waits too long feels laggy. This is a genuine ML problem, and voice stacks solve it with dedicated end-of-turn models rather than simple silence timers.&lt;/p&gt;

&lt;p&gt;In our agent, turn detection comes built into Deepgram’s STT, and the &lt;code&gt;eager_turn_detection=True&lt;/code&gt; flag enables a clever latency trick. When the model suspects your turn is over, it starts the LLM speculatively. If the turn really is over, the response is already in flight, and you’ve saved a few hundred milliseconds. If you keep talking, the draft is discarded, and nothing is lost:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvt4vp42yes3tlx3oiak5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvt4vp42yes3tlx3oiak5.png" alt="Diagram showing how turn detection works" width="800" height="1303"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you pick an STT provider without built-in turn detection (say, a local Whisper model), Vision Agents lets you plug in a standalone detector instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;vision_agents.plugins&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;smart_turn&lt;/span&gt;

&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;...,&lt;/span&gt;
    &lt;span class="n"&gt;stt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;fast_whisper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;STT&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;turn_detection&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;smart_turn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TurnDetection&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Interruption Handling: Letting the User Barge In
&lt;/h3&gt;

&lt;p&gt;In real conversation, people talk over each other constantly. They correct, redirect, or say &lt;em&gt;“no, silly!”&lt;/em&gt; An agent that plows through its full response while you’re trying to interrupt it is instantly infuriating.&lt;/p&gt;

&lt;p&gt;Vision Agents handles this automatically. When the user starts speaking while the agent is mid-response, the framework stops TTS playback, flushes any already-synthesized audio from the outgoing track (so stale speech doesn’t bleed into the next turn), and goes back to listening. You get barge-in behavior without writing any code.&lt;/p&gt;

&lt;p&gt;You can also control interruptibility per utterance when you script specific moments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;say&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;One moment while I check that.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;interrupt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Noise Suppression: Garbage In, Garbage Out
&lt;/h3&gt;

&lt;p&gt;Turn detection and interruption handling are only as good as the audio feeding them. A TV in the background, a second person talking nearby, or keyboard noise can all trigger false barge-ins (the agent stops talking because it “heard” the user) or corrupt transcripts.&lt;/p&gt;

&lt;p&gt;The right place to fix this is as early in the chain as possible, before the audio ever reaches your pipeline. Stream’s client SDKs apply noise suppression and echo cancellation on the capture side, so what arrives at the STT is the user’s voice, not their environment. If your users will call from cafés, cars, or open-plan offices, this is the difference between an agent that works in the demo and one that works in production.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The Latency Budget: Where the Milliseconds Go
&lt;/h3&gt;

&lt;p&gt;Every stage of the loop spends part of your ~1-second budget. Roughly, for a well-tuned pipeline:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Typical cost&lt;/th&gt;
&lt;th&gt;The lever&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Capture + transport (user → agent)&lt;/td&gt;
&lt;td&gt;~30–70 ms&lt;/td&gt;
&lt;td&gt;WebRTC + edge network&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;STT finalization + turn detection&lt;/td&gt;
&lt;td&gt;~100–300 ms&lt;/td&gt;
&lt;td&gt;Eager turn detection hides most of this&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM time-to-first-token&lt;/td&gt;
&lt;td&gt;~200–500 ms&lt;/td&gt;
&lt;td&gt;Model choice; smaller = faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TTS time-to-first-byte&lt;/td&gt;
&lt;td&gt;~100–300 ms&lt;/td&gt;
&lt;td&gt;Provider/model choice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transport back (agent → user)&lt;/td&gt;
&lt;td&gt;~30–70 ms&lt;/td&gt;
&lt;td&gt;WebRTC + edge network&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things to notice. First, the transport rows are small because of WebRTC and edge routing. Doing this over plain HTTP from another continent would mean those rows would dominate the table. Second, everything streams, so these stages overlap. The user starts hearing the response while the LLM is still generating its end.&lt;/p&gt;

&lt;p&gt;There’s one more architectural option on the latency frontier: &lt;a href="https://getstream.io/blog/speech-apis/" rel="noopener noreferrer"&gt;speech-to-speech models&lt;/a&gt; like Gemini Live or OpenAI Realtime, which skip the STT and TTS stages entirely by processing and producing audio natively. In Vision Agents, that’s a one-line change: &lt;code&gt;llm=gemini.Realtime()&lt;/code&gt;, and drop the stt/tts arguments. You gain latency and lose control. No transcript to inspect, no ability to filter or modify the LLM’s text before it’s spoken, less choice over the voice. For most applications, the pipeline’s control is worth its milliseconds, but it’s good to know the trade exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building Your Own AI Voice Agent
&lt;/h2&gt;

&lt;p&gt;Everything specific to your agent lives in two places: the &lt;code&gt;INSTRUCTIONS&lt;/code&gt; string (what the agent’s job is) and the three pipeline components (which providers do the work). The transport, turn-taking, and interruption machinery doesn’t change when you change the agent’s purpose.&lt;/p&gt;

&lt;p&gt;From here, some natural next steps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Give it tools&lt;/strong&gt;. Vision Agents supports function calling and MCP servers, so your agent can look up orders, book appointments, or query your API mid-conversation.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swap providers.&lt;/strong&gt; Try &lt;code&gt;openai.LLM()&lt;/code&gt; or &lt;code&gt;anthropic.LLM()&lt;/code&gt;, or a different ElevenLabs voice, and see how the agent’s character changes. Or, &lt;a href="https://getstream.io/blog/pocket-tts-voice-agent/" rel="noopener noreferrer"&gt;swap in a fully local option&lt;/a&gt; if you need to run offline.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy it.&lt;/strong&gt; &lt;code&gt;uv run agent.py serve&lt;/code&gt; starts an HTTP server that spawns an agent for each incoming call. The first step from local demo toward production, with Docker and Kubernetes guides in the Vision Agents docs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With the transport handled, a real-time voice agent is an afternoon project, and the interesting work is deciding what yours should do.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Build a Voice Agent That Calls to Confirm Fraud Alerts</title>
      <dc:creator>Sarah Lindauer</dc:creator>
      <pubDate>Mon, 10 Aug 2026 15:55:50 +0000</pubDate>
      <link>https://dev.to/getstreamhq/build-a-voice-agent-that-calls-to-confirm-fraud-alerts-26a9</link>
      <guid>https://dev.to/getstreamhq/build-a-voice-agent-that-calls-to-confirm-fraud-alerts-26a9</guid>
      <description>&lt;p&gt;This tutorial &lt;strong&gt;builds a voice agent&lt;/strong&gt; that places an outbound call to a cardholder, reads back a suspicious transaction, and either clears it or freezes the card, all in Python with &lt;a href="https://visionagents.ai/" rel="noopener noreferrer"&gt;Vision Agents&lt;/a&gt;. &lt;/p&gt;

&lt;p&gt;You’ll use speech-to-text from Deepgram, an LLM for the conversation, Cartesia for the voice, and Twilio to place the call. The build is for a fictional issuer, Meridian Bank.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The interesting constraint is that a fraud-alert call and a scam call open identically ("we noticed suspicious activity on your card"), and the only thing that separates them is what the agent does next. &lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A real bank confirms a transaction and never asks the customer to prove themselves with sensitive data. A scammer asks for exactly that. So the rules that keep this bot legitimate are the same rules that keep it from sounding like a scam, and we enforce them in the agent's instructions. &lt;/p&gt;

&lt;p&gt;Let’s build it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We're Building
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6rv1cmy2i29hxjwf8irg.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6rv1cmy2i29hxjwf8irg.gif" alt="Fraud alert voice agent demo" width="600" height="449"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Meridian Bank's fraud system flags a charge and places an outbound call to the cardholder. &lt;/p&gt;

&lt;p&gt;The agent:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Identifies itself as Meridian Bank's fraud line and states why it is calling.
&lt;/li&gt;
&lt;li&gt;Reads the flagged transaction back: merchant, amount, location.
&lt;/li&gt;
&lt;li&gt;Asks the customer to confirm or deny it.
&lt;/li&gt;
&lt;li&gt;If confirmed, it clears the hold so the purchase completes.
&lt;/li&gt;
&lt;li&gt;If denied, it freezes the card, opens a dispute, and hands off to a human specialist.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It runs over &lt;a href="https://getstream.io/video/docs/api/architecture-and-benchmark/" rel="noopener noreferrer"&gt;Stream's edge network for low latency&lt;/a&gt;, with Twilio bridging the phone call. &lt;/p&gt;

&lt;p&gt;The trust boundary from the opening shows up concretely in steps one through three: &lt;strong&gt;1)&lt;/strong&gt; the agent identifies itself, &lt;strong&gt;2)&lt;/strong&gt; reads a transaction back, and &lt;strong&gt;3)&lt;/strong&gt; takes a yes or no. &lt;/p&gt;

&lt;p&gt;It never turns the call into a request for the customer's card number, PIN, password, codes, or money. We'll enforce that in the instructions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Vision Agents Fits This
&lt;/h2&gt;

&lt;p&gt;A fraud callbot is a plumbing problem more than a model problem. &lt;/p&gt;

&lt;p&gt;You need to place a phone call, understand speech coming through a phone codec (mulaw at 8kHz, with background noise and interruptions), respond fast enough that it feels like a conversation and not an interactive voice menu, and take real actions like freezing a card. &lt;/p&gt;

&lt;p&gt;The interesting part is the fraud logic. Everything between the phone line and the model is undifferentiated work.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://getstream.io/vision-agents/" rel="noopener noreferrer"&gt;Vision Agents&lt;/a&gt; collapses that work into one &lt;code&gt;Agent&lt;/code&gt; object with swappable parts. Speech-to-text, the LLM, and &lt;a href="https://getstream.io/blog/text-to-speech/" rel="noopener noreferrer"&gt;text-to-speech&lt;/a&gt; are constructor arguments you can change in a single line. Turn detection is built into Deepgram, so the agent knows when the caller stopped talking. Actions are ordinary async Python functions you register with a decorator. The phone call bridges in through the Twilio plugin without changing the agent itself.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1k713ljalek9dvjwa1yo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1k713ljalek9dvjwa1yo.png" alt="Fraud alert voice agent process diagram" width="800" height="489"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The customer can also join from anywhere, not just a phone. &lt;/p&gt;

&lt;p&gt;The agent runs on a Stream call, and Stream ships &lt;a href="https://getstream.io/video/sdk/" rel="noopener noreferrer"&gt;frontend SDKs&lt;/a&gt; for React, iOS, Android, Flutter, React Native, JavaScript, and Unity. So the same fraud agent can take a Twilio phone call, answer from a "verify this charge" button inside your mobile banking app, or run in a browser. &lt;/p&gt;

&lt;p&gt;You build the agent once and let the customer reach it however they already talk to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;Vision Agents uses &lt;a href="https://docs.astral.sh/uv/" rel="noopener noreferrer"&gt;uv&lt;/a&gt; and targets Python 3.13 for the examples. Install the framework with the plugins this build needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv add &lt;span class="s2"&gt;"vision-agents[getstream, deepgram, cartesia, openai, twilio]"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Get a free Stream API key from &lt;a href="https://getstream.io/try-for-free/" rel="noopener noreferrer"&gt;the Stream dashboard&lt;/a&gt;. The free tier includes 333,000 participant minutes per month, which is more than enough to build and test this.&lt;/p&gt;

&lt;p&gt;Set your keys in a &lt;code&gt;.env&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;STREAM_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...
&lt;span class="nv"&gt;STREAM_API_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...
&lt;span class="nv"&gt;DEEPGRAM_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...
&lt;span class="nv"&gt;CARTESIA_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...
&lt;span class="nv"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...
&lt;span class="nv"&gt;TWILIO_ACCOUNT_SID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...
&lt;span class="nv"&gt;TWILIO_AUTH_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...
&lt;span class="nv"&gt;NGROK_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;NGROK_URL&lt;/code&gt; is the public hostname Twilio will use to reach your machine during development. More on that in the phone section.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Core Agent
&lt;/h2&gt;

&lt;p&gt;Start with the agent itself, no phone yet. This is the part you can run in a browser to hear it work before wiring up telephony.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;vision_agents.core&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;vision_agents.plugins&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;getstream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;deepgram&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cartesia&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt;


&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_agent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;llm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;LLM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gpt-5.5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;edge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;getstream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Edge&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;agent_user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Meridian Fraud Line&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;FRAUD_INSTRUCTIONS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;stt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;deepgram&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;STT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nova-3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;eager_turn_detection&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;tts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cartesia&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TTS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sonic-3.5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things to note, all of which matter in practice:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;llm = openai.LLM(model="gpt-5.5")&lt;/code&gt; is the conversation. This is a one-line swap: any current OpenAI model works, and you can drop in Gemini, Claude, or an OpenAI-compatible endpoint by changing this line alone. &lt;code&gt;gpt-5.5&lt;/code&gt; is OpenAI's current flagship at the time of writing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;stt=deepgram.STT(...)&lt;/code&gt; sets up speech-to-text. &lt;code&gt;eager_turn_detection=True&lt;/code&gt; lowers latency by letting the model decide sooner that the caller has finished talking. It costs a few more tokens but the call feels more natural, which matters when someone is already on edge about a possible fraud charge. Pass &lt;code&gt;model=&lt;/code&gt; explicitly. The plugin's default has changed between releases, so pinning the model keeps your behavior stable.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;tts=cartesia.TTS(...)&lt;/code&gt; sets up the voice. Pin &lt;code&gt;model_id&lt;/code&gt; for the same reason.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;edge=getstream.Edge()&lt;/code&gt; is the transport. It runs on Stream's global edge network, which Stream reports at sub-second join times and audio latency under 30 milliseconds on the transport hop, with a P50 voice round-trip around 240 milliseconds across the full speech-to-text, LLM, and text-to-speech pipeline. Twilio will bridge the phone call into this same call later.&lt;/p&gt;

&lt;p&gt;To run it in a browser during development, wire it to the CLI launcher:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;vision_agents.core&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AgentLauncher&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Runner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;vision_agents.plugins&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;getstream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;deepgram&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cartesia&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt;


&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_agent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# ... as above ...
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;


&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;join_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;call_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;call_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;call&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;call_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;simple_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Introduce yourself as Meridian Bank&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s fraud line and explain you&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;re &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;calling about a recent transaction that needs to be confirmed.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nc"&gt;Runner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AgentLauncher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;create_agent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;create_agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;join_call&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;join_call&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;cli&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv run main.py run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This opens a browser UI where you can talk to the agent directly. You will hear it introduce itself and start the confirmation flow. Get this feeling right before you put it on a phone, because debugging tone and pacing is much easier in the browser than over a live call.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Instructions Are the Product
&lt;/h2&gt;

&lt;p&gt;For most &lt;a href="https://getstream.io/video/voice-ai/" rel="noopener noreferrer"&gt;voice agents&lt;/a&gt;, the system prompt is a personality. For a fraud bot, it is the compliance boundary, and it is the &lt;strong&gt;single most important part of this build&lt;/strong&gt;. &lt;/p&gt;

&lt;p&gt;The instructions have to do two things at once: run the confirmation flow, and hold the line on what the agent will never ask for.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;FRAUD_INSTRUCTIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
You are the automated fraud line for Meridian Bank. You are placing an outbound
call to a cardholder about a transaction our system flagged as unusual. Keep
replies short and conversational. Do not use special characters or formatting.

HOW TO OPEN THE CALL
- Say you are calling from Meridian Bank&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s fraud department.
- Say you are calling to confirm a recent transaction, nothing more.
- Do not create panic. This is a routine check, not an accusation.

HOW TO VERIFY THE TRANSACTION
- Read back only the flagged transaction: merchant, amount, and location.
- Ask the customer to confirm whether they made this purchase, yes or no.
- If they made it, tell them you&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ll clear the hold and the purchase will go through.
- If they did not, tell them you&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ll freeze the card, open a dispute, and connect
  them to a specialist.

WHAT YOU MUST NEVER DO
- Never ask for the full card number, CVV, PIN, or expiration date.
- Never ask for the online banking password or Social Security number.
- Never ask for a one-time passcode or verification code. No real fraud line
  ever asks for these. Asking would make you indistinguishable from a scammer.
- Never ask the customer to move, send, or reverse money.
- Never send or read out a link for them to click.
- We already have the customer&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s account on file. You do not need to collect
  identifying secrets to do your job.

IF THE CUSTOMER IS UNSURE OR THE CALL GETS COMPLICATED
- Offer to let them hang up and call the number on the back of their card. A
  real bank encourages this. Then escalate to a human specialist.
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The line that does the heavy lifting is the reasoning attached to the one-time passcode rule. A model told simply "do not ask for codes" can be argued out of it by a plausible-sounding edge case. A model told why (asking would make it indistinguishable from a scammer) holds the boundary under pressure, because the instruction now carries its own justification.&lt;/p&gt;

&lt;p&gt;This mirrors what real issuers publish. &lt;/p&gt;

&lt;p&gt;A legitimate fraud alert confirms a transaction with a yes or no. It does not extract secrets. Banks state plainly that they will never ask you to verify a full account number, CVV, PIN, or a verification code, and consumer protection guidance is absolute that no real fraud department will ever ask for a one-time code. Encoding that directly is what makes the agent credible to the person on the other end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Giving the Agent Actions
&lt;/h2&gt;

&lt;p&gt;The agent needs to do things, not just talk. &lt;/p&gt;

&lt;p&gt;In Vision Agents, you register plain async Python functions as tools with the &lt;code&gt;@llm.register_function()&lt;/code&gt; decorator, and the LLM calls them when the conversation calls for it.&lt;/p&gt;

&lt;p&gt;One rule to know before you write them: registered functions must be async. If you pass a synchronous function, registration raises a &lt;code&gt;ValueError&lt;/code&gt;. The framework checks with &lt;code&gt;inspect.iscoroutinefunction&lt;/code&gt; at registration time, so this fails fast rather than at runtime.&lt;/p&gt;

&lt;p&gt;Here are the four actions the fraud flow needs. Wire them onto the LLM before you attach it to the agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_agent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;llm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;LLM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gpt-5.5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nd"&gt;@llm.register_function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Confirm a flagged transaction was legitimate and clear the hold.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;confirm_transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transaction_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Call your core banking system to release the hold.
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;confirmed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transaction_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;transaction_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@llm.register_function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Freeze the customer&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s card after they deny a transaction.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;freeze_card&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;card_last4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;frozen&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;card_last4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;card_last4&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@llm.register_function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Open a fraud dispute for a denied transaction.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;open_dispute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transaction_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dispute_opened&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transaction_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;transaction_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@llm.register_function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hand the call off to a human fraud specialist.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;escalate_to_human&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;escalating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;edge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;getstream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Edge&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;agent_user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Meridian Fraud Line&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;FRAUD_INSTRUCTIONS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;stt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;deepgram&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;STT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nova-3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;eager_turn_detection&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;tts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cartesia&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TTS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sonic-3.5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The function name and its parameters become the tool schema the LLM sees, and the &lt;code&gt;description&lt;/code&gt; tells it when to call each one. Notice these functions take a transaction ID and a card's last four digits, never the full number. The tool signatures enforce the same boundary as the instructions: there is no parameter anywhere that could hold a full PAN, CVV, or code, so even a confused model cannot collect one.&lt;/p&gt;

&lt;p&gt;In a real deployment, the function bodies call your core banking and case-management systems. The LLM chains them on its own: a denial leads it to call &lt;code&gt;freeze_card&lt;/code&gt;, then &lt;code&gt;open_dispute&lt;/code&gt;, then &lt;code&gt;escalate_to_human&lt;/code&gt; in sequence. By default the framework allows up to three tool-calling rounds per turn; raise it with &lt;code&gt;openai.LLM(model="gpt-5.5", max_tool_rounds=5)&lt;/code&gt; if a flow needs more steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting It on the Phone
&lt;/h2&gt;

&lt;p&gt;Now the outbound call. Twilio places the call and streams the audio to your agent over a &lt;a href="https://getstream.io/resources/projects/webrtc/fundamentals/real-time-communication-with-websockets/" rel="noopener noreferrer"&gt;WebSocket&lt;/a&gt;, and Vision Agents bridges that stream into the same Stream call the agent already runs on. The agent code above does not change. You are adding a thin telephony layer around it.&lt;/p&gt;

&lt;p&gt;The flow for an outbound call is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Register the call and mint a one-time token for the media stream.
&lt;/li&gt;
&lt;li&gt;Tell Twilio to dial the customer and point its audio at your websocket URL.
&lt;/li&gt;
&lt;li&gt;When Twilio connects, validate the token, attach the phone audio to the Stream call, and let the agent run the conversation.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;click&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;uvicorn&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dotenv&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;load_dotenv&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;WebSocket&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;twilio.rest&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Client&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;vision_agents.plugins&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;twilio&lt;/span&gt;

&lt;span class="nf"&gt;load_dotenv&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;NGROK_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NGROK_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;call_registry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;twilio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TwilioCallRegistry&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initiate_outbound_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from_number&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_number&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;twilio_client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TWILIO_ACCOUNT_SID&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TWILIO_AUTH_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;call_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;prepare_call&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;create_agent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;phone_user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cardholder &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;call_id&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;phone-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;call_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;edge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_users&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;agent_user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;phone_user&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;stream_call&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;call_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;phone_user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stream_call&lt;/span&gt;

    &lt;span class="n"&gt;twilio_call&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;call_registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prepare&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;prepare_call&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wss://&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;NGROK_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/twilio/media/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;call_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;twilio_call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;twilio_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;twiml&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;twilio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_media_stream_twiml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;to_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;from_&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;from_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;call_id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The websocket handler takes over once Twilio connects. It validates the token, accepts the media stream, waits for the prepared agent, and bridges the phone into the call with &lt;code&gt;attach_phone_to_call&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.websocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/twilio/media/{call_sid}/{token}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;media_stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;websocket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;WebSocket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;call_sid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;twilio_call&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;call_registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call_sid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;twilio_stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;twilio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TwilioMediaStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;websocket&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;twilio_stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;twilio_call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;twilio_stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;twilio_stream&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;phone_user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stream_call&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;twilio_call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;await_prepare&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;twilio_call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stream_call&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stream_call&lt;/span&gt;

        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;twilio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attach_phone_to_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stream_call&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;twilio_stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;phone_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stream_call&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;participant_wait_timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;simple_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Introduce yourself as Meridian Bank&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s fraud line and ask the &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer to confirm the flagged transaction.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;twilio_stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;call_registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call_sid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finally, a small entry point starts the server and places the call. The token in the media URL is what stops a random WebSocket connection from hijacking a call, so it is validated before any audio flows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_with_server&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from_number&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_number&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;uvicorn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;localhost&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;log_level&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;info&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;uvicorn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Server&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;server_task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;started&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;initiate_outbound_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;server_task&lt;/span&gt;


&lt;span class="nd"&gt;@click.command&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nd"&gt;@click.option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--from&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;from_number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;help&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Your Twilio number.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@click.option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--to&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;to_number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;help&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The number to call.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from_number&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_number&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;run_with_server&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_number&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Place a call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv run outbound.py &lt;span class="nt"&gt;--from&lt;/span&gt; &lt;span class="s2"&gt;"+15551234567"&lt;/span&gt; &lt;span class="nt"&gt;--to&lt;/span&gt; &lt;span class="s2"&gt;"+15559876543"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twilio sends audio as mulaw at 8kHz. The plugin handles the conversion to and from the format your pipeline expects, so you do not deal with codecs directly. Latency is higher in local development than in production. For a real deployment, run close to your users; on Stream's edge, that means deploying to the region nearest them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What About Compliance?
&lt;/h2&gt;

&lt;p&gt;A few things are worth saying plainly, and none of this is legal advice.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;In the US, the TCPA restricts automated calls, but fraud-prevention calls from a financial institution have generally been treated as exempt under the FCC's rules because they are informational and time-sensitive rather than marketing. The rules around this shift periodically, so a real deployment needs a compliance review rather than a blog post's summary.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;On the technical side, &lt;strong&gt;keep card data out of your transcripts and logs.&lt;/strong&gt; The pipeline in this tutorial never captures a full card number because it never asks for one, which is the cleanest way to stay out of trouble: the safest sensitive data is the data you never collect. The tool signatures back this up by only accepting a transaction ID and a card's last four digits.&lt;/p&gt;

&lt;p&gt;The behavioral rules in the instructions are not “polish you add at the end”. They are what make the call trustworthy to the person answering it. A bot that confirms a transaction and offers to let the customer call the number on the back of their card is behaving exactly like a real bank. A bot that asks for a verification code is behaving exactly like a scammer. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The gap between those two is the entire product.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Go Next
&lt;/h2&gt;

&lt;p&gt;You have an outbound fraud-alert agent that places a real phone call, confirms a transaction, and takes action on the result, with the trust boundary enforced in both the instructions and the tool signatures.&lt;/p&gt;

&lt;p&gt;From here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Swap providers to fit your market.&lt;/strong&gt; The speech-to-text, LLM, and text-to-speech pieces are all one-line changes. &lt;a href="https://visionagents.ai/integrations/llm/sarvam" rel="noopener noreferrer"&gt;Sarvam AI&lt;/a&gt; covers Indian languages, for example.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add answering-machine handling&lt;/strong&gt; for the outbound path so the agent leaves a callback message instead of talking to voicemail.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move to a realtime &lt;a href="https://getstream.io/blog/speech-apis/" rel="noopener noreferrer"&gt;speech-to-speech model&lt;/a&gt;&lt;/strong&gt; if you want the lowest possible latency and can accept coarser transcripts.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skip the scaffolding entirely.&lt;/strong&gt; If you use Claude Code, Cursor, or Codex, &lt;a href="https://getstream.io/agent-skills/" rel="noopener noreferrer"&gt;Stream's Agent Skills&lt;/a&gt; let you drop the voice agent into your project and describe what you want in plain English:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx skills add GetStream/agent-skills &lt;span class="nt"&gt;-s&lt;/span&gt; stream
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The phone build in this tutorial is grounded in the framework's own &lt;a href="https://github.com/GetStream/Vision-Agents/tree/main/examples/03_phone_and_rag_example" rel="noopener noreferrer"&gt;phone example&lt;/a&gt;, which pairs telephony with a knowledge base if you want the agent to answer follow-up questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start Building
&lt;/h2&gt;

&lt;p&gt;Clone the repo, wire in your own fraud logic, and place a test call to your own phone: &lt;a href="https://github.com/GetStream/Vision-Agents" rel="noopener noreferrer"&gt;github.com/GetStream/Vision-Agents&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When you're ready to move past a test number, &lt;a href="https://getstream.io/video/voice-ai/" rel="noopener noreferrer"&gt;Stream's Voice AI&lt;/a&gt; is where this goes to production: hosted STT, LLM, and TTS on a 60+ location edge network, with a P50 voice round-trip around 240 milliseconds.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you want your fraud agent deployed in the same region as your existing Stream chat and video, so a support call, a video session, and a fraud verification all run on one stack, &lt;a href="https://getstream.io/video/voice-ai/" rel="noopener noreferrer"&gt;join the hosted-agents waitlist&lt;/a&gt;. You can &lt;strong&gt;start building on the open-source framework right now&lt;/strong&gt; either way.  &lt;/p&gt;

</description>
      <category>voiceagent</category>
      <category>python</category>
      <category>fraudalert</category>
      <category>voiceai</category>
    </item>
    <item>
      <title>Build a Marketplace Voice Shopping Agent with Kimi K2.5 and Vision Agents</title>
      <dc:creator>Sarah Lindauer</dc:creator>
      <pubDate>Mon, 10 Aug 2026 15:47:00 +0000</pubDate>
      <link>https://dev.to/getstreamhq/build-a-marketplace-voice-shopping-agent-with-kimi-k25-and-vision-agents-am4</link>
      <guid>https://dev.to/getstreamhq/build-a-marketplace-voice-shopping-agent-with-kimi-k25-and-vision-agents-am4</guid>
      <description>&lt;p&gt;A buyer opens your &lt;a href="https://getstream.io/chat/solutions/marketplaces/" rel="noopener noreferrer"&gt;marketplace app&lt;/a&gt; and says: "Find me running shoes under $120, size 10, from a seller who ships in two days." &lt;/p&gt;

&lt;p&gt;Text chatbots stall on requests like that. They need structured follow-ups, live inventory, and seller comparisons across multiple turns. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A voice agent with tool access handles it naturally.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This tutorial walks through a voice shopping concierge for a fictional multi-seller marketplace called Pronto Market. Kimi K2.5 powers the reasoning and function calling. Deepgram transcribes speech. ElevenLabs speaks the replies. &lt;a href="https://getstream.io/vision-agents/" rel="noopener noreferrer"&gt;Vision Agents&lt;/a&gt; orchestrates the pipeline over &lt;a href="https://getstream.io/video/" rel="noopener noreferrer"&gt;Stream's WebRTC edge&lt;/a&gt;, so you can test the whole flow in a browser in under 30 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You'll Build
&lt;/h2&gt;

&lt;p&gt;A conversational voice agent named &lt;strong&gt;Riley&lt;/strong&gt; that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Searches a multi-seller product catalog by query, category, price, and rating
&lt;/li&gt;
&lt;li&gt;Compares sellers on ship time, return policy, and reviews
&lt;/li&gt;
&lt;li&gt;Adds items to a cart after confirming size and price
&lt;/li&gt;
&lt;li&gt;Looks up order status by order ID or email
&lt;/li&gt;
&lt;li&gt;Runs in your browser via Stream, using the same CLI flow as the &lt;a href="https://visionagents.ai/introduction/quickstart" rel="noopener noreferrer"&gt;Vision Agents quickstart&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F74v52zrznzq6h77ly8hf.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F74v52zrznzq6h77ly8hf.gif" alt="Marketplace voice agent demo" width="720" height="590"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Kimi has no native realtime speech API today, so we use a custom pipeline (STT → LLM → TTS) instead of a single speech-to-speech model. That tradeoff buys you two things Kimi excels at: reliable function calling across multi-step shopping flows, and the freedom to swap STT or TTS providers without rewriting your agent.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you want a camera-and-vision take on Kimi instead of a voice-and-commerce one, see our earlier walkthrough on &lt;a href="https://getstream.io/blog/kimi-k2-agent/" rel="noopener noreferrer"&gt;building a video agent with Kimi K2.5&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The catalog and order data live in a local Python module. That means a database, Stripe checkout, and external marketplace API &lt;strong&gt;aren’t required&lt;/strong&gt; to get started.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://platform.moonshot.ai/" rel="noopener noreferrer"&gt;Kimi K2.5&lt;/a&gt; (&lt;code&gt;kimi-k2.5&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Strong reasoning, native tool use, 256K context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;STT&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://deepgram.com/" rel="noopener noreferrer"&gt;Deepgram&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Fast transcription with eager turn detection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TTS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://elevenlabs.io/" rel="noopener noreferrer"&gt;ElevenLabs&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Natural, retail-friendly voice output&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Transport&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://getstream.io/video/" rel="noopener noreferrer"&gt;Stream WebRTC&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Sub-500ms edge delivery, browser demo out of the box&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Framework&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/GetStream/Vision-Agents" rel="noopener noreferrer"&gt;Vision Agents&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Provider-agnostic orchestration, open source&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Audio flows: user speaks → Deepgram STT → Kimi K2.5 (with tools) → ElevenLabs TTS → user hears the reply.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A note on model versions:&lt;/strong&gt; This tutorial uses &lt;code&gt;kimi-k2.5&lt;/code&gt;, released January 2026. Moonshot has since shipped newer models (k2.6, k2.7) and rotates the k2 series over time. If &lt;code&gt;kimi-k2.5&lt;/code&gt; no longer resolves when you run this, check the &lt;a href="https://platform.moonshot.ai/" rel="noopener noreferrer"&gt;Moonshot model list&lt;/a&gt; and swap the model string. The code stays the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Requirements
&lt;/h2&gt;

&lt;p&gt;You'll need API keys from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://platform.moonshot.ai/" rel="noopener noreferrer"&gt;Moonshot Platform&lt;/a&gt; for &lt;code&gt;MOONSHOT_API_KEY&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://deepgram.com/" rel="noopener noreferrer"&gt;Deepgram&lt;/a&gt; for &lt;code&gt;DEEPGRAM_API_KEY&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://elevenlabs.io/" rel="noopener noreferrer"&gt;ElevenLabs&lt;/a&gt; for &lt;code&gt;ELEVENLABS_API_KEY&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://getstream.io/try-for-free/" rel="noopener noreferrer"&gt;Stream&lt;/a&gt; for &lt;code&gt;STREAM_API_KEY&lt;/code&gt; and &lt;code&gt;STREAM_API_SECRET&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You'll also need Python 3.12 and &lt;a href="https://docs.astral.sh/uv/getting-started/installation/" rel="noopener noreferrer"&gt;uv&lt;/a&gt; installed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Scaffold the Project
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv init pronto-voice-shop &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;pronto-voice-shop
uv add &lt;span class="s2"&gt;"vision-agents[openai,deepgram,elevenlabs,getstream]"&lt;/span&gt; python-dotenv
uv add &lt;span class="s2"&gt;"getstream&amp;gt;=3.4.0,&amp;lt;3.5"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last line pins the Stream Python SDK. Fresh installs currently resolve &lt;code&gt;getstream&lt;/code&gt; 3.5.0, which breaks &lt;code&gt;getstream.Edge()&lt;/code&gt; on vision-agents 0.6.x. Pin to 3.4.x until that compatibility issue is fixed upstream.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;uv init&lt;/code&gt; creates &lt;code&gt;main.py&lt;/code&gt; and &lt;code&gt;pyproject.toml&lt;/code&gt;. We'll work in two files: &lt;code&gt;catalog.py&lt;/code&gt; for the mock data and tools, and &lt;code&gt;agent.py&lt;/code&gt; for the agent itself. Rename the generated file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mv &lt;/span&gt;main.py agent.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create a &lt;code&gt;.env&lt;/code&gt; file in the project root and add your keys:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# .env&lt;/span&gt;
&lt;span class="nv"&gt;STREAM_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your_stream_api_key
&lt;span class="nv"&gt;STREAM_API_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your_stream_api_secret
&lt;span class="nv"&gt;MOONSHOT_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your_moonshot_api_key
&lt;span class="nv"&gt;DEEPGRAM_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your_deepgram_api_key
&lt;span class="nv"&gt;ELEVENLABS_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your_elevenlabs_api_key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Vision Agents auto-loads these per plugin via &lt;code&gt;load_dotenv()&lt;/code&gt;. &lt;code&gt;MOONSHOT_API_KEY&lt;/code&gt; is required. The agent reads it directly in &lt;code&gt;setup_llm()&lt;/code&gt; and will not start without it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Add the Mock Marketplace Catalog
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;catalog.py&lt;/code&gt; in the project root. This file holds sellers, products, a session cart, and sample orders. Everything is in memory, so the tutorial runs without external services.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# catalog.py
&lt;/span&gt;
&lt;span class="n"&gt;SELLERS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;peak-gear&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Peak Gear Co.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;4.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;return_policy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;30-day free returns&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;avg_ship_days&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;trailhead-supply&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Trailhead Supply&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;4.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;return_policy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;14-day returns, buyer pays shipping&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;avg_ship_days&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;urban-run&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Urban Run&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;4.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;return_policy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;45-day returns on unworn items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;avg_ship_days&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;PRODUCTS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;boot-001&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TrailRunner GTX&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;category&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;footwear&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;waterproof hiking boots gore-tex trail&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;129.99&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sizes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;11&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seller_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;peak-gear&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;4.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ships_in_days&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shoe-001&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;City Sprint&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;category&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;footwear&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;daily running shoes breathable mesh&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;89.99&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sizes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;11&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seller_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;urban-run&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;4.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ships_in_days&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
   &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jacket-001&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;StormShell Rain Jacket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;category&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;outerwear&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;waterproof packable rain jacket hiking&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;79.99&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sizes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;S&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;M&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;L&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;XL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seller_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;trailhead-supply&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;4.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ships_in_days&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pack-001&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Daypack 28L&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;category&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bags&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;day hiking backpack hydration compatible&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;59.99&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sizes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;one-size&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seller_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;trailhead-supply&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;4.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ships_in_days&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;ORDERS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ORD-1042&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alex@example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shipped&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tracking&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1Z999AA10123456784&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;product_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shoe-005&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;size&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quantity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;estimated_delivery&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-06-27&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ORD-1038&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jamie@example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;processing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tracking&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;product_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;boot-001&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;size&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;11&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quantity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;estimated_delivery&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-06-29&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;CART&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_product_by_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PRODUCTS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search_products&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;500.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;min_rating&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;query_lower&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PRODUCTS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;category&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;max_price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;min_rating&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;

        &lt;span class="n"&gt;haystack&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;category&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="n"&gt;SELLERS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seller_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;query_lower&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;haystack&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;word&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;haystack&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;word&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;query_lower&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;word&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
        &lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;seller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SELLERS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seller_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
            &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sizes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sizes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seller&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;seller&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seller_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seller_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ships_in_days&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ships_in_days&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ships_in_days&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_seller_info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seller_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;seller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SELLERS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seller_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;seller&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Unknown seller: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;seller_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seller_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;seller_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;seller&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;seller&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;return_policy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;seller&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;return_policy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;avg_ship_days&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;seller&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;avg_ship_days&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;add_to_cart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_product_by_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Product not found: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sizes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Size &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; not available. Available: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sizes&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;product_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;size&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quantity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unit_price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;line_total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;CART&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cart_size&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CART&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;added&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cart_total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;line_total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;CART&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_order_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ORDERS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No order found. Check the order ID or email and try again.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: Register Tools on Kimi K2.5
&lt;/h2&gt;

&lt;p&gt;Replace the contents of &lt;code&gt;agent.py&lt;/code&gt; with the code below. The &lt;code&gt;setup_llm()&lt;/code&gt; function wires Kimi through the OpenAI-compatible Chat Completions API and registers four marketplace tools.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# agent.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dotenv&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;load_dotenv&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;vision_agents.core&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AgentLauncher&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Runner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;vision_agents.plugins&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;deepgram&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;elevenlabs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;getstream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;catalog&lt;/span&gt;

&lt;span class="nf"&gt;load_dotenv&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;MARKETPLACE_INSTRUCTIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;&lt;span class="s"&gt;You are Riley, a voice shopping assistant for Pronto Market, a multi-seller marketplace.

Voice rules:
- One or two short sentences per turn. Max 25 words.
- Ask one clarifying question at a time.
- Before adding to cart, confirm product, size, and price.
- When comparing sellers, mention rating and ship time, not long feature lists.

Use tools to search, compare sellers, update the cart, and check orders. Never invent inventory.
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;setup_llm&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ChatCompletionsLLM&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;llm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ChatCompletionsLLM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kimi-k2.5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.moonshot.ai/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MOONSHOT_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nd"&gt;@llm.register_function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Search marketplace products by query, category, max price, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;and minimum seller rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search_products&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;max_price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;500.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;min_rating&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search_products&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;min_rating&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nd"&gt;@llm.register_function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Get seller profile: rating, return policy, average ship time&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_seller_info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seller_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_seller_info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seller_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nd"&gt;@llm.register_function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Add a product to the buyer&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s cart&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;add_to_cart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_to_cart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nd"&gt;@llm.register_function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Look up order status by order ID or buyer email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_order_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_order_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;llm&lt;/span&gt;


&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;edge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;getstream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Edge&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;agent_user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Riley, Pronto Market&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MARKETPLACE_INSTRUCTIONS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;setup_llm&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;stt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;deepgram&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;STT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;eager_turn_detection&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;tts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;elevenlabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TTS&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;join_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;call_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;call_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;call&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;call_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;simple_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Greet the buyer warmly. You are Riley from Pronto Market. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Ask what they are looking for today.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="n"&gt;runner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Runner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AgentLauncher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;create_agent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;create_agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;join_call&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;join_call&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cli&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kimi K2.5 supports function calling with automatic tool invocation. When a buyer asks about boots under $150, Kimi calls &lt;code&gt;search_products&lt;/code&gt;, reads the results, and speaks a short summary back through ElevenLabs. See the &lt;a href="https://visionagents.ai/integrations/kimi" rel="noopener noreferrer"&gt;Kimi integration docs&lt;/a&gt; and &lt;a href="https://visionagents.ai/guides/mcp-tool-calling" rel="noopener noreferrer"&gt;function calling guide&lt;/a&gt; for more patterns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Run It
&lt;/h2&gt;

&lt;p&gt;Start the agent from the project directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv run agent.py run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI prints a join link. Open it in your browser, allow microphone access, and start talking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try this script:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;"I'm looking for waterproof hiking boots under $150."
&lt;/li&gt;
&lt;li&gt;"Which seller ships fastest?"
&lt;/li&gt;
&lt;li&gt;"Add size 10 to my cart."
&lt;/li&gt;
&lt;li&gt;"Where's order ORD-1042?"&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Example dialogue:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Riley:&lt;/strong&gt; Hi, I'm Riley from Pronto Market. What are you shopping for today?  &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You:&lt;/strong&gt; Waterproof hiking boots under $150.  &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Riley:&lt;/strong&gt; I found three options. TrailRunner GTX from Peak Gear is $129.99, ships in two days. Want size 10?  &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You:&lt;/strong&gt; Which seller ships fastest?  &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Riley:&lt;/strong&gt; Urban Run ships in one day, but they sell running shoes, not boots. For boots, Peak Gear ships fastest at two days.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That back-and-forth is Kimi chaining tool calls across turns while keeping replies short enough for voice.&lt;/p&gt;

&lt;h2&gt;
  
  
  From Demo to Production
&lt;/h2&gt;

&lt;p&gt;The mock catalog in &lt;code&gt;catalog.py&lt;/code&gt; is a stand-in for your real backend. In production, swap each tool function for calls to your own services:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;search_products&lt;/code&gt;&lt;/strong&gt; → catalog search API (Algolia, Elasticsearch, or your OMS)
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;get_seller_info&lt;/code&gt;&lt;/strong&gt; → seller profile service
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;add_to_cart&lt;/code&gt;&lt;/strong&gt; → cart session API (the same endpoints you'd expose to a text agent)
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;get_order_status&lt;/code&gt;&lt;/strong&gt; → order management system&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Text-based agentic commerce is standardizing around protocols like the &lt;a href="https://www.agenticcommerce.dev/" rel="noopener noreferrer"&gt;Agentic Commerce Protocol (ACP)&lt;/a&gt; and Google's Universal Commerce Protocol (UCP). Voice fits the same architecture: the input modality changes, but the checkout session, inventory lookup, and order tracking stay the same. Your voice agent calls the same REST or MCP endpoints a chat agent would.&lt;/p&gt;

&lt;p&gt;To reach buyers outside the browser:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Phone:&lt;/strong&gt; extend this agent with &lt;a href="https://visionagents.ai/examples/twilio-phone-agent" rel="noopener noreferrer"&gt;Twilio phone integration&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mobile app:&lt;/strong&gt; join the same Stream call from &lt;a href="https://getstream.io/video/sdk/" rel="noopener noreferrer"&gt;Stream's client SDKs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Managed hosting:&lt;/strong&gt; &lt;a href="https://getstream.io/video/voice-ai/" rel="noopener noreferrer"&gt;Stream Voice AI&lt;/a&gt; runs production voice agents on Stream's global edge with co-located STT, LLM, and TTS. &lt;a href="https://getstream.io/video/voice-ai/" rel="noopener noreferrer"&gt;Join the waitlist&lt;/a&gt; for early access.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Kimi K2.5 for Marketplace Voice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Multi-tool reasoning.&lt;/strong&gt; A single shopping request often needs search, seller comparison, and cart update in sequence. Kimi K2.5 handles that reliably.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;256K context window.&lt;/strong&gt; You can load full seller policy documents, return FAQs, or category guidelines into the system prompt without setting up RAG first. Useful when marketplace rules vary by seller.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OpenAI-compatible API.&lt;/strong&gt; Kimi plugs into Vision Agents through &lt;code&gt;openai.ChatCompletionsLLM&lt;/code&gt; with a custom base URL. Same pattern works for other OpenAI-compatible providers if you want to benchmark latency or cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Room to grow.&lt;/strong&gt; The same &lt;code&gt;Agent&lt;/code&gt; class supports video processors later. A follow-up tutorial could let buyers hold up a product to the camera and ask "do you have this in size 10?"&lt;/p&gt;

&lt;p&gt;We like Kimi for marketplace flows because the model stays grounded when tool results come back mid-conversation. It summarizes inventory instead of inventing products, which matters when money is on the line.&lt;/p&gt;

&lt;p&gt;A tuning note: Moonshot's published guidance for K2.5 is temperature 1.0 for thinking mode and 0.6 for instant mode, with top_p 0.95. The code above doesn't set temperature, so you get the provider default. If you want shorter, more deterministic replies for voice, start at the instant-mode setting and test from there.&lt;/p&gt;

&lt;p&gt;Give it a try, swap in your catalog API, and let Riley handle the voice layer while Kimi does the reasoning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Star &lt;a href="https://github.com/GetStream/Vision-Agents" rel="noopener noreferrer"&gt;Vision Agents on GitHub&lt;/a&gt;&lt;/strong&gt; if this helped. Pull requests and issue reports welcome.&lt;/p&gt;

</description>
      <category>voiceai</category>
      <category>visionagents</category>
      <category>voiceshoppingagent</category>
      <category>kimik2</category>
    </item>
    <item>
      <title>How To Build a Social Media App: Types, Features, Monetization</title>
      <dc:creator>Sarah Lindauer</dc:creator>
      <pubDate>Fri, 20 Mar 2026 20:32:54 +0000</pubDate>
      <link>https://dev.to/getstreamhq/how-to-build-a-social-media-app-types-features-monetization-132d</link>
      <guid>https://dev.to/getstreamhq/how-to-build-a-social-media-app-types-features-monetization-132d</guid>
      <description>&lt;p&gt;People leave social media apps for many reasons, such as a lack of trust in leadership, to improve mental health, or because of political alignment. Many are just looking for a more authentic social platform. To be that platform, you'll need to start with a roadmap and &lt;a href="https://getstream.io/blog/build-a-social-media-app/" rel="noopener noreferrer"&gt;build your social media network&lt;/a&gt; from the ground up.&lt;/p&gt;

&lt;p&gt;Let's take a high-level overview of what you need to get started.&lt;/p&gt;

&lt;h2&gt;
  
  
  Overview of the Social Media App Market
&lt;/h2&gt;

&lt;p&gt;Various social media platforms have come and gone over the years, with some staying around long term, like Snapchat, and others becoming popular before losing steam, like Clubhouse. As of winter 2023, older platforms like Facebook, Instagram, and YouTube are still on top, but &lt;a href="https://datareportal.com/social-media-users" rel="noopener noreferrer"&gt;TikTok is growing fast&lt;/a&gt;. Twitter usage has fluctuated in recent years, dipping in 2018 and 2019 before seeing growth again, while &lt;a href="https://www.businessofapps.com/data/pinterest-statistics/" rel="noopener noreferrer"&gt;Pinterest&lt;/a&gt; usage declined recently.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.businessofapps.com/data/social-app-market/#:~:text=The%20average%20smartphone%20user%20spends%202%20hours%20and%2020%20minutes%20a%20day%20or%2070%20hours%20on%20social%20media%20apps%20every%20month%20and%2055%25%20of%20the%20population%2C%20or%204.88%20billion%20people%2C%20have%20social%20media%20accounts." rel="noopener noreferrer"&gt;Business of Apps&lt;/a&gt; says 4.88 billion people worldwide have social media accounts, and smartphone users spend 70 hours using social media apps monthly. According to a &lt;a href="https://www.grandviewresearch.com/press-release/global-social-networking-app-market" rel="noopener noreferrer"&gt;report by Grand View Research&lt;/a&gt;, Inc., the social networking app market is projected to rise to $310.37 billion US by 2030. It's expected to have a compound annual growth rate of 26.2% from 2023 to 2030. Grand View Research attributes this to an increase in demand for digital marketing services globally. It's also due to increased social media adoption from all age groups, increased use of smartphones, and increased internet access.&lt;/p&gt;

&lt;p&gt;Musk's Twitter purchase sent people away from the app, including prominent users, though many searched for alternatives to join. In the wake, alternative online communities have gained users. Some growth has been at a sustainable pace, like &lt;a href="https://www.businessofapps.com/data/discord-statistics/" rel="noopener noreferrer"&gt;Discord&lt;/a&gt; and &lt;a href="https://www.businessofapps.com/data/reddit-statistics/" rel="noopener noreferrer"&gt;Reddit&lt;/a&gt;, and some has been temporary, like the spike seen at Mastodon, which slowed in the months following.&lt;/p&gt;

&lt;p&gt;Among the &lt;a href="https://blog.hubspot.com/marketing/state-of-social-media" rel="noopener noreferrer"&gt;top social media trends&lt;/a&gt; are &lt;a href="https://getstream.io/blog/user-generated-content-examples/" rel="noopener noreferrer"&gt;user-generated content&lt;/a&gt; and highly personalized content. TikTok excels at both of these since users primarily create and share video content, and the app's #ForYou page is the main way users discover content. &lt;a href="https://getstream.io/blog/tiktok-feed-lessons/" rel="noopener noreferrer"&gt;TikTok's algorithms&lt;/a&gt; excel at helping users find content that resonates with them and feels genuine.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://blog.hootsuite.com/how-to-make-money-on-social-media/#:~:text=3.%20Enroll%20in%20platform%2Dspecific%20monetization%20programs" rel="noopener noreferrer"&gt;Monetization&lt;/a&gt; of user accounts is keeping them on platforms like TikTok and Facebook that pay them for the number of views their content garners as more people look to earn money on social media. &lt;a href="https://sproutsocial.com/insights/creator-marketing/" rel="noopener noreferrer"&gt;Influencer marketing&lt;/a&gt; continues to grow as a marketing tactic for many companies that have found authenticity is key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Types of Social Media Apps
&lt;/h2&gt;

&lt;p&gt;Each &lt;a href="https://getstream.io/blog/social-app-types/" rel="noopener noreferrer"&gt;type of social media application&lt;/a&gt; has its own unique audience and purpose. For example, Facebook attracts older users for social networking, while Snapchat targets younger users and is designed for photo sharing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Social Networks | Examples: Facebook, LinkedIn
&lt;/h3&gt;

&lt;p&gt;Considered traditional or legacy apps, social networks allow people to interact in a multitude of ways, through profiles, activity feeds, posts, photo and video content, groups, and more. Sites like these are some of the most successful because they've continuously adapted over time while continuing to grow. People use Facebook to connect with faraway family and friends, and they use LinkedIn to build and maintain their professional network.&lt;/p&gt;

&lt;h3&gt;
  
  
  Photo Sharing | Examples: Instagram, Snapchat
&lt;/h3&gt;

&lt;p&gt;Apps like Instagram allow users to share photos with captions and filters, as well as infographics and direct messages. Instagram launched at a time when mobile phone cameras became better, and Snapchat created something entirely new with ephemeral content that disappears within 24 hours. In recent years, Instagram and other photo apps have incorporated more video to compete with apps like TikTok.&amp;nbsp;&lt;/p&gt;

&lt;h3&gt;
  
  
  Video Sharing | Examples: YouTube, TikTok
&lt;/h3&gt;

&lt;p&gt;Some video-sharing apps are better for longer videos, like YouTube and Vimeo, while others focus on short-form content, like TikTok. YouTube is one of the oldest video-sharing platforms, making it extremely well known. It's accessed by more than 2.5 billion people each month. TikTok's success comes from its interactive capabilities and its #ForYou page algorithm that keeps users scrolling for hours. The app has more than 3 billion downloads.&amp;nbsp;&lt;/p&gt;

&lt;h3&gt;
  
  
  Content Sharing | Examples: Pinterest, SlideShare
&lt;/h3&gt;

&lt;p&gt;Content-sharing apps provide a place for users to share multiple types of content, but they typically lean toward informational content in visual formats. Pinterest serves as a virtual pinboard where users add links, images, and photos from around the internet. They can also upload their own content, like how-tos, recipes, and infographics. People gravitate toward Pinterest for planning and organizing their thoughts, like gathering ideas for a birthday party, inspiration for a kitchen renovation, or tips on childcare. SlideShare users upload professional presentation decks, making it a strong contender for business use.&lt;/p&gt;

&lt;h3&gt;
  
  
  Blogging/Microblogging | Examples: Medium, Twitter
&lt;/h3&gt;

&lt;p&gt;Blogs and microblogs are primarily text-based but include photos, videos, and links as well. On sites like Medium, users can publish articles or blogs with media embedded. Many professionals use the site to share long-form thought leadership content. Twitter, an early social media app, provides a place for users to have conversations, publicly or privately, in media attachments and a maximum of 280 characters.&lt;/p&gt;

&lt;h3&gt;
  
  
  Post/Forum | Examples: Reddit, Quora
&lt;/h3&gt;

&lt;p&gt;Forum-type sites serve as a place to have conversations and connect mainly through text posts and comments. Reddit, founded in 2005, allows users to join communities where moderators help guide discussions on a number of topics, from current events to personal finance to favorite television shows. Question-and-answer platform Quora draws many professionals who have the know-how to answer questions and share knowledge about their areas of expertise.&lt;/p&gt;

&lt;h3&gt;
  
  
  Messaging | Examples: WhatsApp, Discord
&lt;/h3&gt;

&lt;p&gt;Similar to text or SMS messages, social media messaging services are mostly used for sending text messages but also let users share photos, videos, gifs, audio, and more. &lt;a href="https://getstream.io/blog/whatsapp-features/" rel="noopener noreferrer"&gt;WhatsApp&lt;/a&gt; grew in popularity because it offered a way for users to message and call each other for free, including internationally. Discord drew a large audience of gamers and gave them a space to create &lt;a href="https://getstream.io/blog/in-game-chat/" rel="noopener noreferrer"&gt;online gaming communities&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Social Media App Build Process
&lt;/h2&gt;

&lt;p&gt;When you align the process of building your own social media app with the phases of &lt;a href="https://www.notion.so/blog/project-management-playbook-for-founders" rel="noopener noreferrer"&gt;project management&lt;/a&gt;, the roadmap to launching your product includes five phases.&amp;nbsp;&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 1: Project Initiation&amp;nbsp;
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Document the big picture for your app idea.&lt;/strong&gt; What niche will your social media app fill, or what problem will it solve for its users? You can do this with a &lt;a href="https://www.notion.so/blog/product-vision-boards-for-teams" rel="noopener noreferrer"&gt;product vision board&lt;/a&gt; or a product requirement document. These techniques help you outline your business goals, stay focused on your vision, and determine the target audience for your app.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Start your go-to-market strategy.&lt;/strong&gt; Develop an ideal customer profile and research your competitors. Begin thinking about how you'll position and market your app and what tactics might be best to promote it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Phase 2: Project Planning
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Make key development decisions.&lt;/strong&gt; Will you develop your social media app for &lt;a href="https://getstream.io/chat/sdk/ios/" rel="noopener noreferrer"&gt;iOS&lt;/a&gt; or &lt;a href="https://getstream.io/chat/sdk/android/" rel="noopener noreferrer"&gt;Android&lt;/a&gt;? What development framework and tech stack will you use? What features do you want your app to have? Answering these questions will help you determine how long it will take to create your app and what resources you'll need. (We'll look at these decisions in depth later.)&amp;nbsp;&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Choose your project management methodology.&lt;/strong&gt; Waterfall, Kanban, Agile, or something else? Each method has pros and cons. Consider the outcomes you'd like to achieve with your social media app.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Create your project timeline and budget.&lt;/strong&gt; You'll be able to estimate timing and costs based on your selections to this point. For example, if you've opted to build an app for Apple iOS, you can include a line item in your budget for the &lt;a href="https://developer.apple.com/support/compare-memberships/" rel="noopener noreferrer"&gt;developer membership fee&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Phase 3: Project Execution&amp;nbsp;
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Create an admin panel.&lt;/strong&gt; The admin panel will serve as your operations headquarters to control your app. It's a space where you can view user activations, ban users who violate your terms of service, oversee app features, and more.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Design your mobile app.&lt;/strong&gt; Create a wireframe for your app to map out its functionality using best practices for &lt;a href="https://getstream.io/blog/mobile-app-apis/" rel="noopener noreferrer"&gt;UI/UX design&lt;/a&gt;. Consistency in the design and simple, intuitive navigation help keep users coming back.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Complete app development and quality assurance.&lt;/strong&gt; Hand your project plans and design to the team who will develop your social media app. Complete QA testing to address bugs. Social media users will jump to another platform when one isn't working correctly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Phase 4: Project Monitoring and Controlling&amp;nbsp;
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Track project timeline, progress, and costs.&lt;/strong&gt; Maintain documentation on the progress of your app throughout, so you will have a thorough history of what went smoothly and what didn't. You can use that information going forward when making updates and adding features to your social media app.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Adjust for unexpected delays, issues, and cost overruns.&lt;/strong&gt; Modify your timeline and budget to accommodate &lt;a href="https://getstream.io/blog/hidden-build-costs/" rel="noopener noreferrer"&gt;changes to your product roadmap&lt;/a&gt;. There may be outside influences, like new legislation, or trends among competitors that prompt the adjustments.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Phase 5: Project Closing&amp;nbsp;
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Launch your app and wrap up the project.&lt;/strong&gt; Release your social media app to the world and work on increasing downloads and building an active user base. Wrap up your project by making sure all related expenses are settled. Then archive all project plans, budgets, and documents.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Gather feedback from the team and users.&lt;/strong&gt; Feedback helps you improve your social media app. You can also use this information on future projects or updates to your app.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Considerations for the Application Development Phase
&lt;/h2&gt;

&lt;p&gt;The decisions you make during the development phase will impact which technologies you use — from the backend development to the frontend user experience to which app store it's available through.&amp;nbsp;&lt;/p&gt;

&lt;h3&gt;
  
  
  Pick Your Platform&amp;nbsp;
&lt;/h3&gt;

&lt;p&gt;Startups typically develop their app for either Apple iOS or Android first — not both. The choice will depend on factors like your timeline and target market. While Android usage leads the way globally, iOS usage leads in countries like Japan and the United States.&amp;nbsp;&lt;/p&gt;

&lt;h3&gt;
  
  
  Pin Down Your Development Framework&amp;nbsp;
&lt;/h3&gt;

&lt;p&gt;Selecting the type of &lt;a href="https://appmaster.io/blog/effective-software-development-framework" rel="noopener noreferrer"&gt;development framework&lt;/a&gt; to use will depend on your team's skills and your budget.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;SaaS&lt;/strong&gt; — Many &lt;a href="https://www.g2.com/categories/application-development-platforms" rel="noopener noreferrer"&gt;SaaS companies&lt;/a&gt; can help you build an app quickly with pre-built features. These services are DIY, so customization options will be limited.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;CMS&lt;/strong&gt; — &lt;a href="https://www.g2.com/categories/online-community-management#learn-more" rel="noopener noreferrer"&gt;Community management software&lt;/a&gt; allows you to create online community spaces that you can control. While they can be useful for brands that already have a loyal following, they lack customization capabilities.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Open-source&lt;/strong&gt; — &lt;a href="https://www.designrush.com/agency/mobile-app-design-development/trends/free-app-builders" rel="noopener noreferrer"&gt;Open-source software&lt;/a&gt; is pre-built software that offers a lot of flexibility. You can even find no-code or low-code options, and some software is free.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Develop from the ground up&lt;/strong&gt; — To develop an app from scratch, you'll need to &lt;a href="https://www.turing.com/resources/know-all-about-hiring-an-app-developer" rel="noopener noreferrer"&gt;hire skilled developers or an app development company&lt;/a&gt;. This is an expensive option but gives you the ability to develop a custom app to suit your specific needs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Choose Your Tech Stack&amp;nbsp;
&lt;/h3&gt;

&lt;p&gt;Your platform choice and development framework will help you narrow down the rest of your technology stack for your app. If you're not using an agency to create your app, closely evaluate your options for APIs, plugins, and &lt;a href="https://getstream.io/chat/sdk/" rel="noopener noreferrer"&gt;chat SDKs&lt;/a&gt;. These can be used to connect to app features like sign-in, chat, geolocation services, and more.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://getstream.io/blog/build-vs-buy-chat/" rel="noopener noreferrer"&gt;right external tech solutions&lt;/a&gt; can help you increase efficiencies and cut down your app development cost and timeline. Consider the limitations of the technology, cost, and scalability. Technology that's limited or can't scale to the size of your user base will result in a bad user experience and harm retention.&lt;/p&gt;

&lt;p&gt;You'll also need a database, such as &lt;a href="https://www.mysql.com/" rel="noopener noreferrer"&gt;MySQL&lt;/a&gt; or &lt;a href="https://aws.amazon.com/rds/" rel="noopener noreferrer"&gt;Amazon RDS&lt;/a&gt;, and a storage solution, like &lt;a href="https://aws.amazon.com/" rel="noopener noreferrer"&gt;Amazon AWS&lt;/a&gt; or &lt;a href="https://cloud.google.com/appengine" rel="noopener noreferrer"&gt;Google App Engine&lt;/a&gt;. Capabilities, cost, and scalability are equally important for your database and storage.&lt;/p&gt;

&lt;h3&gt;
  
  
  Select Your App Features
&lt;/h3&gt;

&lt;p&gt;Start with the basic &lt;a href="https://getstream.io/blog/social-media-app-features/" rel="noopener noreferrer"&gt;social media app features&lt;/a&gt;. Every social media network includes user profiles and activity feeds. Then consider what must-have features and advanced features will attract your target market and boost user engagement.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;User profiles&lt;/strong&gt; are an area where users can provide basic information about themselves, their company, or their organization.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Newsfeeds&lt;/strong&gt;, or &lt;a href="https://getstream.io/activity-feeds/" rel="noopener noreferrer"&gt;activity feeds&lt;/a&gt;, provide a list of actions that other users have taken and are updated in real time.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Content&lt;/strong&gt; will depend on the type of social media network you create and can include text, images, video, or external links.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;In-app content creation&lt;/strong&gt; tools help users create content from scratch without having to leave the app.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Augmented reality&lt;/strong&gt; within social media lets users apply filters or effects to images.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Search bar functionality&lt;/strong&gt; on social media helps users find each other and find publicly viewable content about specific topics.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Social SEO&lt;/strong&gt; allows users to use SEO strategies to help make their profiles more visible in search engines.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Chat/messaging&lt;/strong&gt; services allow users of your app to connect privately and in small groups.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Groups/discussion forums&lt;/strong&gt; provide a place for users to connect with others on special interest topics.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Push notifications&lt;/strong&gt; send alerts to your users to let them know there is a new update or message.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Security and safety features&lt;/strong&gt;, such as multi-factor authentication, keep users' information secure.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to Monetize Your App
&lt;/h2&gt;

&lt;p&gt;Part of creating a successful app is finding revenue streams to tap into. The &lt;a href="https://getstream.io/blog/social-media-business-models/" rel="noopener noreferrer"&gt;monetization strategies&lt;/a&gt; you focus on will depend on the type of site you're creating and your target users.&lt;/p&gt;

&lt;h3&gt;
  
  
  Advertising
&lt;/h3&gt;

&lt;p&gt;Digital advertising is standard for social media apps at this point. Even small advertising budgets can make a big difference for a small business on social media, as long as the algorithms for advertising are effective at pushing the ads to the right social media users.&lt;/p&gt;

&lt;p&gt;A self-serve platform makes it easy for business users to create and place their own ads. You'll also want to offer &lt;a href="https://blog.hootsuite.com/social-media-metrics/" rel="noopener noreferrer"&gt;meaningful ad metrics&lt;/a&gt;, like reach, impressions, and engagement rate.&lt;/p&gt;

&lt;h3&gt;
  
  
  In-app Purchases
&lt;/h3&gt;

&lt;p&gt;In-app purchases might include subscriptions, special filters, exclusive features, points or tokens for games, or rewards or gifts that you can give content creators.&lt;/p&gt;

&lt;p&gt;Usage of in-app purchases can be very specific to the type of app you offer. On the video app TikTok, users can purchase gifts to give to other content creators. On Instagram, users can subscribe to exclusive content created by users who've opted to monetize their accounts. The app, in turn, takes a percentage of the money from the transaction or charges the monetized user a monthly fee.&lt;/p&gt;

&lt;h3&gt;
  
  
  Subscriptions
&lt;/h3&gt;

&lt;p&gt;The subscription model is one that's in an evolving state as apps continue to look for ways to earn revenue. Some apps, such as &lt;a href="https://apps.apple.com/us/app/raya/id957215308" rel="noopener noreferrer"&gt;Raya&lt;/a&gt;, are exclusive, invite-only social networks. Tumblr offers a subscription to use the platform ad-free. On LinkedIn, you can subscribe as a premium member, which gives you access to view additional job applicant information, direct messaging features, and learning courses.&lt;/p&gt;

&lt;p&gt;In November 2022, after Musk's purchase of Twitter, he launched an account verification subscription service that allowed anyone to buy a verified account with a blue checkmark. The service was rolled back within 48 hours after users &lt;a href="https://www.engadget.com/twitter-blue-verification-impersonation-disaster-203656712.html" rel="noopener noreferrer"&gt;created fake accounts&lt;/a&gt; that looked authentic and caused chaos so extreme that it impacted the stock markets. For $8, someone purchased a verified account and spoofed the pharmaceutical company Eli Lilly. After the spoof account tweeted that insulin would be free, &lt;a href="https://gizmodo.com/twitter-eli-lilly-elon-musk-insulin-1849779323#:~:text=Most%20importantly%20for,in%20market%20cap." rel="noopener noreferrer"&gt;Eli Lilly's stock fell&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Twitter's idea led other social media networks to explore similar strategies. In February 2023, &lt;a href="https://techcrunch.com/2023/02/27/social-media-apps-adopting-subscription-models/" rel="noopener noreferrer"&gt;Facebook announced a verification program&lt;/a&gt; that verifies a user's identity and offers customer support access, protection from impersonators, and other features.&lt;/p&gt;

&lt;h3&gt;
  
  
  Partnerships
&lt;/h3&gt;

&lt;p&gt;Partner programs can be a lucrative way to encourage people to join your app and continuously create content. YouTube's Partner Program is available to users with at least 1,000 subscribers and 4,000 hours of videos they uploaded being watched. YouTube pays qualifying users who opt in a percentage of the ad sales for their videos.&lt;/p&gt;

&lt;p&gt;You can also partner with prominent, highly active social media users and brands to create promotional content for your app. Promotions from influencers help them grow their follower count and give you visibility with new users. Just be sure that both you and your partners follow all &lt;a href="https://www.ftc.gov/business-guidance/resources/disclosures-101-social-media-influencers" rel="noopener noreferrer"&gt;FTC disclosure requirements&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQs: How To Build a Social Media App&amp;nbsp;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How much does it cost to create a social media app?
&lt;/h3&gt;

&lt;p&gt;Cost can vary from a few thousand dollars using out-of-the-box/premade software and plugins to half a million or more for a custom-built platform with sophisticated proprietary technology. The discovery phase will help you learn which options align with your budget and best fit your vision.&amp;nbsp;&lt;/p&gt;

&lt;h3&gt;
  
  
  How long does it take to build a social media app?
&lt;/h3&gt;

&lt;p&gt;For a simple platform, it can take just a few weeks to build out your app, while a more complicated app with a lot of features can take six months or more.&amp;nbsp;&lt;/p&gt;

&lt;h3&gt;
  
  
  Is it hard to create a social media app?
&lt;/h3&gt;

&lt;p&gt;While there are simple ways of creating social media apps, making them a success can be difficult. Your app needs to solve a problem for its users that makes it compelling to use and come back to. It needs to function well and be user-friendly, which takes the right team to make happen.&amp;nbsp;&lt;/p&gt;

&lt;h3&gt;
  
  
  Is having your own social network profitable?
&lt;/h3&gt;

&lt;p&gt;It can be. The key to creating a social media network that's profitable is gaining enough active users. The higher the number of users you have, the easier it is to charge business users for advertising and partnerships.&amp;nbsp;&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the safest social media app?
&lt;/h3&gt;

&lt;p&gt;This depends on what you mean by safest. If you're referring to &lt;a href="https://startuptalky.com/privacy-focused-social-media-platforms/" rel="noopener noreferrer"&gt;privacy on social media&lt;/a&gt;, apps like Signal and MeWe have a heavy focus on protecting users' privacy. Signal, a messaging app similar to WhatsApp, has end-to-end encryption. MeWe has a strong privacy policy and no advertising. When considering safety from the perspective of &lt;a href="https://hacked.com/which-social-media-services-are-best-for-you/" rel="noopener noreferrer"&gt;online harassment and abuse&lt;/a&gt;, sites like Twitter and Instagram allow you to create private accounts and easily block other users.&amp;nbsp;&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I make an app with no experience?
&lt;/h3&gt;

&lt;p&gt;Absolutely. You should understand the basics of the process and of the social media market, but the development process doesn't require experience.&amp;nbsp;&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need to know coding to create an app?
&lt;/h3&gt;

&lt;p&gt;You can use services that allow you to build apps without coding by choosing features that are already built. Or, you can build a team that has the knowledge to build the app for you or even hire a company that specializes in social networking app development.&amp;nbsp;&lt;/p&gt;

&lt;h3&gt;
  
  
  Which social media app makes the most money?
&lt;/h3&gt;

&lt;p&gt;Facebook earns the most revenue of social media apps, with $116.6 billion in revenue in 2022.&amp;nbsp;&lt;/p&gt;

&lt;h2&gt;
  
  
  Social Media App Development for Startups
&lt;/h2&gt;

&lt;p&gt;There are many paths you can take to build your social media app, but you don't have to learn how to code your app design from scratch or hire a development team. You can integrate important core features like chat and activity feeds easily with Stream. With this basic functionality in place, you can focus your efforts on the best ways to build the rest of your app and make it a success.&lt;/p&gt;

</description>
      <category>socialmedia</category>
      <category>softwaredevelopment</category>
      <category>startup</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Best Visual AI Agents in 2026: Real-Time &amp; Multimodal Tools</title>
      <dc:creator>Sarah Lindauer</dc:creator>
      <pubDate>Tue, 03 Mar 2026 23:03:09 +0000</pubDate>
      <link>https://dev.to/getstreamhq/best-visual-ai-agents-in-2026-real-time-multimodal-tools-44g6</link>
      <guid>https://dev.to/getstreamhq/best-visual-ai-agents-in-2026-real-time-multimodal-tools-44g6</guid>
      <description>&lt;p&gt;Chatbot integration in popular software has become so widespread that it no longer offers a meaningful competitive edge. The real challenge now is moving beyond simple text interfaces to build products that can &lt;a href="https://getstream.io/blog/how-vision-models-work/" rel="noopener noreferrer"&gt;perceive the world as it is&lt;/a&gt; and carry out meaningful tasks.&lt;/p&gt;

&lt;p&gt;Visual AI agents give this edge by combining computer vision with agentic reasoning to perform tasks with little to no input from the user. &lt;/p&gt;

&lt;p&gt;This guide will go deeper into what AI agents are, some of the top picks, and supporting architecture, as well as how to choose the best visual AI agent(s) for your organization.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Are Visual AI Agents?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://getstream.io/glossary/vision-ai/" rel="noopener noreferrer"&gt;Visual AI&lt;/a&gt; agents are intelligent and autonomous systems that can plan, reason, and make decisions using visual information from photos, videos, and live feeds. What sets visual agents apart from existing computer vision systems, such as traditional &lt;a href="https://getstream.io/blog/visual-search/" rel="noopener noreferrer"&gt;visual search systems&lt;/a&gt;, is their ability to act on contextual information. &lt;/p&gt;

&lt;p&gt;Their core capabilities center around functions like object detection, spatial reasoning, multimodal understanding, and image-to-action.&lt;/p&gt;

&lt;p&gt;By merging computer vision with agentic AI, these systems can act on visual context with minimal explicit instruction. One common consumer-facing use case is hands-free, &lt;a href="https://getstream.io/glossary/conversational-ai/" rel="noopener noreferrer"&gt;conversational interactions&lt;/a&gt; with built-in AI assistants in smart glasses.&lt;/p&gt;

&lt;p&gt;Here are some broad categories of visual AI agents to illustrate other uses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Robotics/perception agents&lt;/strong&gt; control objects in real-world settings like autonomous vehicle navigation, real-time object recognition and manipulation, and surveillance and escalation.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Creative visual agents&lt;/strong&gt; generate and modify visual content. Examples include digital design assistants, automatic media post-production, and style transfer and editing.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Analytical agents&lt;/strong&gt; extract information from visual input to make decisions. They’re used for medical imaging analysis, retail shelf monitoring and footfall tracking, and &lt;a href="https://getstream.io/blog/ai-sports/" rel="noopener noreferrer"&gt;sports coaching&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The 4 Best Visual AI Agents
&lt;/h2&gt;

&lt;p&gt;Many of the most powerful AI models &lt;a href="https://getstream.io/blog/multimodal-ai-agents/" rel="noopener noreferrer"&gt;are multimodal&lt;/a&gt;, allowing them to accept inputs in different forms, such as visual data, text data, and files. Some of our picks for the best visual AI agents aren't purpose-built to ingest purely visual data, but they happen to excel at it. &lt;/p&gt;

&lt;p&gt;With that out of the way, let’s look at some of the best visual AI agents on the market.&lt;/p&gt;

&lt;h3&gt;
  
  
  Amazon Bedrock Agents
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4g1h59r4gk366bs05q39.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4g1h59r4gk366bs05q39.png" alt="AskUI Vision Agent" width="800" height="318"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://aws.amazon.com/bedrock/agents/" rel="noopener noreferrer"&gt;Bedrock Agents&lt;/a&gt; can be configured to work as visual agents by using foundation models with an orchestration layer that translates visual data into tool calls. It can ingest video and photos using Kinesis, M3U8, and S3.&lt;/p&gt;

&lt;p&gt;Agents can be built using the AWS console. They’re deployed and maintained using AgentCore. Agents access actions through action groups that contain executable functions or &lt;a href="https://getstream.io/blog/what-is-mcp-infrastructure/" rel="noopener noreferrer"&gt;MCP-enabled tools&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The capabilities of these functions range from simple notifications on event triggers to controlling IoT devices and sending API requests. &lt;/p&gt;

&lt;h4&gt;
  
  
  Pros
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AWS Integration:&lt;/strong&gt; Any compatible AWS service can be layered with Bedrock Agents, resulting in a high degree of extensibility.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Traceability:&lt;/strong&gt; Every step taken by an agent produces a trace. These traces outline the reasoning of the agents, the inputs given, the functions used, and the output received.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Universal UI Controls:&lt;/strong&gt; Supports direct computer use to imitate human interaction with software that doesn't allow agentic actions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Cons
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ecosystem Lock-In:&lt;/strong&gt; Bedrock Agents ties you tightly to AWS, making it difficult to migrate agent workflows to other platforms without a total rebuild.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enterprise Pricing:&lt;/strong&gt; Because it is meant largely for enterprise use, the cost to run Amazon Bedrock can be out of reach for smaller organizations and startups.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Google Gemini
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmdehs5r95ii9npx0vkcq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmdehs5r95ii9npx0vkcq.png" alt="Google Gemini" width="800" height="358"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://gemini.google.com/" rel="noopener noreferrer"&gt;Google Gemini&lt;/a&gt; can be used as a visual agent, as it combines multimodal perception along with &lt;a href="https://getstream.io/blog/reasoning-llms/" rel="noopener noreferrer"&gt;reasoning&lt;/a&gt; to act on what it sees.&lt;/p&gt;

&lt;p&gt;It uses Vision-Language-Action capabilities to translate visual data directly into low-level commands (like motor movements or mouse drags), while also being capable of high-level orchestration. By natively calling functions and tools, the agent can see a video (such as a specific error on a screen or a product defect in a livestream) and execute logic to fix it.&lt;/p&gt;

&lt;p&gt;To use &lt;a href="https://getstream.io/blog/gemini-vision-ai-capabilities/" rel="noopener noreferrer"&gt;Gemini as a visual agent&lt;/a&gt;, use the Observer-Think-Act loop using the Gemini API or Multimodal Live API. &lt;/p&gt;

&lt;p&gt;For static images or recorded video, the media is sent along with a tool definition, which results in a function call. For live feeds, the agent processes frames in real-time to trigger immediate actions while maintaining context through “Thought Signatures” that preserve its train of thought across sessions.&lt;/p&gt;

&lt;h4&gt;
  
  
  Pros
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Universal UI Controls:&lt;/strong&gt; Navigates and controls any visual interface without official APIs or HTML scraping.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3D Spatial Awareness:&lt;/strong&gt; Gemini can output 3D bounding boxes and trajectories, allowing it to work well with AR/XR and robotics.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bidirectional Streaming:&lt;/strong&gt; The Multimodal Live API allows the model to see a video stream and trigger function calls, like trigger_alert() or log_data(), as events unfold in real-time.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Cons
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Resource Intensive:&lt;/strong&gt; High-resolution video and frequent screenshot loops consume tokens rapidly, seeing as it isn't priced specifically for ingesting visual input.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provider Overload:&lt;/strong&gt; The popularity of Google Gemini leads to occasional processing overload, which can break autonomous loops mid-task.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  AskUI Vision Agent
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsha7zemf8m6h7wlrjoas.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsha7zemf8m6h7wlrjoas.png" alt="AskUI Vision Agent" width="799" height="342"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/askui/vision-agent" rel="noopener noreferrer"&gt;AskUI Vision Agent&lt;/a&gt; is a specialized GUI-focused visual agent that works at the operating system level. Unlike more general-purpose models, AskUI is purpose-built to perceive mobile and desktop screens to interact with them exactly as a human would by taking control of input devices. &lt;/p&gt;

&lt;p&gt;AskUI treats the entire device screen as a live coordinate system. It employs a computer-use architecture, where it takes a screenshot, identifies UI elements visually (like buttons, text fields, and icons), and maps those elements to physical actions.&lt;/p&gt;

&lt;p&gt;Developers can integrate this agent by using the AskUI Python SDK or Typescript library. The first step is to create a “Controller” that bridges the AI to your OS. After that, intent-based commands are written (like agent.click(“Login”)), and the agent handles the rest.&lt;/p&gt;

&lt;h4&gt;
  
  
  Pros
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Universal UI Controls:&lt;/strong&gt; Due to its computer-use functionality, AskUI can access software that does not support agentic communication.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Low Cost of Operation:&lt;/strong&gt; The SDK/CLI of AskUI is completely free, and it can use &lt;a href="https://getstream.io/blog/best-local-llm-tools/" rel="noopener noreferrer"&gt;natively-hosted LLMs&lt;/a&gt; to avoid API fees.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local-First Execution:&lt;/strong&gt; Because the controller runs locally on your machine, it can automate highly secure, offline, or air-gapped environments where cloud-based agents might be restricted.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Cons
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Visual Fragility:&lt;/strong&gt; Since the only input is captured screenshots, UI changes like refreshes and unexpected pop-ups can break coordinate mapping.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Low Flexibility:&lt;/strong&gt; AskUI is strictly meant for UI operations, so it can’t perform agentic functions with camera feeds or other visual input.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  NVIDIA Metropolis
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fld0k3tq3jmqy83995wet.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fld0k3tq3jmqy83995wet.png" alt="NVIDIA Metropolis" width="800" height="295"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.nvidia.com/en-us/autonomous-machines/intelligent-video-analytics-platform/" rel="noopener noreferrer"&gt;NVIDIA Metropolis&lt;/a&gt; is an enterprise-grade vision AI application platform designed to build and scale visual agents across edge and cloud environments, including devices like cameras and robots.&lt;/p&gt;

&lt;p&gt;Metropolis is a full-stack engineering ecosystem for physical spaces. It provides the specialized SDKs, microservices, and blueprints needed to turn video feeds into agentic actions in industries like manufacturing and retail, as well as in smart city deployments.&lt;/p&gt;

&lt;p&gt;Metropolis connects high-level vision language models (VLMs) with low-level sensor data. It uses models to analyze video at very high fidelity, with the &lt;a href="https://developer.nvidia.com/blog/optimizing-semiconductor-defect-classification-with-generative-ai-and-vision-foundation-models/" rel="noopener noreferrer"&gt;NVIDIA Cosmos reasoning model&lt;/a&gt; reaching over 96% accuracy in a wafer map defect classification test.&lt;/p&gt;

&lt;p&gt;Unlike standard LLMs and vision models that work one frame at a time, Metropolis uses tools like Multi-Camera Tracking to follow an object across 3D space, maintaining the state of the agent’s task as the subject moves.&lt;/p&gt;

&lt;p&gt;Metropolis uses a “Microservice Pipeline” with the following components:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ingestion (Video Storage Toolkit):&lt;/strong&gt; Manages live RTSP streams from multiple cameras.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inference (DeepStream/NIM):&lt;/strong&gt; Runs the visual models on NVIDIA GPUs to extract real-time insights.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agentic Logic (NVIDIA AI Blueprints):&lt;/strong&gt; Provides reference code for Video Search and Summarization. This allows agents to answer natural language queries and perform multi-step planning.
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://getstream.io/blog/edge-computing/" rel="noopener noreferrer"&gt;&lt;strong&gt;Edge Computing&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;:&lt;/strong&gt; Agents deploy onto NVIDIA Jetson hardware, allowing the AI to act locally even if the internet goes down.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Pros
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Edge-to-Cloud Flexibility:&lt;/strong&gt; Can run entirely on-site (on Jetson Orin) for execution with zero network latency or scale to the cloud for massive video archives.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Digital Twin Training:&lt;/strong&gt; Uses NVIDIA Omniverse to train visual agents in a virtual world before deploying them to the real world.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High Throughput:&lt;/strong&gt; Optimized specifically for &lt;a href="https://blogs.nvidia.com/blog/metropolis-ai-blueprint-video/" rel="noopener noreferrer"&gt;NVIDIA hardware&lt;/a&gt;, which claims to be able to process video 30 times faster than real-time analysis.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Cons
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Specialization:&lt;/strong&gt; An efficient implementation requires specialization in NVIDIA’s accelerated interface stack and hardware-aware AI deployment.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hardware Lock-In:&lt;/strong&gt; Requires specialized NVIDIA GPUs to run the software stack, like A100s, H100s, and Jetsons.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Comparison Table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;th&gt;Ideal Use Case&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Amazon Bedrock Agents&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;AWS-managed custom agents that translate visual data into tool and API actions using action groups and foundation models.&lt;/td&gt;
&lt;td&gt;Enterprise workflows that combine with AWS services and automation.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Google Gemini&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multimodal AI that reasons over images and video to directly execute actions.&lt;/td&gt;
&lt;td&gt;General-purpose visual reasoning, UI control, and live visual monitoring.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AskUI Vision Agent&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;OS-level visual agent that automates software by interacting with screens like a human.&lt;/td&gt;
&lt;td&gt;Desktop/mobile UI automation where APIs are unavailable.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NVIDIA Metropolis&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Full-stack vision AI agent platform for analyzing live camera feeds and physical environments.&lt;/td&gt;
&lt;td&gt;Smart cities, factories, retail analytics, and large-scale camera networks.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Infrastructure Powering Visual Agents
&lt;/h2&gt;

&lt;p&gt;While out-of-the-box agents are powerful, many specialized use cases require custom-built solutions. This involves assembling a stack of supporting technologies to bridge the gap between vision and execution.&lt;/p&gt;

&lt;h3&gt;
  
  
  Vision-Language-Action Foundation Models
&lt;/h3&gt;

&lt;p&gt;The reasoning engine for modern visual agents is ‌vision-language-action models. These models are specifically trained to give outputs as actions instead of text or speech responses.&lt;/p&gt;

&lt;p&gt;Models like InternVL3 and NVIDIA’s Cosmos-based GR00T are trained to ground their reasoning in spatial coordinates, allowing them to point to options directly from visual feeds. These models enable agents to understand complex instructions like “turn off the machine when the light turns red” and translate them into actions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multi-Agent Orchestration Tools
&lt;/h3&gt;

&lt;p&gt;Complex visual tasks often require agent teams, consisting of specialized agents rather than a single monolithic model. &lt;a href="https://getstream.io/blog/best-ai-orchestration-tools/" rel="noopener noreferrer"&gt;Orchestration frameworks,&lt;/a&gt; like LangGraph, CrewAI, and Microsoft AutoGen, manage these collaborations, where one agent might focus on high-speed object detection (perception) while another handles long-term planning (reasoning). &lt;/p&gt;

&lt;p&gt;These tools ensure that state is maintained across tasks, allowing agents to remember a visual context even as the camera view changes or the task evolves over time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Real-Time Streaming Infrastructure
&lt;/h3&gt;

&lt;p&gt;To function in the real world, visual agents require a live paradigm that employs bidirectional streaming. Frameworks like &lt;a href="https://visionagents.ai/" rel="noopener noreferrer"&gt;Vision Agents&lt;/a&gt; make this practical by leveraging low-latency edge transport layers (such as Stream’s global edge network) and real-time video/audio pipelines to enable continuous ingestion of visual data. &lt;/p&gt;

&lt;p&gt;Similarly, StreamingVLM architectures enable agents to process unbounded video feeds by using specialized memory caches. This infrastructure makes agents situationally aware, treating live video as a continuous, unified context rather than a series of disconnected snapshots.&lt;/p&gt;

&lt;h3&gt;
  
  
  Robotics &amp;amp; Edge Control Platforms
&lt;/h3&gt;

&lt;p&gt;Visual agents might be embedded into individual devices for certain tasks, but they can also run on control platforms for complex deployments that involve several robots or edge devices. For example, a centralized agent can use a warehouse’s cameras to optimize pallet placements before sending pathing commands to delivery robots via the platform.&lt;/p&gt;

&lt;p&gt;Compatibility and capabilities will vary by platform, but three popular open-source choices are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AWS IoT Greengrass&lt;/strong&gt;: An AWS service for edge devices that can use Bedrock Agents for scenarios like agricultural fleet control.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NVIDIA Isaac&lt;/strong&gt;: A robotics development platform that tightly integrates with Metropolis for digital twin training with Isaac Sim.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Viam&lt;/strong&gt;: A robotics and edge control platform that is a little more complicated for agent setup but is hardware-agnostic, costs nothing to start, and has premade modules for integrating with Gemini, ChatGPT Vision, and more.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to Evaluate the Best Visual AI Agent
&lt;/h2&gt;

&lt;p&gt;Choosing the right agent requires an understanding of its performance metrics, operational costs, and safety protocols. &lt;/p&gt;

&lt;p&gt;Let’s look at some of the most important metrics to evaluate a visual AI agent.&lt;/p&gt;

&lt;h3&gt;
  
  
  Model Flexibility
&lt;/h3&gt;

&lt;p&gt;Visual AI agents are often powered by multiple models across different tasks. General-purpose models are good at open-ended reasoning and scene understanding, while specialized models often outperform them in latency-sensitive or narrowly defined tasks.&lt;/p&gt;

&lt;p&gt;Model flexibility refers to an agent’s ability to route different stages of perception and reasoning to the most appropriate model, rather than forcing all workloads through one monolithic architecture. This is especially important in streaming environments, as it allows agents to choose between latency, reasoning depth, cost, and time constraints dynamically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Latency vs Intelligence
&lt;/h3&gt;

&lt;p&gt;Visual agents often need to make split-second decisions, but this comes at the cost of a lower reasoning depth. &lt;a href="https://getstream.io/blog/realtime-ai-agents-latency/" rel="noopener noreferrer"&gt;Low-latency agents&lt;/a&gt; are essential for physical tasks like robotics or security monitoring, where sub-second responses are required. The quicker models have fewer parameters (1B-11B), and they usually run on the system's edge.&lt;/p&gt;

&lt;p&gt;High-intelligence agents take their time while making decisions, which comes in handy for tasks like complex GUI navigation or medical image analysis. These typically rely on larger, cloud-hosted models that can take several seconds to think through a visual scene.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cost Tradeoffs
&lt;/h3&gt;

&lt;p&gt;To evaluate the total cost of ownership, it's a good idea to compare the per-action &lt;a href="https://getstream.io/blog/artificial-intelligence-api/" rel="noopener noreferrer"&gt;LLM API&lt;/a&gt; costs of cloud providers against the infrastructure overhead of self-hosted models. Many organizations use a tiered cost model, which uses a smaller, cheaper model for routine monitoring tasks, and escalation to an expensive model occurs only when a visual anomaly is detected.&lt;/p&gt;

&lt;h3&gt;
  
  
  Human-in-the-Loop Workflows
&lt;/h3&gt;

&lt;p&gt;For high-stakes decisions, such as authorizing a financial transaction or approving a medical diagnosis, a visual AI agent should support human-in-the-loop checkpoints. Some agents use confidence gating, which asks for human guidance if the model’s confidence score falls below a certain threshold.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Which AI Has the Best Agents?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It’s impossible to definitively say which AI has the best agents overall for two reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Performance Varies by Task&lt;/strong&gt;: A given agent may excel in some areas but be outperformed in others. AskUI Vision Agents is one of the best for workflow automations, but it’d be a poor choice for a shopping agent.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frequent Updates and Upgrades&lt;/strong&gt;:  AI companies are constantly improving their products, so the agent that scores the highest on a benchmark in March may lose to an updated competitor model in June.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;2. What Exactly Is Visual AI?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Visual AI is the use of computer vision in AI systems, which allows models to understand information present in images and videos.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Is Siri an AI Agent?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Apple’s Siri can be described as an AI agent as it can perform tasks and make decisions based on commands. However, Siri doesn't make proactive automated decisions without instruction from the user.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. What Are Level 3 AI Agents?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Level 3 AI agents use LLM reasoning and &lt;a href="https://getstream.io/glossary/ai-agent-orchestration/" rel="noopener noreferrer"&gt;orchestration frameworks&lt;/a&gt; to make decisions and perform multi-step tasks without human intervention. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. What Is the Difference Between LLMs and AI Agents?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;LLMs are AI systems that can understand and respond in natural language. AI agents use LLMs along with tools, reasoning, and knowledge-base lookups to perform actions based on events or natural language requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Visual AI Agent Should Your Organization Use?
&lt;/h2&gt;

&lt;p&gt;While deciding, it’s important to remember that the right visual AI agent depends on your organization’s technical requirements rather than on general popularity. A team &lt;a href="https://visionagents.ai/cookbook/golf-coach" rel="noopener noreferrer"&gt;building a real-time golf coach&lt;/a&gt; will have different priorities than one working on a manufacturing quality control system.&lt;/p&gt;

&lt;p&gt;Here are our recommended use cases for the visual AI agents mentioned in this guide. You should use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Amazon Bedrock Agents&lt;/strong&gt; for highly customizable enterprise workflows that have deep integration with existing AWS services and automated tool-calling.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google Gemini&lt;/strong&gt; if you need a versatile, general-purpose multimodal agent capable of sophisticated reasoning over live video and direct UI control.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AskUI Vision Agent&lt;/strong&gt; for cross-platform desktop or mobile workflow automation, especially when you need to interact with software that lacks accessible APIs.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NVIDIA Metropolis&lt;/strong&gt; for tasks involving large-scale camera networks where performance and reliability are essential.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>visualaiagents</category>
      <category>aiagents</category>
      <category>visualai</category>
      <category>visionai</category>
    </item>
    <item>
      <title>The 8 Best Platforms To Build Voice AI Agents</title>
      <dc:creator>Sarah Lindauer</dc:creator>
      <pubDate>Tue, 03 Mar 2026 21:12:38 +0000</pubDate>
      <link>https://dev.to/getstreamhq/the-8-best-platforms-to-build-voice-ai-agents-4oel</link>
      <guid>https://dev.to/getstreamhq/the-8-best-platforms-to-build-voice-ai-agents-4oel</guid>
      <description>&lt;p&gt;Voice assistants like Siri and Alexa are great for non-trivial everyday personal assistive tasks. However, they are limited in providing accurate answers to complex questions, real-time information, handling turns, and user interruptions. &lt;/p&gt;

&lt;p&gt;Try asking Siri about the best things to do with kids in a particular city or location. It won't provide an accurate answer because it can’t access web search tools. On devices supporting Apple Intelligence, asking the same question will be handed off to ChatGPT. &lt;/p&gt;

&lt;p&gt;As in-app conversational app features, &lt;a href="https://getstream.io/video/voice-agents/" rel="noopener noreferrer"&gt;voice agents&lt;/a&gt; are here to solve these limitations. &lt;/p&gt;

&lt;p&gt;The sections below will help you discover how to build AI voice agents and the best creation platforms. Although it is not required for this article, you can set up a local &lt;a href="https://github.com/GetStream/openai-tutorial-node" rel="noopener noreferrer"&gt;Node.js server&lt;/a&gt; and run our &lt;a href="https://github.com/GetStream/openai-tutorial-ios/" rel="noopener noreferrer"&gt;demo&lt;/a&gt; iOS/iPadOS voice agent in SwiftUI. After setting up your local Node server, you can also test the conversational agent for other platforms by following these step-by-step tutorials: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://getstream.io/video/sdk/react/tutorial/ai-voice-assistant/" rel="noopener noreferrer"&gt;React&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://getstream.io/video/sdk/android/tutorial/ai-voice-assistant/" rel="noopener noreferrer"&gt;Android&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://getstream.io/video/sdk/flutter/tutorial/ai-voice-assistant/" rel="noopener noreferrer"&gt;Flutter&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://getstream.io/video/sdk/ios/tutorial/ai-voice-assistant/" rel="noopener noreferrer"&gt;iOS&lt;/a&gt; &lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What Is an AI Voice Agent?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9d2taazkhdwhvwisqo4t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9d2taazkhdwhvwisqo4t.png" alt="AI voice agent" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A voice agent is a &lt;a href="https://getstream.io/glossary/conversational-ai/" rel="noopener noreferrer"&gt;conversational AI&lt;/a&gt; assistant capable of taking user instructions and responding with a human-like voice in real-time using a &lt;a href="https://getstream.io/blog/best-local-llm-tools/" rel="noopener noreferrer"&gt;local&lt;/a&gt; or cloud-based LLM. &lt;/p&gt;

&lt;p&gt;Like text-generation agents, voice-based ones use LLMs to output audio responses. The best way to think of it is to consider ChatGPT's voice mode, as illustrated in the above image. With the tap of a button and selecting your preferred voice, you can easily speak to ChatGPT for real-time responses. &lt;/p&gt;

&lt;p&gt;In the following sections, we’ll look at the top platforms for building a ChatGPT-like voice mode experience. &lt;/p&gt;

&lt;h2&gt;
  
  
  Why Build a Voice Agent?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi1gscq89jewly56gcldn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi1gscq89jewly56gcldn.png" alt="Voice agent interaction" width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Like text-based AI agents, the support for &lt;a href="https://getstream.io/blog/mcp-llms-agents/" rel="noopener noreferrer"&gt;MCP&lt;/a&gt; in voice applications helps agents retrieve accurate, real-time information from services such as &lt;a href="https://www.perplexity.ai/" rel="noopener noreferrer"&gt;Perplexity&lt;/a&gt; and &lt;a href="https://exa.ai/" rel="noopener noreferrer"&gt;Exa&lt;/a&gt;. With MCP, you can build an agent to manage tasks through Slack and Linear using voice interactions. You can also connect voice agents to MCP tools for custom workflows. &lt;/p&gt;

&lt;p&gt;When creating your voice AI app, support for diverse accents may be needed. Luckily, major platforms, like the OpenAI Agents SDK, provide a &lt;a href="https://www.openai.fm/" rel="noopener noreferrer"&gt;library&lt;/a&gt; of voices to choose from. Voice-based agents can be used across many domains, including sales, marketing, customer support, small businesses, and enterprises.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Video AI&lt;/strong&gt;: An excellent use case of voice agents in a video AI setting is &lt;a href="https://gemini.google/overview/gemini-live/?hl=en" rel="noopener noreferrer"&gt;Gemini Live&lt;/a&gt;. It uses your phone's camera to see and understand objects around you and provide answers through speech interactions. Gemini Live also allows users to screen share their phone device's screen to ask questions about the content on the selected screen. &lt;/li&gt;
&lt;/ul&gt;


    
  Gemini voice mode


&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sales leads&lt;/strong&gt;: Use a voice agent to follow up and contact potential customers for inbound sales in enterprises and small businesses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Customer support and call center&lt;/strong&gt;: Voice agents can receive customer complaints and help fix issues. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Personal assistant&lt;/strong&gt;: Like Gemini Live, you can create a voice system to help you understand your surroundings and what you’re looking at. Another trending application area is computer and &lt;a href="https://browser-use.com/" rel="noopener noreferrer"&gt;browser use&lt;/a&gt;. You can integrate a voice system with an AI browser agent to automate &lt;a href="https://getstream.io/blog/telemedicine-chat-and-scheduling/" rel="noopener noreferrer"&gt;online booking&lt;/a&gt; and appointment scheduling.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Social platform&lt;/strong&gt;: Build voice agents to interact with people in &lt;a href="https://getstream.io/chat/solutions/social/" rel="noopener noreferrer"&gt;social communities&lt;/a&gt; by giving real-time voice responses to users' queries. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gaming&lt;/strong&gt;: Build character dialog and interactive narration systems with voice agents for &lt;a href="https://getstream.io/chat/solutions/gaming/" rel="noopener noreferrer"&gt;online gaming&lt;/a&gt; platforms. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Telecare&lt;/strong&gt;: Use an AI voice to interact with patients and collect their information online in &lt;a href="https://getstream.io/chat/solutions/healthcare/" rel="noopener noreferrer"&gt;telehealth&lt;/a&gt; scenarios. &lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Top 8 Platforms To Build Voice-Enabled Apps
&lt;/h2&gt;

&lt;p&gt;Building a voice AI app can be daunting. Several aspects include backend infrastructure, audio quality, latency, and more. For these reasons, you can rely on frameworks, SDKs, and APIs to create AI solutions with voice as one of the main in-app features. &lt;/p&gt;

&lt;p&gt;Most of these voice agent-building platforms offer a Python-first approach. TypeScript-based options are now catching up. &lt;/p&gt;

&lt;p&gt;Let's look at the leading solutions and how to build quickly with them. &lt;/p&gt;

&lt;h3&gt;
  
  
  1. Stream Python AI SDK: Integrate In-App Voice AI
&lt;/h3&gt;

&lt;p&gt;Stream allows developers to build real-time &lt;a href="https://getstream.io/video/voice-agents/" rel="noopener noreferrer"&gt;audio apps&lt;/a&gt; in React, Swift, Android, Flutter, JavaScript, and Python. With minimal effort, the recently released &lt;a href="https://getstream.io/video/docs/python-ai/" rel="noopener noreferrer"&gt;Python AI SDK&lt;/a&gt; helps developers create complex voice AI services, such as meeting assistants and bots for &lt;a href="https://getstream.io/blog/video-conferencing-nextjs/" rel="noopener noreferrer"&gt;video conferencing&lt;/a&gt;. &lt;/p&gt;

&lt;p&gt;You can use the Python SDK for transcribing, voice activity detection (VAD), converting speech to text, and vice versa. Aside from these features, you can integrate and extend your Stream-powered voice AI app with other leading platforms like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://getstream.io/video/docs/python-ai/integrations/openai/" rel="noopener noreferrer"&gt;OpenAI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://getstream.io/video/docs/python-ai/integrations/cartesia/" rel="noopener noreferrer"&gt;Cartesia&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://getstream.io/video/docs/python-ai/integrations/deepgram/" rel="noopener noreferrer"&gt;Deepgram&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://getstream.io/video/docs/python-ai/integrations/elevenlabs/" rel="noopener noreferrer"&gt;EleveLabs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://getstream.io/video/docs/python-ai/integrations/kokoro/" rel="noopener noreferrer"&gt;Kokoro&lt;/a&gt; and more.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The SDK's foundation is built with &lt;a href="https://getstream.io/resources/projects/webrtc/basics/welcome/" rel="noopener noreferrer"&gt;WebRTC&lt;/a&gt; and the &lt;a href="https://platform.openai.com/docs/guides/realtime" rel="noopener noreferrer"&gt;OpenAI Real-Time API&lt;/a&gt; to endure low-latency communication. &lt;/p&gt;

&lt;h4&gt;
  
  
  Get Started with Stream Python AI SDK
&lt;/h4&gt;

&lt;p&gt;Creating your first voice app with the Stream's Python AI SDK requires a few steps: &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Set up a Python environment and install the necessary dependencies&lt;/strong&gt;. You can configure your Python virtual environment with this command.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;python3 -m venv venv &amp;amp;&amp;amp; source venv/bin/activate&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;You may also set it up using &lt;code&gt;uv&lt;/code&gt; and add a &lt;code&gt;.env&lt;/code&gt; file to your project’s root directory and fill it up with the following:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;STREAM_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-stream-api-key
&lt;span class="nv"&gt;STREAM_API_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-stream-api-secret
&lt;span class="nv"&gt;STREAM_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://pronto.getstream.io/
&lt;span class="nv"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sk-your-openai-api-key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sign up for a Stream &lt;a href="https://dashboard.getstream.io/" rel="noopener noreferrer"&gt;dashboard&lt;/a&gt; account, create an app, and grab its API key and secret to substitute the above placeholders. With the &lt;code&gt;STREAM_BASE_URL&lt;/code&gt;, you can create and join a Stream &lt;a href="https://pronto.getstream.io/" rel="noopener noreferrer"&gt;video call&lt;/a&gt; in your browser. &lt;/p&gt;

&lt;p&gt;Next, run the following commands to install the Python AI SDK.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pip install --pre "getstream[plugins]"

# or using uv
uv add "getstream[plugins]" --prerelease=allow
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Initialize a Stream client and user, and create a call&lt;/strong&gt;.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dotenv&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;load_dotenv&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;getstream&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Stream&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;getstream.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;UserRequest&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;uuid4&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;webbrowser&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;urllib.parse&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urlencode&lt;/span&gt;

&lt;span class="c1"&gt;# Load environment variables
&lt;/span&gt;&lt;span class="nf"&gt;load_dotenv&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# Initialize Stream client from ENV
&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_env&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# Generates a new user ID and creates a new user
&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert_users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UserRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;My User&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c1"&gt;# We can use this later to join the call
&lt;/span&gt;&lt;span class="n"&gt;user_token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expiration&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Generate a user ID for the OpenAI bot that is added later
&lt;/span&gt;&lt;span class="n"&gt;bot_user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openai-realtime-speech-to-speech-bot-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert_users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UserRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;bot_user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OpenAI Realtime Speech to Speech Bot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c1"&gt;# Create a call with a new generated ID
&lt;/span&gt;&lt;span class="n"&gt;call_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;call&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;call_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_or_create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;created_by_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bot_user_id&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Create an OpenAI speech-to-speech pipeline&lt;/strong&gt;. &lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The following code snippet allows you to create a speech-to-speech pipeline by initializing the &lt;code&gt;OpenAIRealtime&lt;/code&gt; class of the SDK and launching the call with a web browser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;sts_bot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAIRealtime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gpt-4o-realtime-preview&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You are a friendly assistant; reply in a concise manner.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;voice&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alloy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Connect OpenAI bot
&lt;/span&gt;    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;sts_bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;agent_user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;bot_user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;

        &lt;span class="c1"&gt;# Sends a message to OpenAI from the user side
&lt;/span&gt;        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;sts_bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_user_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Give a very short greeting to the user.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Handle exception
&lt;/span&gt;&lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Delete users when done
&lt;/span&gt;    &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete_users&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bot_user_id&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="n"&gt;base_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;EXAMPLE_BASE_URL&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/join/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;# The token is the user token we generated from the client before.
&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;skip_lobby&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;call_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;?&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;urlencode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;webbrowser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Failed to open browser: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Please manually open this URL: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you put the code snippets together in the above steps and test out the combined code, you should see an output similar to this: &lt;/p&gt;

&lt;p&gt;&lt;br&gt;
    &lt;br&gt;
  Stream voice agent&lt;br&gt;
&lt;/p&gt;
&lt;h3&gt;
  
  
  2. OpenAI: Create Voice Apps
&lt;/h3&gt;

&lt;p&gt;OpenAI allows developers to integrate voice services with their apps in two ways. With its &lt;a href="https://openai.github.io/openai-agents-python/ref/voice/pipeline/" rel="noopener noreferrer"&gt;Python Agents SDK&lt;/a&gt; and &lt;a href="https://openai.github.io/openai-agents-js/guides/voice-agents/quickstart/" rel="noopener noreferrer"&gt;TypeScript SDK&lt;/a&gt;, you can easily add voice agents to any AI application. &lt;/p&gt;

&lt;p&gt;The SDK supports male and female &lt;a href="https://www.openai.fm/" rel="noopener noreferrer"&gt;TTSVoices&lt;/a&gt; such as Alloy, Ash, Coral, Echo, Fable, Onyx, Nova, Sage, and Shimmer. With its voice agent pipeline, you can transcribe an audio input into text, run a workflow for sequential text responses, and transform the text-based output into streaming audio. &lt;/p&gt;

&lt;p&gt;Another way to build audio/speech experiences with OpenAI is to use its &lt;a href="https://platform.openai.com/docs/guides/realtime" rel="noopener noreferrer"&gt;Realtime API&lt;/a&gt;. Using its &lt;a href="https://platform.openai.com/docs/guides/realtime#connect-with-webrtc" rel="noopener noreferrer"&gt;WebRTC&lt;/a&gt; or &lt;a href="https://platform.openai.com/docs/guides/realtime#connect-with-websockets" rel="noopener noreferrer"&gt;WebSockets&lt;/a&gt; backend, developers can build multi-modal experiences supporting realtime text and speech generation, transcription, function calling, and more. &lt;/p&gt;

&lt;p&gt;Some of the unique features of building voice agents with OpenAI include the following: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tools&lt;/strong&gt;: Give your voice applications access to external &lt;a href="https://openai.github.io/openai-agents-js/guides/voice-agents/build/#tools" rel="noopener noreferrer"&gt;services&lt;/a&gt; to execute actions.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agent monitoring&lt;/strong&gt;: Provide &lt;a href="https://openai.github.io/openai-agents-js/guides/voice-agents/build/#guardrails" rel="noopener noreferrer"&gt;guardrails&lt;/a&gt; and rules for voice agents to follow. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agent handoff&lt;/strong&gt;: Create multiple agents who can assign tasks to others. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audio handling&lt;/strong&gt;: It uses WebRTC to handle audio input/output by default. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://getstream.io/blog/session-management/" rel="noopener noreferrer"&gt;Session management&lt;/a&gt;&lt;/strong&gt;: It allows configuring and customizing real-time sessions. &lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;
  
  
  Get Started with OpenAI JS SDK
&lt;/h4&gt;

&lt;p&gt;With the sample code below, you can start building voice apps with the OpenAI JS SDK. Check out &lt;a href="https://openai.github.io/openai-agents-js/guides/voice-agents/quickstart/" rel="noopener noreferrer"&gt;voice agents quickstart&lt;/a&gt; to learn more.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;RealtimeAgent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;RealtimeSession&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@openai/agents/realtime&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RealtimeAgent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Assistant&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;You are a helpful assistant.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RealtimeSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Automatically connects your microphone and audio output&lt;/span&gt;
&lt;span class="c1"&gt;// in the browser via WebRTC.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;client-api-key&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. ElevenLabs: Build Conversational Voice Agents
&lt;/h3&gt;

&lt;p&gt;ElevenLabs is one of the leading platforms for building conversational AI applications. It provides developers and enterprises with all the building blocks for integrating low-latency voice agents with any service. &lt;/p&gt;

&lt;p&gt;The example below demonstrates a realistic &lt;a href="https://getstream.io/blog/text-to-speech/" rel="noopener noreferrer"&gt;text-to-speech&lt;/a&gt; interaction. &lt;/p&gt;

&lt;p&gt;&lt;br&gt;
    &lt;br&gt;
  ElevenLabs interactive voice demo&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;With this platform, you can access categories of AI models for voice cloning, isolation, swapping, voice design, and making sound effects. Combinations of these models can be used to create and deploy interactive audio services. &lt;/p&gt;

&lt;p&gt;For example, its latest model (&lt;a href="https://elevenlabs.io/v3" rel="noopener noreferrer"&gt;Eleven V3&lt;/a&gt;) at the time of writing this article is an excellent choice for implementing realistic and expressive in-app text-to-speech. With its support for different categories of speech models, you have several options for video, audio, gaming, telehealth, and &lt;a href="https://getstream.io/chat/solutions/marketplaces/" rel="noopener noreferrer"&gt;marketplace&lt;/a&gt; applications. &lt;/p&gt;
&lt;h4&gt;
  
  
  Get Started with ElevenLabs
&lt;/h4&gt;

&lt;p&gt;ElevenLabs provides easy-to-use SDKs for Python and TypeScript developers. The sample code below is all you need to make your first API call to the Python voice AI SDK to create your text-to-speech app.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dotenv&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;load_dotenv&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;elevenlabs.client&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ElevenLabs&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;elevenlabs&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;play&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;

&lt;span class="nf"&gt;load_dotenv&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;elevenlabs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ElevenLabs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ELEVENLABS_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;elevenlabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text_to_speech&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The first move is what sets everything in motion.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;voice_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JBFqnCBsd6RMkjVDRZzb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;model_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eleven_multilingual_v2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;output_format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mp3_44100_128&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;play&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check out the &lt;a href="https://elevenlabs.io/docs/quickstart" rel="noopener noreferrer"&gt;developer quickstart&lt;/a&gt; to learn more about the TypeScript version of the SDK.  &lt;/p&gt;

&lt;h3&gt;
  
  
  4. Deepgram: Build Voice AI Solutions
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F71sce6nsd0bqgb8ey70a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F71sce6nsd0bqgb8ey70a.png" alt="Deepgram voice agent UI" width="677" height="530"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://deepgram.com/" rel="noopener noreferrer"&gt;Deepgram&lt;/a&gt; is a voice AI application creation platform. Developers can use its API to build audio apps with text-to-speech, speech-to-speech, and speech-to-text models. To experience and see how Deepgram works, visit the URL above and try the interactive, real-time voice demo on the home page.  &lt;/p&gt;

&lt;p&gt;With Deepgram, you can try different models and APIs to build intelligent audio apps for use cases in customer service, telemedicine, sales, service ordering, etc. The quickest way to start building your app with Deepgram is to try its &lt;a href="https://playground.deepgram.com/" rel="noopener noreferrer"&gt;API playground&lt;/a&gt;.  &lt;/p&gt;

&lt;h4&gt;
  
  
  Get Started with Deepgram
&lt;/h4&gt;

&lt;p&gt;One advantage of using Deepgram is that its APIs are available for Python, JavaScript, C#, and Go. To build your first voice agent with any of the Deepgram SDKs, configure your environment and install the platform-specific SDK. The example commands below are for Python. &lt;/p&gt;

&lt;p&gt;To begin with your preferred platform for implementing a voice agent, head to the &lt;a href="https://developers.deepgram.com/docs/voice-agent" rel="noopener noreferrer"&gt;Getting Started&lt;/a&gt; guides.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;deepgram-agent-demo
&lt;span class="nb"&gt;cd &lt;/span&gt;deepgram-agent-demo
&lt;span class="nb"&gt;touch &lt;/span&gt;index.py
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;DEEPGRAM_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"your_Deepgram_API_key_here"&lt;/span&gt;
@TODO other Python commands

&lt;span class="c"&gt;# Install the SDK&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;deepgram-sdk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;br&gt;
    &lt;br&gt;
  Deepgram interactive voice demo&lt;br&gt;
&lt;/p&gt;
&lt;h3&gt;
  
  
  5. Vapi: Voice AI Agents For Developers
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxfv0gzkb3c1odw49dl1b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxfv0gzkb3c1odw49dl1b.png" alt="Vapi voice agent UI" width="800" height="411"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://vapi.ai/" rel="noopener noreferrer"&gt;Vapi&lt;/a&gt; platform helps developers build and deploy voice agents and AI products in Python, React, and TypeScript. It provides two ways to make intelligent voice apps. It's assistant's option allows you to create simple conversational services that may require a single system prompt for the underlying model's operations. &lt;/p&gt;

&lt;p&gt;Example use cases of Vapi Assistants include simple question and answer systems and chatbots. If an agentic system has a complex logic or involves a multi-step process, you can use the workflow feature of Vapi to build your agents. Applications in this category are suitable for appointment scheduling and service ordering. &lt;/p&gt;

&lt;p&gt;Vapi is an excellent choice for developers and enterprises developing voice AI products for call operations involving actual phone numbers. You can integrate it with several applications and model providers, such as Salesforce, Notion, Google Calendar, Slack, OpenAI, Anthropic, Gemini, and more. &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Multilingual support&lt;/strong&gt;: The API supports multilingual operations. This means your app's users can speak to agents in English, Spanish, and 100+ other supported languages.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;External tools&lt;/strong&gt;: Easily add external tools to allow your voice agent to perform accurate actions. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automated testing&lt;/strong&gt;:  Use simulated AI voices to create test suites for production-ready agents. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plugin any model&lt;/strong&gt;: You can bring your favorite text-to-speech, speech-to-text, and speech-to-speech models from any major AI service provider. &lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;
  
  
  Get Started with Vapi
&lt;/h4&gt;

&lt;p&gt;Making your first voice AI app with Vapi or integrating it with an existing app is simple. The React sample code below can get you started.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Vapi&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@vapi-ai/web&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;vapi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Vapi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;YOUR_PUBLIC_API_KEY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Get your public api key from the dashboard&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;VapiAssistant&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;callStatus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setCallStatus&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;inactive&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setCallStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;loading&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;vapi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;YOUR_ASSISTANT_ID&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Get your assistant id from the dashboard&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setCallStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;loading&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;vapi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;vapi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;call-start&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setCallStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nx"&gt;vapi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;call-end&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setCallStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;inactive&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;vapi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeAllListeners&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;callStatus&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;inactive&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;button&lt;/span&gt; &lt;span class="nx"&gt;onClick&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nx"&gt;Start&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/button&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt; : null&lt;/span&gt;&lt;span class="err"&gt;}
&lt;/span&gt;      &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;callStatus&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;loading&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nx"&gt;Loading&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/i&amp;gt; : null&lt;/span&gt;&lt;span class="err"&gt;}
&lt;/span&gt;      &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;callStatus&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;button&lt;/span&gt; &lt;span class="nx"&gt;onClick&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nx"&gt;Stop&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/button&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt; : null&lt;/span&gt;&lt;span class="err"&gt;}
&lt;/span&gt;    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Visit &lt;a href="https://vapi.ai/" rel="noopener noreferrer"&gt;vapi.ai&lt;/a&gt; to try making voice agents for other platforms. &lt;/p&gt;

&lt;h3&gt;
  
  
  6. Play.ai: Build Real-Time Intelligent Voice Apps
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr10m65mqqa3pds17wz6a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr10m65mqqa3pds17wz6a.png" alt="PlayAI voice AI UI" width="800" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://play.ai/" rel="noopener noreferrer"&gt;PlayAI&lt;/a&gt; is a platform for making intelligent voice apps for the web and mobile. The platform allows engineers to create voice agents for healthcare, real estate, gaming, food delivery, &lt;a href="https://getstream.io/chat/solutions/ed-tech/" rel="noopener noreferrer"&gt;EdTech&lt;/a&gt;, and more. &lt;/p&gt;

&lt;p&gt;To see how the service works, check out the interactive voice chat demo from the above URL or try the &lt;a href="https://play.ai/playnote/my-notes" rel="noopener noreferrer"&gt;PlayNote&lt;/a&gt; web app, which allows you to turn JPEG, PDF, EPUB, CSV, and several other files into a human-like-sounding audio format. Like other platforms, PlayAI has a &lt;a href="https://play.ai/voices/voice-library" rel="noopener noreferrer"&gt;library&lt;/a&gt; of AI voices for experimenting with your apps. &lt;/p&gt;

&lt;p&gt;The PlayAI's &lt;a href="https://play.ai/api/sandbox" rel="noopener noreferrer"&gt;playground/sandbox&lt;/a&gt; provides a starting point for experimenting, testing speech generation, and building audio experiences. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F18c8lggbs4qcbbxg8565.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F18c8lggbs4qcbbxg8565.png" alt="PlayAI playground" width="800" height="388"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Get Started with PlayAI
&lt;/h4&gt;

&lt;p&gt;You can use the PlayAI text-to-speech &lt;a href="https://docs.play.ai/documentation/text-to-speech/tts-quickstart" rel="noopener noreferrer"&gt;API&lt;/a&gt; in Bash, Python, JavaScript, Go, Dart, and Swift. First, set your API credentials on your machine.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;
&lt;span class="c"&gt;# macOS (zsh)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'export PLAYAI_KEY="your_api_key_here"'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.zshrc
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'export PLAYAI_USER_ID="your_user_id_here"'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.zshrc
&lt;span class="nb"&gt;source&lt;/span&gt; ~/.zshrc

&lt;span class="c"&gt;# Windows&lt;/span&gt;
setx PLAYAI_KEY &lt;span class="s2"&gt;"your_api_key_here"&lt;/span&gt;
setx PLAYAI_USER_ID &lt;span class="s2"&gt;"your_user_id_here"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, make your first API call to create an audio from a text prompt using this Curl script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s1"&gt;'https://api.play.ai/api/v1/tts/stream'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$PLAYAI_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-USER-ID: &lt;/span&gt;&lt;span class="nv"&gt;$PLAYAI_USER_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "model": "PlayDialog",
    "text": "Hello! This is my first text-to-speech audio using PlayAI!",
    "voice": "s3://voice-cloning-zero-shot/baf1ef41-36b6-428c-9bdf-50ba54682bd8/original/manifest.json",
    "outputFormat": "wav"
  }'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running the above in your Terminal should output an audio file called &lt;strong&gt;hello.wav&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Pipecat: Build Voice AI Apps
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpmkv78n0p7df64yiluuh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpmkv78n0p7df64yiluuh.png" alt="Pipecat illustration" width="800" height="380"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://getstream.io/blog/pipecat-alternatives/" rel="noopener noreferrer"&gt;Pipecat&lt;/a&gt; is one of the most widely used &lt;a href="https://github.com/pipecat-ai/pipecat" rel="noopener noreferrer"&gt;open-source&lt;/a&gt; frameworks for building conversational AI applications. The framework allows developers to create complex dialog systems, enterprise-grade customer support agents, multimodal interactions (video, voice, and images), and video meeting assistants. &lt;/p&gt;

&lt;p&gt;Check out this &lt;a href="https://x.com/sathvikdivili/status/1939404933192970473" rel="noopener noreferrer"&gt;X post&lt;/a&gt; to see a practical demo of Pipecat in action. &lt;/p&gt;

&lt;p&gt;The client SDKs for Web, iOS, Android, and C++ allow you to build low-latency conversational apps with several AI services, tools, and underlying backend technologies such as WebRTC and WebSockets. &lt;/p&gt;

&lt;h4&gt;
  
  
  Get Started with Pipecat
&lt;/h4&gt;

&lt;p&gt;You can start running Pipecat on a local machine by configuring your environment, installing the module, and switching to the cloud once your voice application is ready for production.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install the module&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;pipecat-ai

&lt;span class="c"&gt;# Set up your environment&lt;/span&gt;
&lt;span class="nb"&gt;cp &lt;/span&gt;dot-env.template .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Refer to the Pipecat’s &lt;a href="https://github.com/pipecat-ai/pipecat" rel="noopener noreferrer"&gt;GitHub repo&lt;/a&gt; for more code samples and detailed instructions on building conversational agents with the framework. &lt;/p&gt;

&lt;h3&gt;
  
  
  8. Cartesia: Create Realistic AI Voices
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy830pj0id47c8vojj34k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy830pj0id47c8vojj34k.png" alt="Cartesia voice UI" width="800" height="395"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://cartesia.ai/" rel="noopener noreferrer"&gt;Cartesia&lt;/a&gt; is a developer-first platform for voice AI. The Cartesia API makes incorporating high-quality voices into any product easier. It also provides seamless support for extending voice agents with other platforms like LiveKit, Vapi, and Pipecat. You can build your speech applications in 15+ languages and deploy them anywhere and on any device. &lt;/p&gt;

&lt;p&gt;To learn more about how Cartesia works, you can check out its &lt;a href="https://cartesia.ai/sonic" rel="noopener noreferrer"&gt;Sonic&lt;/a&gt; text-to-speech and &lt;a href="https://cartesia.ai/ink" rel="noopener noreferrer"&gt;Ink-Whisper&lt;/a&gt; speech-to-text models.  &lt;/p&gt;

&lt;h4&gt;
  
  
  Get Started with Cartesia
&lt;/h4&gt;

&lt;p&gt;Depending on your machine, there are a few installation requirements for using the &lt;a href="https://docs.cartesia.ai/2024-11-13/get-started/make-an-api-request" rel="noopener noreferrer"&gt;Cartesia API&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# macOS&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;ffmpeg

&lt;span class="c"&gt;# Debian/Ubuntu&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;ffmpeg

&lt;span class="c"&gt;# Fedora&lt;/span&gt;
dnf &lt;span class="nb"&gt;install &lt;/span&gt;ffmpeg

&lt;span class="c"&gt;# Arch Linux&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;pacman &lt;span class="nt"&gt;-S&lt;/span&gt; ffmpeg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After you install any of the above for your computer, you can now make an API call to generate your first speech from text using cURL, Python, or JavaScript/TypeScript.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.cartesia.ai/tts/bytes"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Cartesia-Version: 2024-11-13"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-API-Key: YOUR_API_KEY"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"transcript": "Welcome to Cartesia Sonic!", "model_id": "sonic-2", "voice": {"mode":"id", "id": "694f9389-aac1-45b6-b726-9d9369183238"}, "output_format":{"container":"wav", "encoding":"pcm_f32le", "sample_rate":44100}}'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; sonic-2.wav
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To go further, you can try Cartesia's &lt;a href="https://docs.cartesia.ai/2024-11-13/use-an-sdk/python" rel="noopener noreferrer"&gt;Python&lt;/a&gt; and &lt;a href="https://docs.cartesia.ai/2024-11-13/use-an-sdk/javascript-typescript" rel="noopener noreferrer"&gt;TypeScript&lt;/a&gt; SDKs. &lt;/p&gt;

&lt;h2&gt;
  
  
  Other Notable Voice AI Platforms
&lt;/h2&gt;

&lt;p&gt;There are other excellent services like &lt;a href="https://getstream.io/blog/livekit-alternatives/" rel="noopener noreferrer"&gt;LiveKit&lt;/a&gt;, &lt;a href="https://huggingface.co/spaces/hexgrad/Kokoro-TTS" rel="noopener noreferrer"&gt;Kokoro TTS&lt;/a&gt;, and &lt;a href="https://github.com/moonshine-ai/moonshine" rel="noopener noreferrer"&gt;Moonshine&lt;/a&gt; for creating voice applications. You can also use platforms like &lt;a href="https://unmute.sh/" rel="noopener noreferrer"&gt;Unmute.sh&lt;/a&gt; and &lt;a href="https://www.openai.fm/" rel="noopener noreferrer"&gt;OpenAI.fm&lt;/a&gt; to experiment and test with a library of natural and realistic AI voices.&lt;/p&gt;

&lt;p&gt;To make voice apps for specific use cases, such as phone call operations, you can use services like &lt;a href="https://www.bland.ai/" rel="noopener noreferrer"&gt;Bland AI&lt;/a&gt;, &lt;a href="https://www.retellai.com/" rel="noopener noreferrer"&gt;Retell AI&lt;/a&gt;, and &lt;a href="https://synthflow.ai/" rel="noopener noreferrer"&gt;Synthflow AI&lt;/a&gt;. If you want to try open-source TTS models, check out &lt;a href="https://github.com/resemble-ai/chatterbox/" rel="noopener noreferrer"&gt;Chatterbox TTS&lt;/a&gt; and the &lt;a href="https://huggingface.co/spaces?category=speech-synthesis" rel="noopener noreferrer"&gt;Speech Synthesis&lt;/a&gt; category on Hugging Face Spaces.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Future of Voice Agents
&lt;/h2&gt;

&lt;p&gt;This article covered eight of the best platforms you can use today to make speech AI products. &lt;/p&gt;

&lt;p&gt;Although all the platforms covered in this article provide natural-sounding human-like voices, many have high latencies when interacting with agents. Some do not handle interruptions and noisy conditions properly. For example, many platforms will struggle to understand a user's voice, especially if a baby or a kid is playing and talking in the background.&lt;/p&gt;

&lt;p&gt;As this AI field keeps improving regularly, future speech models, APIs, and SDKs will enhance their interruption capabilities, noise detection, and ensure low-latency speech-to-speech, speech-to-text, and text-to-speech interactions. Many of these platforms are currently closed source, but as the voice AI landscape evolves rapidly, open-source alternatives will continue to emerge.&lt;/p&gt;

</description>
      <category>voiceaiagents</category>
      <category>voiceai</category>
    </item>
    <item>
      <title>Official Statement: Distinction Between GetStream.io and GetStream.live</title>
      <dc:creator>Sarah Lindauer</dc:creator>
      <pubDate>Tue, 27 Jan 2026 22:40:28 +0000</pubDate>
      <link>https://dev.to/getstreamhq/official-statement-distinction-between-getstreamio-and-getstreamlive-166p</link>
      <guid>https://dev.to/getstreamhq/official-statement-distinction-between-getstreamio-and-getstreamlive-166p</guid>
      <description>&lt;h3&gt;
  
  
  We are issuing this advisory to clarify confusion regarding our brand, GetStream.io (Stream), and an unrelated third-party website known as "GetStream.live."
&lt;/h3&gt;

&lt;p&gt;Stream is a legitimate technology company providing Chat and Activity Feed APIs for developers. We are &lt;strong&gt;NOT&lt;/strong&gt; affiliated, associated, or connected in any way with "&lt;strong&gt;GetStream.live&lt;/strong&gt;," a site primarily known for unauthorized sports streaming.&lt;/p&gt;

&lt;blockquote&gt;
&lt;h2&gt;
  
  
  &lt;strong&gt;Important Security Warning for Users&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;If you arrived here searching for free sports streaming, please be aware that you have reached the wrong destination.&lt;/p&gt;

&lt;p&gt;We strongly advise caution if you intend to visit unauthorized streaming websites ending in &lt;code&gt;.live&lt;/code&gt; or similar TLDs. Cybersecurity researchers frequently associate illegal streaming sites with high risks of malware, browser hijackers, and phishing scams targeting personal data.&lt;/p&gt;

&lt;p&gt;GetStream.io maintains strict security protocols and is a trusted service provider for over a billion end-users globally. We do not host, stream, or provide access to copyrighted sports content.&lt;/p&gt;

&lt;p&gt;If you would like to watch your events online, we suggest a legal site like &lt;a href="https://www.espn.com/watch/" rel="noopener noreferrer"&gt;ESPN&lt;/a&gt;, &lt;a href="https://www.nba.com/watch/nba-tv" rel="noopener noreferrer"&gt;NBA&lt;/a&gt;, or &lt;a href="https://go3.tv/en/sports" rel="noopener noreferrer"&gt;Go3&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What is Stream?
&lt;/h2&gt;

&lt;p&gt;We build the world's most scalable Chat, Video, and Activity Feed infrastructure for applications.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;For Developers:&lt;/strong&gt; If you are looking to integrate in-app chat or social feeds into your product, you are in the right place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For Sports Fans:&lt;/strong&gt; If you are looking for live sports, we recommend using authorized, legal streaming platforms to ensure your device security.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is GetStream.io the same as GetStream.live?
&lt;/h3&gt;

&lt;p&gt;No. GetStream.io is a US-based SaaS technology company founded in 2014. GetStream.live is an entirely unrelated entity.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does GetStream.live appear in search results when I search for your brand?
&lt;/h3&gt;

&lt;p&gt;Due to the similarity in naming, search engines may occasionally conflate the two distinct entities. We are publishing advisories like this one to help clarify the distinction for users and search algorithms.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the .live associated with Getstreaming.TV?
&lt;/h3&gt;

&lt;p&gt;No, the .live site is not associated with the site you are on right now (.io), or &lt;a href="https://getstreaming.tv/" rel="noopener noreferrer"&gt;Getstreaming.TV&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Am I really at risk of having malware installed?
&lt;/h3&gt;

&lt;p&gt;Yes. The &lt;strong&gt;.live&lt;/strong&gt; site is using sports streams created illegally. These streams are not from official sources and can contain any malicious files the creator chooses to include.&lt;/p&gt;

&lt;h3&gt;
  
  
  I've read reviews that the .live site is a legit place to watch sports, is that not correct?
&lt;/h3&gt;

&lt;p&gt;If you are referring to &lt;a href="https://www.scamadviser.com/check-website/getstream.live" rel="noopener noreferrer"&gt;these reviews of the .live site&lt;/a&gt; those should definitely not be trusted.&lt;/p&gt;

</description>
      <category>api</category>
      <category>cybersecurity</category>
      <category>news</category>
      <category>security</category>
    </item>
  </channel>
</rss>
