# Quickstart

> **Note:** This page covers every SDK. Each sample is labelled with its language and where it runs: the client (your app, signed in as the user) or the server (with your API secret).

Building your own text, voice or video AI is easy.
In this quickstart you create an agent, give it a tool that calls your own code, and build a chat and voice UI for it.

Stream hosts the agents backend for you, in regions around the world for low latency.
The backend is open source, so you can also [self-host](https://getstream.io/agents/docs/self-hosting/) it.

Time to get started. If you get stuck at any point, use the **Ask AI** button at the top of the page. It is built with Stream Agents too.

## The quickest path

In a hurry? Copy a prompt into Claude or another coding agent, and it builds the agent for you in the SDKs you picked at the top of the page. Each prompt points it at Stream's agent skill, `getstream.io/SKILL-agents.md`.

**Example prompts** to paste into a coding agent. Each ends with "Use the Stream agent skill: getstream.io/SKILL-agents.md".

**Voice agent**

Build me a voice AI in Node.js that takes orders for my restaurant. Optimize it for Dutch and give me a phone number in Amsterdam.

**Video agent**

Build me a video AI in Node.js that checks which strawberries are ripe and counts them.

**Support agent**

Build me a support agent in Node.js that answers questions from my docs, and add its chat UI to my web app.

## Stream app setup

Install the beta [Stream CLI](https://getstream.io/cli/docs/install/), then pick or create the Stream app your agent runs in:

```sh
curl -fsSL https://getstream.io/cli/beta/install.sh | bash
getstream --version # a beta build shows a date and commit, such as 2026-10-14-3178aae

getstream init # pick or create a Stream app
```

>
> **Warning:** The `getstream agents` commands are only in beta builds of the CLI. If `getstream agents` answers "agents unavailable", you are running a stable build: install the beta with the command above, and check that `getstream --version` shows a date and commit.
>

The installer puts `getstream` in `~/.local/bin`. If that folder is not on your `PATH`, the installer adds it to your shell's rc file (`~/.zshrc`, `~/.bashrc`, `~/.bash_profile` or `~/.profile`, or fish's `config.fish`), so open a new terminal before you run `getstream`.

## Create an agent

First, define an agent. An agent is a folder of configuration files that you sync to Stream, and it holds no code. Each agent folder has this structure:

```text
agents/my-agent/
  knowledge/       # indexed markdown and URLs
    faq.md
    urls.yaml
  skills/          # skills loaded as needed
    research.md
  agent.yaml       # config for the agent
  instructions.md  # system prompt
  guardrail.md     # prevent abuse
```

1. **Create the folder from a template**

   The quickest way to create an agent folder is from one of the CLI's templates. Run this in your project's folder:

   ```sh
   getstream agents init my-agent --template text # or --template voice
   ```

2. **Edit the system prompt**

   The `agents/my-agent/` folder appears, with a lot of settings to play with. The template is a support agent for Acme Corp, a made-up company. Edit the system prompt in `instructions.md` to match your use case.

3. **Sync the agent**

   Upload the agent to Stream:

   ```sh
   getstream agents sync my-agent
   ```

   Your agent now runs on Stream.

4. **Talk to it**

   Talk to it from the terminal and see how it does:

   ```sh
   getstream agents test my-agent
   ```

   Ask it about Acme first, such as "How do I get started with Acme?". The template's guardrail turns away questions that are not about Acme Corp.

You build any agent from these blocks:

- [Instructions](https://getstream.io/agents/docs/creating-an-agent/): the system prompt in `instructions.md`.
- [Knowledge and skills](https://getstream.io/agents/docs/knowledge-and-skills/): documents the agent searches, and instructions it loads when a task needs them.
- [Guardrail](https://getstream.io/agents/docs/guardrails/): what the agent refuses to talk about.
- [Tools](https://getstream.io/agents/docs/tool-calling-and-dispatch/): functions in your own code that the agent calls. The tool calling section below adds one.
- [Sandbox](https://getstream.io/agents/docs/harness-and-sandbox/): an isolated place where the agent writes and runs code, such as Python for arithmetic or parsing.

## Client and server

Stream hosts the agent you just synced: the backend runs its conversations and calls, picks the models, and searches its knowledge. Running your own code next to it is not self-hosting, which means running that backend yourself.

From here on, your own code runs in two places, and each call is made in only one of them:

- **Client**: your app (JavaScript in the browser, Kotlin on Android, Swift on iOS, or Flutter) holds the conversation for the user who signed in. It opens the session, sends what they type or say, and shows the replies. It connects with the public API key and a token for that user, never with your API secret.
- **Server**: your server (Node.js, Python, Ruby, PHP, Go, .NET or Rust) holds the API secret. It mints each user's token, runs the tools that reach your systems in a worker (see the next section), and does what a client may not, such as syncing agents, buying phone numbers and setting spend limits.

So you don't open a session on your server and again in the client: the client opens it. With no client, such as for a phone line, WhatsApp or a server-only integration, your server opens sessions instead.

Pick yours in the **Client** and **Server** menus at the top of the page, and every sample shows the SDK you picked. [Auth](https://getstream.io/agents/docs/auth/) has the details.

## Tool calling

Tool calling connects your agent to your own systems.

If you're building a customer support bot, you typically want tools for:

- Creating a ticket
- Looking up a ticket
- Replying to a customer

If you're building a legal AI, you might want to expose legal databases through tools.

Tools are code, so they don't go in the agent folder. Server tools run in a **worker**: a process on your server that connects out to Stream (nothing you run needs to be publicly reachable). When the model calls a tool, Stream sends the call to your worker, the worker runs your function, and the result goes back to the model. Stream still runs the agent; the worker only answers its tool calls. An agent that uses only built-in features and [connectors](https://getstream.io/agents/docs/connectors/catalog/) needs no worker.

1. **Install the server SDK**

   Install the SDK in your server project:

   **Node.js (server)**

   ```sh label="JavaScript"
   npm install @stream-io/stream-agents
   ```

   **Python (server)**

   ```sh label="Python"
   uv add "vision-agents[stream]"
   ```

   **Ruby (server)**

   ```sh label="Ruby"
   bundle add getstream-vision-agents
   ```

   **PHP (server)**

   ```sh label="PHP"
   composer require getstream/vision-agents amphp/websocket-client amphp/http-client-psr7
   ```

   **Go (server)**

   ```sh label="Go"
   go get github.com/GetStream/Vision-Agents/sdks/go@latest
   ```

   **.NET (server)**

   ```sh label="C#"
   dotnet add package getstream-vision-agents-net
   ```

   **Rust (server)**

   ```sh label="Rust"
   cargo add vision-agents --git https://github.com/GetStream/Vision-Agents --branch accelerate
   cargo add tokio --features full
   cargo add serde_json
   ```

2. **Set your app's credentials**

   The worker signs in to Stream with your app's API key and secret, which it reads from the environment. `getstream env` writes your app's keys to an env file for your project. Set them in the terminal where you run the worker:

   ```sh
   export STREAM_API_KEY=your_api_key
   export STREAM_API_SECRET=your_api_secret
   ```

   The secret is for your server only: never put it in an app or a web page. See [Auth](https://getstream.io/agents/docs/auth/).

3. **Write and start the worker**

   This worker registers a `weather_lookup` tool on `my-agent` and then runs, waiting for tool calls. Save it as a file in your server project, such as `worker.ts`, `worker.py` or `main.go`:

   **Node.js (server)**

   ```ts label="JavaScript"
   import { Client, Dispatch } from "@stream-io/stream-agents";

   const client = new Client();
   const agent = client.agent("my-agent");

   agent.tools.register<{ location: string }>({
     name: "weather_lookup",
     description: "Look up the current weather for a location",
     parameters: {
       type: "object",
       properties: {
         location: { type: "string", description: "a city or region" },
       },
       required: ["location"],
     },
     run: ({ location }) => {
       const place = location.toLowerCase();
       if (place.includes("colorado")) return "sunny";
       if (place.includes("amsterdam")) return "rainy";
       if (place.includes("san francisco")) return "foggy";
       return `no forecast for ${location}`;
     },
   });

   await new Dispatch().host(agent).run();
   ```

   **Python (server)**

   ```python label="Python"
   import asyncio

   from vision_agents.plugins import stream

   agent = stream.Client().agent("my-agent")


   @agent.register(description="Look up the current weather for a location")
   async def weather_lookup(location: str) -> str:
       place = location.lower()
       if "colorado" in place:
           return "sunny"
       if "amsterdam" in place:
           return "rainy"
       if "san francisco" in place:
           return "foggy"
       return f"no forecast for {location}"


   async def main() -> None:
       dispatch = stream.Dispatch()
       dispatch.host(agent)
       await dispatch.run()


   asyncio.run(main())
   ```

   **Ruby (server)**

   ```ruby label="Ruby"
   require "getstream/vision_agents"

   agent = GetStream::VisionAgents::Client.new.agent("my-agent")

   agent.tools.register(
     "weather_lookup",
     description: "Look up the current weather for a location",
     parameters: {
       type: "object",
       properties: {
         location: { type: "string", description: "a city or region" },
       },
       required: ["location"],
     },
   ) do |args|
     location = args["location"]
     place = location.downcase
     if place.include?("colorado") then "sunny"
     elsif place.include?("amsterdam") then "rainy"
     elsif place.include?("san francisco") then "foggy"
     else "no forecast for #{location}"
     end
   end

   dispatch = GetStream::VisionAgents::Dispatch.new
   dispatch.host(agent)
   dispatch.run
   ```

   **PHP (server)**

   ```php label="PHP"
<?php

   declare(strict_types=1);

   use GetStream\VisionAgents\Client;
   use GetStream\VisionAgents\Worker\Dispatch;

   require __DIR__ . '/vendor/autoload.php';

   $agent = (new Client())->agent('my-agent');

   $agent->tools->register(
       'weather_lookup',
       'Look up the current weather for a location',
       [
           'type' => 'object',
           'properties' => [
               'location' => [
                   'type' => 'string',
                   'description' => 'a city or region',
               ],
           ],
           'required' => ['location'],
       ],
       function (array $args): string {
           $location = $args['location'];
           $place = strtolower($location);
           return match (true) {
               str_contains($place, 'colorado') => 'sunny',
               str_contains($place, 'amsterdam') => 'rainy',
               str_contains($place, 'san francisco') => 'foggy',
               default => "no forecast for {$location}",
           };
       },
   );

   (new Dispatch())->host($agent)->run();
   ```

   **Go (server)**

   ```go label="Go"
   package main

   import (
   	"context"
   	"log"
   	"strings"

   	"github.com/GetStream/Vision-Agents/sdks/go/agents"
   	"github.com/GetStream/Vision-Agents/sdks/go/client"
   	"github.com/GetStream/Vision-Agents/sdks/go/stream"
   	"github.com/GetStream/Vision-Agents/sdks/go/tools"
   )

   type WeatherLookup struct {
   	Location string `json:"location" schema:"a city or region"`
   }

   func weatherLookup(_ context.Context, in WeatherLookup) (any, error) {
   	place := strings.ToLower(in.Location)
   	switch {
   	case strings.Contains(place, "colorado"):
   		return "sunny", nil
   	case strings.Contains(place, "amsterdam"):
   		return "rainy", nil
   	case strings.Contains(place, "san francisco"):
   		return "foggy", nil
   	}
   	return "no forecast for " + in.Location, nil
   }

   func main() {
   	api, err := client.New(stream.Backend{})
   	if err != nil {
   		log.Fatal(err)
   	}
   	agent := api.Agent("my-agent")
   	if err := tools.Register(agent.Tools(), "weather_lookup", "Look up the current weather for a location", weatherLookup); err != nil {
   		log.Fatal(err)
   	}

   	dispatch, err := agents.NewDispatch(agents.DispatchOptions{})
   	if err != nil {
   		log.Fatal(err)
   	}
   	dispatch.Host(agent, 0)
   	log.Fatal(dispatch.Run(context.Background()))
   }
   ```

   **.NET (server)**

   ```csharp label="C#"
   using GetStream.VisionAgents;

   using var client = new VisionAgentsClient();
   var agent = client.Agent("my-agent");

   agent.Tools.Register<WeatherLookup, string>("weather_lookup",
       "Look up the current weather for a location",
       (lookup, _) => Task.FromResult(Forecast(lookup.Location)));

   await new Dispatch(new DispatchOptions { Client = client }).Host(agent).RunAsync();

   static string Forecast(string location) => location.ToLowerInvariant() switch
   {
       var place when place.Contains("colorado") => "sunny",
       var place when place.Contains("amsterdam") => "rainy",
       var place when place.Contains("san francisco") => "foggy",
       _ => $"no forecast for {location}",
   };

   record WeatherLookup(string Location);
   ```

   **Rust (server)**

   ```rust label="Rust"
   use serde_json::json;
   use vision_agents::{Client, Dispatch};

   #[tokio::main]
   async fn main() -> vision_agents::Result<()> {
       let client = Client::from_env()?;
       let agent = client.agent("my-agent");

       agent.tools.register(
           "weather_lookup",
           "Look up the current weather for a location",
           json!({
               "type": "object",
               "properties": { "location": { "type": "string", "description": "a city or region" } },
               "required": ["location"],
           }),
           |args| async move {
               let place = args["location"].as_str().unwrap_or_default().to_lowercase();
               let weather = if place.contains("colorado") {
                   "sunny"
               } else if place.contains("amsterdam") {
                   "rainy"
               } else if place.contains("san francisco") {
                   "foggy"
               } else {
                   "no forecast for that location"
               };
               Ok::<_, String>(weather)
           },
       );

       let dispatch = Dispatch::new(client);
       dispatch.host(&agent, None);
       dispatch.run().await
   }
   ```

   Start the worker with your language's usual run command, and leave it running in its own terminal. From now on, every session of `my-agent` can call `weather_lookup`.

4. **Let the weather through and test**

   The template's guardrail turns away anything that isn't about Acme, so it would refuse a weather question before the tool is ever called. In a second terminal, edit the policy in `agents/my-agent/guardrail.md` to allow the weather:

   ```text
   Answer questions about Acme Corp: its products, plans, pricing, accounts and support,
   and questions about the weather.
   Greetings, thanks, goodbyes and questions about who the agent is or what it can help with
   are permitted too.
   ```

   Then test again. `--sync` uploads your changed guardrail before the test starts:

   ```sh
   getstream agents test my-agent --sync
   ```

   Ask about the weather in Amsterdam, Colorado or San Francisco. The model calls `weather_lookup`, your worker answers, and the agent replies with the forecast.

Approval, interruptions, progress updates and the rest are in [Tool calling and dispatch](https://getstream.io/agents/docs/tool-calling-and-dispatch/).
For a runnable Go agent with tools, knowledge and memory together, see the [caregiving example](https://github.com/GetStream/Vision-Agents/tree/accelerate/sdks/go/examples/caregiving).

## Agent UI

Next, build your agent's UI in React, on Android or on iOS.

1. **Install the SDK**

   **JavaScript (client)**

   ```bash label="JavaScript"
   npm install @stream-io/stream-agents stream-chat stream-chat-react @stream-io/video-react-sdk
   ```

   **Kotlin (client)**

   ```kotlin label="Kotlin"
   // settings.gradle.kts: the SDK and the Stream Chat it uses come from Stream's Maven repository
   dependencyResolutionManagement {
       repositories {
           google()
           mavenCentral()
           maven("https://stream-io-repo.com")
       }
   }

   // build.gradle.kts: needs compileSdk 37 and Kotlin 2.3 or newer
   implementation("io.getstream:stream-agents-android-core:0.2.0")
   implementation("io.getstream:stream-chat-android-ai-compose:7.18.0")
   implementation("androidx.lifecycle:lifecycle-runtime-compose:<version>")
   ```

   **Swift (client)**

   ```swift label="Swift"
   // Package.swift: needs iOS 15 and Xcode 27 or newer
   platforms: [.iOS(.v15)],
   dependencies: [
       .package(url: "https://github.com/GetStream/Vision-Agents.git", branch: "accelerate"),
       .package(url: "https://github.com/GetStream/stream-chat-swift.git", from: "5.16.0"),
   ],
   targets: [
       .target(
           name: "MyApp",
           dependencies: [
               .product(name: "StreamAgents", package: "Vision-Agents"),
               .product(name: "StreamChatAI", package: "stream-chat-swift"),
           ]
       ),
   ]
   ```

2. **Mint a token**

   Your users sign in with a Stream user token.
   Mint one for a test user with the CLI.

   ```sh
   getstream token jlahey --ttl 2h
   ```

   >
   > **Info:** In production, your server mints the token when a user signs up or logs in.
   >

3. **Create a session**

   A session is one conversation between a user and your agent, by text or by voice. Open one and ask the agent something.
   Replace `your_api_key` with your app's API key (the public one, which `getstream env` writes to your env file) and `paste_the_token_here` with the token from the previous step:

   **JavaScript (client)**

   ```ts label="JavaScript"
   import { Client, type Session } from "@stream-io/stream-agents";

   // Stream's hosted router. Self-hosting covers pointing it at your own.
   export const api = new Client({ apiKey: "your_api_key" });

   /** Call once, when the user signs in. */
   export async function signIn() {
     await api.setUser("jlahey", "paste_the_token_here");
   }

   /** Opens a session with the agent and asks it something. */
   export async function startSession(): Promise<Session> {
     const session = await api.agent("my-agent").sessions.create();
     await session.responses.create("How do I get started with Acme?");
     return session;
   }
   ```

   **Kotlin (client)**

   ```kotlin label="Kotlin"
   val agents = StreamAgents(apiKey = "your_api_key")
   agents.setUser(User(id = "jlahey"), token = "paste_the_token_here")

   val session = agents.agent("my-agent").chat()
   session.responses.create("How do I get started with Acme?")
   ```

   **Swift (client)**

   ```swift label="Swift"
   import StreamAgents

   let agents = StreamAgents(apiKey: "your_api_key")

   /// Call once, when the user signs in.
   func signIn() {
       agents.setUser(User(id: "jlahey"), token: "paste_the_token_here")
   }

   /// Opens a session with the agent and asks it something.
   @MainActor
   func startSession() async throws -> AgentSession {
       let session = try await agents.agent("my-agent").sessions.create()
       // The session's socket, which stops a reply and runs the tools this phone offers.
       await session.start()
       _ = try await session.responses.create("How do I get started with Acme?")
       return session
   }
   ```

4. **Add the chat UI**

   Each session's conversation is stored in a Stream Chat channel, so you show it with Stream Chat's UI components. They support thinking, tool calling, markdown, table responses, code, and [UI the agent writes](https://getstream.io/agents/docs/generative-ui/) in A2UI or OpenUI.

   >
   > **Info:** Send the user's messages with `session.responses.create`, not to the channel. The agent doesn't answer a message posted to the channel directly. `responses.create` asks the agent, and Stream adds the user's message to the channel for you.
   >
   > Voice sessions use the channel the same way:
   >
   > - Each thing the caller says is written to it as a message.
   > - The agent's reply streams into its message while it is generated, and settles once it is spoken.
   > - A reply the caller cut off is marked interrupted, and its text is usually cleared.
   >
   > So render the channel for the transcript and live captions, and don't build a second transcript from `session.events()`.
   >

   Here is how to show the UI:

   **JavaScript (client)**

   In JavaScript, import `@stream-io/stream-agents/chat` once, or `session.chat()` throws (`@stream-io/stream-agents/video` does the same for `session.video()`).

   ```tsx
   import "@stream-io/stream-agents/chat";
   import type { Session } from "@stream-io/stream-agents";
   import { useMemo } from "react";
   import type { Channel as ChatChannel, StreamChat } from "stream-chat";
   import {
     AIStateIndicator,
     Channel,
     Chat,
     MessageComposer,
     MessageList,
     WithComponents,
   } from "stream-chat-react";
   import "stream-chat-react/css/index.css";

   // The agent's replies carry `ai_generated: true`.
   declare module "stream-chat" {
     interface CustomMessageData {
       ai_generated?: boolean;
     }
   }

   /** Opens the session's channel, as the chat's views show it. */
   export async function openChat(session: Session) {
     // `session.chat()` connects to Stream Chat with the user set above.
     const { client, channel } = await session.chat();
     return {
       session,
       client: client as StreamChat,
       channel: channel as ChatChannel,
     };
   }

   export type AgentChat = Awaited<ReturnType<typeof openChat>>;

   export const AgentConversation = ({ chat }: { chat: AgentChat }) => {
     // While the agent answers, the composer shows a Stop button. Its default sends
     // `ai_indicator.stop` to the channel, which the agent does not act on, so stop
     // the reply through the session instead.
     const StopButton = useMemo(
       () => () => (
         <button
           aria-label="Stop"
           className="str-chat__stop-ai-generation-button"
           onClick={() => chat.session.interrupt()}
           type="button"
         />
       ),
       [chat.session],
     );

     return (
       <Chat
         client={chat.client}
         // Renders the agent's replies as streaming markdown.
         isMessageAIGenerated={(message) => Boolean(message.ai_generated)}
       >
         <WithComponents overrides={{ StopAIGenerationButton: StopButton }}>
           <Channel channel={chat.channel}>
             <MessageList />
             {/* Thinking and generating states, for text and voice sessions. */}
             <AIStateIndicator />
             <MessageComposer
               overrideSubmitHandler={async ({ message }) => {
                 await chat.session.responses.create(message.text ?? "");
               }}
             />
           </Channel>
         </WithComponents>
       </Chat>
     );
   };
   ```

   In React, `isMessageAIGenerated` makes `MessageList` render the agent's replies with streaming markdown, and `AIStateIndicator` shows while the agent thinks or writes. The [React AI integration guide](https://getstream.io/chat/docs/sdk/react/v14/ai-components/sdk-integration/) covers both. For a custom message, use the [AI components](https://getstream.io/chat/docs/sdk/react/v14/ai-components/) such as `StreamingMessage` and `AIMarkdown`. `AIConversation`, among them, lays the conversation out by turn with each reply's reasoning, tool calls, approvals and sign-ins. The [React assistant](https://github.com/GetStream/Vision-Agents/tree/accelerate/examples/voice_agents/react_assistant) sample builds a complete app on them, with a list of conversations, photos, voice mode, connector sign-ins and tools that run in the browser. Step 5 adds the call audio and a listening, thinking or speaking status.

   **Kotlin (client)**

   ```kotlin
   import android.content.Context
   import androidx.activity.compose.setContent
   import androidx.compose.foundation.layout.Column
   import androidx.compose.foundation.layout.fillMaxSize
   import androidx.compose.runtime.Composable
   import androidx.compose.runtime.getValue
   import androidx.compose.ui.Modifier
   import androidx.lifecycle.compose.collectAsStateWithLifecycle
   import androidx.lifecycle.lifecycleScope
   import io.getstream.chat.android.ai.compose.conversation.AIMessage
   import io.getstream.chat.android.ai.compose.conversation.AIMessageRole
   import io.getstream.chat.android.ai.compose.parts.AIClientIdentity
   import io.getstream.chat.android.ai.compose.parts.AIMessagePart
   import io.getstream.chat.android.ai.compose.ui.component.AIConversation
   import io.getstream.chat.android.ai.compose.ui.component.AIToolApprover
   import io.getstream.chat.android.ai.compose.ui.component.ChatComposer
   import io.getstream.chat.android.client.channel.state.ChannelState
   import io.getstream.chat.android.models.Message
   import io.getstream.agents.android.core.AgentSession
   import io.getstream.agents.android.core.AgentsException
   import io.getstream.agents.android.core.Conversation
   import io.getstream.agents.android.core.channelState
   import kotlinx.coroutines.CoroutineScope
   import kotlinx.coroutines.ExperimentalCoroutinesApi
   import kotlinx.coroutines.flow.SharingStarted
   import kotlinx.coroutines.flow.StateFlow
   import kotlinx.coroutines.flow.combine
   import kotlinx.coroutines.flow.flatMapLatest
   import kotlinx.coroutines.flow.flowOf
   import kotlinx.coroutines.flow.map
   import kotlinx.coroutines.flow.stateIn
   import kotlinx.coroutines.launch

   // In your activity, with the session from step 3.
   lifecycleScope.launch {
       val chat = AgentChat.open(this@MainActivity, session, lifecycleScope)
       setContent { AgentConversation(chat, Modifier.fillMaxSize()) }
   }

   @Composable
   fun AgentConversation(chat: AgentChat, modifier: Modifier = Modifier) {
       val messages by chat.messages.collectAsStateWithLifecycle()
       val answering by chat.isAnswering.collectAsStateWithLifecycle()

       Column(modifier) {
           // Each question moves to the top, and its reply streams in below it with its
           // reasoning and tool calls.
           AIConversation(
               messages = messages,
               modifier = Modifier.weight(1f),
               // The agent is on it, and its reply isn't in the channel yet.
               isThinking = answering && messages.lastOrNull()?.role != AIMessageRole.Assistant,
               // Asks the user before a tool that asks first runs.
               approver = chat.approver,
           )
           ChatComposer(
               // The agent does not answer a message sent to the channel: ask the session.
               onSendClick = { chat.send(it.text) },
               onStopClick = chat::stop,
               // While the agent answers, the composer shows a Stop button.
               isGenerating = answering,
           )
       }
   }

   /** The session's channel, as the AI components show it. */
   @OptIn(ExperimentalCoroutinesApi::class)
   class AgentChat private constructor(
       val session: AgentSession,
       val approver: AIToolApprover,
       channel: StateFlow<ChannelState?>,
       private val scope: CoroutineScope,
   ) {
       /** The channel's messages, oldest first, as Stream Chat keeps them up to date. */
       val messages: StateFlow<List<AgentMessage>> = channel
           .flatMapLatest { it?.messages ?: flowOf(emptyList()) }
           .map { messages -> messages.filter { it.deletedAt == null }.map(::AgentMessage) }
           .stateIn(scope, SharingStarted.Eagerly, emptyList())

       /** Whether a reply is on its way, from the first thought to the last word. */
       val isAnswering: StateFlow<Boolean> = combine(session.conversation, messages) { conversation, messages ->
           when (conversation.state) {
               Conversation.State.Responding, is Conversation.State.Working -> true
               else -> messages.lastOrNull()?.isGenerating == true
           }
       }.stateIn(scope, SharingStarted.Eagerly, false)

       fun send(text: String) {
           scope.launch {
               try {
                   session.responses.create(text)
               } catch (_: AgentsException) {
                   // Not sent: the user can send it again.
               }
           }
       }

       /** Stops the reply in flight. What was written so far is kept. */
       fun stop() {
           scope.launch {
               try {
                   session.interrupt()
               } catch (_: AgentsException) {
                   // The reply finished first.
               }
           }
       }

       companion object {
           /** Opens the session's channel. Pass a [scope] on the main thread, such as `lifecycleScope`. */
           suspend fun open(context: Context, session: AgentSession, scope: CoroutineScope): AgentChat {
               // The user set above, who answers the calls the agent addresses to them.
               val approver = AIToolApprover("jlahey", AIClientIdentity.installId(context), scope) { call, allowed ->
                   session.decide(call.id, allowed)
               }
               // Connects to Stream Chat as the user set above and watches the session's channel.
               return AgentChat(session, approver, session.channelState(context, scope), scope)
           }
       }
   }

   /** A message of the channel, as the AI components show it. */
   data class AgentMessage(val message: Message) : AIMessage {
       override val id: String = message.id
       // The agent's replies carry `ai_generated: true`.
       override val role: AIMessageRole =
           if (message.extraData["ai_generated"] == true) AIMessageRole.Assistant else AIMessageRole.User
       override val text: String = message.text
       override val isGenerating: Boolean = message.extraData["generating"] == true
       // The reply's reasoning and tool calls, which the router writes as its attachments. Stream Chat
       // lifts `name` out of an attachment's fields, and a tool call's step needs it back.
       override val parts: List<AIMessagePart> = AIMessagePart.parts(
           message.attachments.map { it.type.orEmpty() to it.extraData + ("name" to it.name) },
       )
   }
   ```

   On Android, the views are the [AI components](https://getstream.io/chat/docs/sdk/android/ai-components/) of `stream-chat-android-ai-compose`, and the agents SDK brings no views of its own. Stream Chat's client, which `channelState` watches the channel with, comes with the agents SDK. `AIConversation` shows each reply's reasoning and tool calls above its streaming markdown answer, and shows the agent thinking until the reply has something to show. `AIToolApprover` asks the user before a tool that asks first runs, and answers with `session.decide(call.id, allowed)`. It keeps the user's answers, so in an app, open the chat in a `ViewModel` and pass its `viewModelScope`. `ChatComposer` asks for the microphone the first time the user taps dictation; `RECORD_AUDIO` is already in your manifest, from Stream Video, which comes with the agents SDK. Its photo picker needs no permission, but `send` here passes only the text: `responses.create` takes photos as `images`. The [Android assistant](https://github.com/GetStream/Vision-Agents/tree/accelerate/examples/voice_agents/android_assistant) sample builds a complete app on them, with a list of conversations, photos, voice mode, connector sign-ins and tools that run on the phone.

   **Swift (client)**

   ```swift
   import Combine
   import StreamChat
   import StreamChatAI
   import SwiftUI
   import StreamAgents

   /// Opens a session with the agent, and shows it once its channel is open.
   struct AgentScreen: View {
       @State private var transport: AgentTransport?

       var body: some View {
           if let transport {
               AgentConversation(transport: transport)
           } else {
               ProgressView()
                   .task { transport = try? await AgentTransport(startSession()) }
           }
       }
   }

   struct AgentConversation: View {
       @StateObject private var conversation: AIConversation<AgentMessage>

       init(transport: AgentTransport) {
           _conversation = StateObject(
               wrappedValue: AIConversation(transport: transport, approver: transport.approver)
           )
       }

       var body: some View {
           // Each question moves to the top, and its reply streams in below it with its
           // reasoning and tool calls. While the agent writes, the composer shows a Stop button.
           AIConversationView(conversation: conversation)
               .safeAreaInset(edge: .bottom) {
                   AIComposerView(conversation: conversation)
               }
       }
   }

   /// Connects the AI components to the session: they show its channel, and ask the session.
   @MainActor
   final class AgentTransport: AIConversationTransport {
       let session: AgentSession
       private let channel: ChatChannelController
       private var subscription: AnyCancellable?

       init(_ session: AgentSession) async throws {
           self.session = session
           // `session.chat()` connects to Stream Chat as the user set above and opens the session's channel.
           channel = try await session.chat()
           // The session's socket, which stops a reply and runs the tools this phone offers.
           await session.start()
       }

       func connect(_ feed: AIConversationFeed<AgentMessage>) {
           // The channel lists its messages newest first, the conversation oldest first.
           let publish = { [channel] in
               feed.update(messages: channel.messages.reversed().map(AgentMessage.init))
           }
           subscription = channel.messagesChangesPublisher.sink { _ in publish() }
           channel.synchronize { _ in publish() }
       }

       // The agent does not answer a message sent to the channel: ask the session.
       func send(_ request: AIConversationRequest<AgentMessage>) async throws {
           _ = try await session.responses.create(request.text)
       }

       func stop() async throws {
           try await session.interrupt()
       }

       /// Answers the tools that ask before they run.
       var approver: AIToolApprover {
           AIToolApprover(userID: agents.backend.user?.id ?? "", clientID: AIClientIdentity.installID) { call, allowed in
               try await self.session.decide(call.id, allowed: allowed)
           }
       }
   }

   /// A message of the channel, as the AI components show it.
   struct AgentMessage: AIMessage {
       let message: ChatMessage

       var id: MessageId { message.id }
       // The agent's replies carry `ai_generated: true`.
       var role: AIMessageRole {
           message.extraData["ai_generated"]?.boolValue == true ? .assistant : .user
       }
       var text: String { message.text }
       var isGenerating: Bool { message.extraData["generating"]?.boolValue == true }
       // The reply's reasoning and tool calls, which the router writes as its attachments.
       var parts: [AIMessagePart] {
           AIMessagePart.parts(from: message.allAttachments.map { ($0.type.rawValue, $0.payload) })
       }
   }
   ```

   On iOS, the views are the [AI components](https://getstream.io/chat/docs/sdk/ios/ai-components/) of StreamChatAI, and StreamAgents brings no views of its own. StreamChat, which the snippet imports for the channel, comes with StreamAgents. [`AIConversation`](https://getstream.io/chat/docs/sdk/ios/ai-components/conversation-state/) holds the conversation's state, and the transport connects it to the session. `AIConversationView` shows the reply's [reasoning and tool calls](https://getstream.io/chat/docs/sdk/ios/ai-components/reasoning-and-tool-calls/) above its streaming markdown answer, and shows the agent thinking until the reply has something to show. The transport's `approver` lets the user answer a tool that asks first, with `session.decide(call.id, allowed: allowed)`. The composer's photo picker and dictation need the camera, photo library, microphone and speech recognition [usage descriptions](https://getstream.io/chat/docs/sdk/ios/ai-components/composer/#usage-descriptions) in your `Info.plist`. The [Swift assistant](https://github.com/GetStream/Vision-Agents/tree/accelerate/examples/voice_agents/swift_assistant) sample builds a complete app on them, with a list of conversations, photos, voice mode, connector sign-ins and tools that run on the phone.

5. **Add the voice UI**

   A voice session puts the agent on a call. Your app joins that call, and the agent hears the user and answers out loud.
   What is said on the call is written into the session's channel, so the chat's views show the transcript here too. The code builds on the client and session from the earlier steps, and on the chat UI.
   Add a glow that shows whether the agent is listening, thinking or speaking, and buttons to mute and hang up:

   **JavaScript (client)**

   ```tsx
   import { VoiceSession } from "@stream-io/stream-agents/video";
   import {
     ParticipantsAudio,
     StreamCall,
     StreamVideo,
     useCallStateHooks,
   } from "@stream-io/video-react-sdk";
   import { useEffect, useSyncExternalStore } from "react";
   import { Channel, Chat, MessageList } from "stream-chat-react";
   import { VoiceGlow } from "stream-chat-react/ai-components";

   /** Starts a session with voice on, and opens its transcript. Call it from a click. */
   export async function startVoice() {
     // The agent joins the session's call right away.
     const voice = await VoiceSession.start(api.agent("my-agent"));
     // What is said on the call is written into the session's channel, so the chat's views show it.
     return { voice, chat: await openChat(voice.session) };
   }

   export const AgentVoice = ({
     voice,
     chat,
   }: {
     voice: VoiceSession;
     chat: AgentChat;
   }) => {
     // What the agent is doing, in the states the glow takes.
     const { call, client, muted, status } = useSyncExternalStore(
       voice.subscribe,
       voice.get,
     );

     // Joins the call from this device, with the microphone on. Run twice, it joins once.
     useEffect(() => {
       voice
         .join()
         .catch((error: unknown) =>
           console.error("Could not join the call", error),
         );
     }, [voice]);

     return (
       <div>
         <VoiceGlow state={status} style={{ width: 240, height: 240 }} />
         {call && client && (
           <StreamVideo client={client}>
             <StreamCall call={call}>
               <AgentAudio />
             </StreamCall>
           </StreamVideo>
         )}
         {/* What the user said and the agent's replies, as in the chat. */}
         <Chat
           client={chat.client}
           isMessageAIGenerated={(message) => Boolean(message.ai_generated)}
         >
           <Channel channel={chat.channel}>
             <MessageList />
           </Channel>
         </Chat>
         <button onClick={() => void voice.setMuted(!muted)}>
           {muted ? "Unmute" : "Mute"}
         </button>
         {/* Leaves the call and ends the session this page started. */}
         <button onClick={() => void voice.leave()}>Hang up</button>
       </div>
     );
   };

   /** Plays the agent. */
   const AgentAudio = () => {
     const { useRemoteParticipants } = useCallStateHooks();
     return <ParticipantsAudio participants={useRemoteParticipants()} />;
   };
   ```

   `VoiceSession` joins the call with Stream Video, signed in as the user you passed to `setUser`. `join()` rejects if joining fails, for example because the user denied microphone access. Call `startVoice()` from a click: a browser asks for the microphone and plays audio only after one.
   `voice.session` is a regular `Session`, so `openChat` from step 4 works here too, and the agent's replies stream into the transcript as they're generated.
   `status` is worked out for you: speaking comes from the call, and the agent is thinking until its reply is heard, since a reply is written before it is spoken. Import `VoiceSession` from `@stream-io/stream-agents/video`, which brings in `@stream-io/video-client` with the Video React SDK from step 1, so don't install that separately.

   `VoiceGlow` is one of the React SDK's [AI components](https://getstream.io/chat/docs/sdk/react/v14/ai-components/), in `stream-chat-react/ai-components` from version 14.14. For a waveform instead of a glow, use `VoiceWave`, which takes the same states.
   To show the agent what the camera sees, join with `voice.join({ camera: true })` or call `voice.setCameraEnabled(true)`. The camera starts on the back lens where there is one, and the agent looks through it when asked, if its config has the `vision` skill and a vision subagent.

   An agent made from the text template talks with Stream's default speech-to-text and text-to-speech models. Make it with `--template voice` to choose those models and add a greeting.
   To switch an open chat to voice, call `session.voice.start()`, then join with `VoiceSession.of(session)`. Leaving then leaves the session running; `voice.end()` ends it. The voice mode of the [React assistant](https://github.com/GetStream/Vision-Agents/tree/accelerate/examples/voice_agents/react_assistant) sample does this.

   **Kotlin (client)**

   ```kotlin
   import io.getstream.chat.android.ai.compose.ui.component.AIVoiceAgentState
   import io.getstream.chat.android.ai.compose.ui.component.AIVoiceGlow
   import io.getstream.agents.android.core.Conversation
   import io.getstream.agents.android.core.SessionOptions
   import io.getstream.agents.android.core.VoiceSession

   /** Starts a session with voice on. Ask for RECORD_AUDIO before you call it. */
   suspend fun startVoice(context: Context): VoiceSession =
       // The agent joins the session's call right away.
       VoiceSession.start(context, agents, SessionOptions(agent = "my-agent"))

   @Composable
   fun AgentVoice(voice: VoiceSession) {
       val scope = rememberCoroutineScope()
       val call by voice.call.collectAsStateWithLifecycle()
       val muted by voice.isMuted.collectAsStateWithLifecycle()
       val conversation by voice.session.conversation.collectAsStateWithLifecycle()
       // Who on the call is speaking: the agent, or the person holding the phone.
       val speakers by remember(call) { call?.state?.activeSpeakers ?: MutableStateFlow(emptyList()) }
           .collectAsStateWithLifecycle()

       // Joins the call from this device, with the microphone on and the agent on the speaker.
       LaunchedEffect(voice) { voice.join() }

       // Speaking comes from the call: a reply is written before it is spoken.
       val state = when {
           call == null -> AIVoiceAgentState.Connecting
           speakers.any { !it.isLocal } -> AIVoiceAgentState.Speaking
           speakers.any { it.isLocal } -> AIVoiceAgentState.Listening
           else -> when (conversation.state) {
               Conversation.State.Listening -> AIVoiceAgentState.Listening
               Conversation.State.Responding, is Conversation.State.Working -> AIVoiceAgentState.Thinking
               else -> AIVoiceAgentState.Idle
           }
       }

       Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) {
           AIVoiceGlow(state = state, modifier = Modifier.size(240.dp))
           // What the user said and the agent's replies.
           LazyColumn(Modifier.weight(1f)) {
               items(conversation.turns) { turn -> Text(turn.text) }
           }
           Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
               Button(onClick = { voice.setMuted(!muted) }) { Text(if (muted) "Unmute" else "Mute") }
               // Leaves the call and ends the session this device started.
               Button(onClick = { scope.launch { voice.leave() } }) { Text("Hang up") }
           }
       }
   }
   ```

   `VoiceSession` joins the call with Stream Video, signed in as the user you passed to `setUser`. `join()` doesn't throw. If joining fails, for example because the app doesn't hold `RECORD_AUDIO`, the error is in `voice.failure`, so ask for the microphone before `startVoice`.
   `voice.session` is a regular `AgentSession`, so `AgentChat.open` from step 4 works here too. `conversation` holds the turns, each reply growing as it is generated.
   You don't need to add Stream Video to your app: it comes with the agents SDK.

   `AIVoiceGlow` is one of the [AI components](https://getstream.io/chat/docs/sdk/android/ai-components/) of `stream-chat-android-ai-compose`, from step 1. For a waveform instead of a glow, use `AIVoiceWave`, which takes the same states.

   An agent made from the text template talks with Stream's default speech-to-text and text-to-speech models. Make it with `--template voice` to choose those models and add a greeting.
   To switch an open chat to voice, call `session.startVoice()`, then join with `VoiceSession.attach(context, agents, session.id)`. `voice.end()` ends the session whoever started it. The voice mode of the [Android assistant](https://github.com/GetStream/Vision-Agents/tree/accelerate/examples/voice_agents/android_assistant) sample does this.

   **Swift (client)**

   ```swift
   import StreamChatAI
   import StreamVideo
   import SwiftUI
   import StreamAgents

   /// Starts a session with voice on, and opens its transcript.
   @MainActor
   func startVoice() async throws -> (voice: VoiceSession, transcript: AgentTransport) {
       // The agent joins the session's call right away.
       let voice = try await VoiceSession.start(agents: agents, agent: "my-agent")
       // The session's socket, which follows what the agent is doing and runs the tools this phone offers.
       await voice.session.start()
       // What is said on the call is written into the session's channel, so the chat's views show it.
       return (voice, try await AgentTransport(voice.session))
   }

   struct AgentVoice: View {
       @ObservedObject var voice: VoiceSession
       @StateObject private var transcript: AIConversation<AgentMessage>

       init(voice: VoiceSession, transcript: AgentTransport) {
           self.voice = voice
           _transcript = StateObject(
               wrappedValue: AIConversation(transport: transcript, approver: transcript.approver)
           )
       }

       var body: some View {
           VStack(spacing: 16) {
               if let call = voice.call {
                   AgentStatus(voice: voice, call: call.state)
               } else {
                   AIVoiceGlowView(state: .connecting)
                       .frame(width: 240, height: 240)
               }
               // What the user said and the agent's replies, as in the chat.
               AIConversationView(conversation: transcript)
               HStack(spacing: 24) {
                   Button {
                       Task { await voice.setMuted(!voice.isMuted) }
                   } label: {
                       Image(systemName: voice.isMuted ? "mic.slash.fill" : "mic.fill")
                   }
                   // Leaves the call and ends the session this device started.
                   Button(role: .destructive) {
                       Task { await voice.leave() }
                   } label: {
                       Image(systemName: "phone.down.fill")
                   }
               }
               .font(.title2)
               .buttonStyle(.bordered)
           }
           // Joins the call from this device, with the microphone on and the agent on the speaker.
           .task { await voice.join() }
       }
   }

   /// What the agent is doing, as the glow shows it.
   struct AgentStatus: View {
       @ObservedObject var voice: VoiceSession
       // The call's participants, and which of them is speaking.
       @ObservedObject var call: CallState

       var body: some View {
           AIVoiceGlowView(state: state)
               .frame(width: 240, height: 240)
       }

       private var state: AIVoiceAgentState {
           // Speaking comes from the call: a reply is written before it is spoken,
           // and is done before the agent stops talking.
           if call.remoteParticipants.contains(where: \.isSpeaking) { return .speaking }
           if call.localParticipant?.isSpeaking == true { return .listening }
           switch voice.session.state {
           case .listening: return .listening
           case .responding, .working: return .thinking
           case .idle, .ended: return .idle
           }
       }
   }
   ```

   `VoiceSession` joins the call with Stream Video, signed in as the user you passed to `setUser`. `join()` doesn't throw. If joining fails, for example because the user denied microphone access, the error is in `voice.failure`.
   `voice.session` is a regular `AgentSession`, so the `AgentTransport` from step 4 works here too, and the agent's replies stream into the transcript as they're generated.
   You don't need to add StreamVideo to your package: it comes with StreamAgents.

   `AIVoiceGlowView` is one of StreamChatAI's [AI components](https://getstream.io/chat/docs/sdk/ios/ai-components/). For a waveform instead of a glow, use `AIVoiceWaveView`, which takes the same states.
   Add a microphone [usage description](https://getstream.io/chat/docs/sdk/ios/ai-components/composer/#usage-descriptions) to your `Info.plist`. Without it, iOS closes the app when it asks for the microphone.

   An agent made from the text template talks with Stream's default speech-to-text and text-to-speech models. Make it with `--template voice` to choose those models and add a greeting.
   To switch an open chat to voice, call `session.startVoice()`, then join with `VoiceSession.attach(agents:sessionID:)`. The voice mode of the [Swift assistant](https://github.com/GetStream/Vision-Agents/tree/accelerate/examples/voice_agents/swift_assistant) sample does this.

## Going further

- [Sessions API](https://getstream.io/agents/docs/sessions/): Create, search, fork and close the conversations your agent holds.
- [Tool calling and dispatch](https://getstream.io/agents/docs/tool-calling-and-dispatch/): Your own functions, run in your process.
- [Generative UI](https://getstream.io/agents/docs/generative-ui/): Let the agent answer with UI that your app renders in place of text.
- [Purchase a phone number](https://getstream.io/agents/docs/telephony/purchase-number/): Buy a number from Stream, in the Dashboard or through the API.

---

For the most recent version of this documentation, visit [https://getstream.io/agents/docs/quickstart/](https://getstream.io/agents/docs/quickstart/).