Sessions API

Sessions store a conversation with AI. Every frontier model handles the storage of messages differently. That's why we standardize message persistence and make it easy for you to change the underlying model.

Sessions have a title, description and custom fields. You can do the following with sessions:

Let's get started and create your first session:

JavaScript
import { Client } from "@stream-io/stream-agents";

const client = new Client({ apiKey: "your_api_key" });
await client.setUser("jlahey", async () => {
  // Your server mints it: https://getstream.io/agents/docs/auth/#generating-a-token
  const response = await fetch("/api/agent-token");
  if (!response.ok) throw new Error(`token: ${response.status}`);
  return response.text();
});
const agent = client.agent("my-agent");

const session = await agent.sessions.create({ custom: { ticket: "4721" } });
Kotlin
val agents = StreamAgents(apiKey = "your_api_key")
agents.setUser(User(id = "jlahey")) { yourBackend.agentToken() }
val agent = agents.agent("my-agent")

val session = agent.chat()
Swift
let agents = StreamAgents(apiKey: "your_api_key")
agents.setUser(User(id: "jlahey")) { try await yourBackend.agentToken() }
let agent = agents.agent("my-agent")

let session = try await agent.sessions.create()
await session.start()
Dart
final agents = VisionAgents(
  apiKey: 'your_api_key',
  userId: 'jlahey',
  token: () => http.read(Uri.parse('https://your-app.example.com/api/agent-token')),
);
final agent = agents.agent('my-agent');

final session = await agent.chat();

Your server authenticates with STREAM_API_KEY and STREAM_API_SECRET; a client needs the API key, the user's id and a token for them. Auth explains how this works in detail.

Now that you have a session open, you can use responses.create to interact with the agent.

JavaScript
await session.responses.create("Where is order 1042?");

for await (const event of session.events()) {
  if (event.kind === "response_delta") console.log(event.text);
  if (event.kind === "responded") break;
}
Kotlin
val answer = launch {
    session.events()
        .takeWhile { it.type != "responded" }
        .collect { if (it.type == "response_delta") print(it.text) }
}
session.responses.create("Where is order 1042?")
answer.join()
Swift
let events = session.events()
_ = try await session.responses.create("Where is order 1042?")

for await event in events {
    if event.kind == .responseDelta { print(event.text, terminator: "") }
    if event.kind == .responded { break }
}
Dart
final events = session.events();
await session.responses.create('Where is order 1042?');

await for (final event in events) {
  if (event.kind == AgentEventKind.responseDelta) stdout.write(event.text);
  if (event.kind == AgentEventKind.responded) break;
}

A session keeps its conversation in a Stream Chat channel, which session.chat() opens. The channel is a transcript, for text and voice sessions alike.

Warning:

The agent does not answer a message sent to the channel. Write to the agent with responses.create, which also stores your message in the channel.

Not documented for Flutter yet. Showing JavaScript.

JavaScript

session.chat() returns the StreamChat client and the channel. Import @stream-io/stream-agents/chat once first, or it throws. It opens on the StreamChat you handed over with client.use, or else connects one as the user setUser named. Call channel.watch() before you show it: it loads the latest messages and keeps them up to date, and stream-chat-react's components don't load a channel for you.

Kotlin

session.chat(context) returns a ChannelClient for the channel. It opens on the ChatClient you handed over with agents.use(client), or else connects one as the user setUser named.

Swift

session.chat() returns a ChatChannelController for the channel. It opens on the ChatClient you handed over with agents.use(_:), or else connects one as the user setUser named.

If you've used OpenAI before the responses.create API will seem familiar. Creating responses API explains how to interact with the agent.

A few fields on the session are updated automatically:

  • Title
  • Description
  • Usage

Creating or updating a session

sessions.create creates a conversation

JavaScript
const session = await agent.sessions.create({
  title: "Order 1042",
  custom: { ticket: "4721" },
});
Kotlin
val session = agent.chat(
    SessionOptions(
        title = "Order 1042",
        custom = buildJsonObject { put("ticket", "4721") },
    ),
)
Swift
var options = SessionOptions()
options.title = "Order 1042"
options.custom = ["ticket": "4721"]
let session = try await agent.sessions.create(options)
await session.start()
Dart
final session = await agent.chat(const SessionOptions(
  title: 'Order 1042',
  custom: {'ticket': '4721'},
));

Passing your own id gives you control over uniqueness. This is convenient when you want to create a session for an object in your database. For instance opening a session for a specific order in your shop etc.

JavaScript
const session = await agent.sessions.create({
  id: "order-1042",
  title: "Order 1042",
});
Kotlin
val session = agent.chat(SessionOptions(id = "order-1042", title = "Order 1042"))
Swift
var options = SessionOptions()
options.id = "order-1042"
options.title = "Order 1042"
let session = try await agent.sessions.create(options)
await session.start()
Dart
final session = await agent.chat(const SessionOptions(id: 'order-1042', title: 'Order 1042'));

An id is any string of up to 64 letters, digits, - and _. A UUID works too, and is stored lowercase. Creating a session with an id that is already taken is refused with a 409.

Everything is optional:

Option What it does
id The id to hold the session by, such as your own record's id. Left out, the router generates a UUIDv7
title, description What a person finds the conversation by later. Both are searched
project_id Groups conversations, and is recorded as the project cost tag
custom Your own labels, handed back untouched
tags Cost tags for this conversation, merged with the agent's own tags. Not in the Python, PHP or C# SDKs
model_overwrites Change the models for this conversation, such as { llm: "llm-thinking", thinking: "high" }
incognito Keep nothing: no Stream Chat channel, no session row, no turns. It cannot be listed, searched or forked afterwards
start_voice Start voice as the session opens. Left out, the conversation is held in writing until voice is started
agent, config_id Which agent config runs it, by name or by id. Left out, the agent the SDK was opened with
timezone The person's IANA zone, such as Europe/Lisbon, so the agent knows their date and time
memory What the session remembers about the user. A client can only name its own user

Your server can also send instructions, history, call, team, floor_control and resume_history. A client that sends any of them is refused with a 403.

Models, cost tags and incognito

It's common to override the thinking level, cost tracking or incognito mode.

model_overwrites changes the models for one conversation, tags adds cost tags to it. incognito mode disables permanent storage.

JavaScript
const session = await agent.sessions.create({
  modelOverwrites: { llm: "llm-thinking", thinking: "high" },
  tags: { customer_id: "123", plan: "pro" },
  incognito: true,
});
Kotlin
val session = agent.chat(
    SessionOptions(
        modelOverwrites = ModelOverwrites(llm = "llm-thinking", thinking = ModelOverwrites.Thinking.High),
        tags = mapOf("customer_id" to "123", "plan" to "pro"),
        incognito = true,
    ),
)
Swift
var options = SessionOptions()
options.modelOverwrites = ModelOverwrites(llm: "llm-thinking", thinking: .high)
options.tags = ["customer_id": "123", "plan": "pro"]
options.incognito = true
let session = try await agent.sessions.create(options)
await session.start()
Dart
final session = await agent.chat(const SessionOptions(
  modelOverwrites: ModelOverwrites(llm: 'llm-thinking', thinking: Thinking.high),
  tags: {'customer_id': '123', 'plan': 'pro'},
  incognito: true,
));

model_overwrites takes llm, stt, tts, sts, search, thinking, temperature, max_output_tokens and verbosity. thinking is how hard the model reasons before answering: none, minimal, low, medium or high. A model that does not reason ignores it.

tags are cost labels. This helps you easily see costs by user, use case, or any other tag you provide.

Update a running session

update changes a session's title, description, custom data, pin, models or voice. New models take over from the next turn, and one that doesn't route is refused. A client can only change the title, description and custom data, and pin or unpin the session:

JavaScript
const updated = await session.update({ title: "Pricing" });

// From your backend, the models too:
// await session.update({ llm: "llm-thinking", thinking: "high" });
Kotlin
val updated = session.update(title = "Pricing")
Swift
let updated = try await session.update(title: "Pricing")
Dart
final updated = await session.update(title: 'Pricing');

Fields you leave out stay as they are. custom replaces the labels whole, and {} clears them. pinned set to true pins the session and false unpins it, see Pinning a session. Setting sts switches the session to speech-to-speech, and an empty sts switches it back. A session that has ended can still be renamed, relabelled, pinned and unpinned, but changing its models or voice is refused with a 400, and a client asking for models or voice gets a 403. A session you only have the id of is changed with sessions.update(id, ...), from a client too.

Title, description and review

The router writes some fields for you:

Field Written By
title, description After the first answer, then after every third answer A fast model reading the start of the conversation
review_score, review_notes A few seconds after the session ends A quality model reading the whole conversation
usage When the session ends The router, from every model it called

The title is up to eight words, such as "Add push notifications to an Android app", and the description is one sentence, both in the user's language. A title passed to create is replaced after the first answer. Setting a title or description with update stops the router renaming a running session, but a session reopened after it ended is renamed again as it goes on. A session reopened and ended again is reviewed again.

An incognito session gets none of these, and a session nobody spoke in gets no review. Read them with Getting a session. The review and usage are only given to your server.

Delete or close a session

close ends the conversation and keeps it. delete removes the session and what it recorded.

JavaScript
await session.close();
await session.delete();
// A session you only have the id of: agent.sessions.close(id)
Kotlin
session.close()
session.delete()
// A session you only have the id of: sessions.close(id)
Swift
await session.close()
try await session.delete()
// A session you only have the id of: agents.close(sessionID:)
Dart
await session.close();
await session.delete();
// A session you only have the id of: sessions.stop(id)

close ends the conversation: with voice on, the agent leaves the call. It keeps what the session recorded and remembered. A session you only have the id of is closed by its id, as the JavaScript, Kotlin, Swift and Dart samples show. Closing by id a session that is no longer running is answered with a 404 session_not_found.

Warning:

A text session kept in Stream Chat ends 5 minutes after the last client watching it disconnects. A page that reloads within that time resumes the same running session; after it, resuming by the session id reopens it with everything that was said.

delete stops the session if it is running, and deletes its scheduled tasks too. A session you only hold the id of is deleted with sessions.delete(id).

Warning:

delete also deletes the session's turns and what it remembered. The user's other memories are kept, and so is the Stream Chat channel holding the transcript, which you delete with Stream Chat. If what it remembered can't be deleted, the answer is a 503 memory_unavailable and the session is kept, stopped. To keep the session and forget only what it remembered, see Memory and persistence.

Resume a session

A text conversation goes on by the id of the session it was held in. Resuming it starts watching it again, and one that ended is reopened with everything that was said. Keep the session id, for example in the page's URL or your database, to come back to it after a reload:

JavaScript
const session = await agent.sessions.resume(sessionId);
// A page's own tools come back with it: agent.sessions.resume(sessionId, { tools })
await session.responses.create("Where were we?");
Kotlin
val session = agents.attach(sessionId)
session.responses.create("Where were we?")
Swift
let session = try await agents.attach(sessionID: sessionId)
_ = try await session.responses.create("Where were we?")
Dart
final session = await agents.attach(sessionId);
await session.responses.create('Where were we?');

The PHP SDK has no resume on an agent's sessions: an Agent you define in code resumes with $agent->resume($id).

A client can resume only the sessions of the user it signed in as. Asking a session something by its id with agent.sessions.responses(id).create(text) reopens it too, without watching it.

Voice

voice.start() starts the realtime communication and voice.stop() stops it. The agent talks on a call of the agent call type, which is built into every Stream app.

JavaScript
// npm install @stream-io/video-client (React: @stream-io/video-react-sdk, see below)
import { VoiceSession } from "@stream-io/stream-agents/video";

await session.voice.start();

// Joins the call "agent:" + session.id from this device, with the microphone on,
// as the user setUser named.
const voice = VoiceSession.of(session);
await voice.join();

// Without a UI framework, play the agent with one <audio> element per remote participant.
// In React, render <ParticipantsAudio /> instead: see Voice UI below.
const call = voice.call;
const playing = new Set<string>();
call?.state.remoteParticipants$.subscribe((participants) => {
  for (const { sessionId } of participants) {
    if (playing.has(sessionId)) continue;
    playing.add(sessionId);
    const audio = document.body.appendChild(document.createElement("audio"));
    call.bindAudioElement(audio, sessionId);
  }
});

// When you are done talking: this device leaves the call, and the conversation
// carries on in writing.
await voice.leave();
await session.voice.stop();
Kotlin
session.startVoice()

// Joins the call "agent:" + session.id with Stream Video, as the user setUser named.
val voice = VoiceSession.attach(context, agents, session.id)
voice.join()

// When you are done talking. Leaving a call you attached to keeps the session.
voice.leave()
session.stopVoice()
Swift
try await session.startVoice()

// Joins the call "agent:" + session.id with Stream Video, as the user setUser named.
let voice = try await VoiceSession.attach(agents: agents, sessionID: session.id)
await voice.join()

// When you are done talking. Leaving a call you attached to keeps the session.
await voice.leave()
try await session.stopVoice()
Dart
await session.startVoice();

// Joins the call "agent:" + session.id with Stream Video, as the user VisionAgents was given.
final voice = await VoiceSession.attach(agents, session.id);
await voice.join();

// When you are done talking. Leaving a call you attached to keeps the session.
await voice.leave();
await session.stopVoice();

VoiceSession is this device on the session's call. A session created with start_voice: true has voice on from the start.

Not documented for Flutter yet. Showing JavaScript.

JavaScript

Import VoiceSession from @stream-io/stream-agents/video.

  • Joining:
    • VoiceSession.start(agent) opens a session with voice on.
    • VoiceSession.of(session) joins one whose voice is already on.
    • VoiceSession.attach(client, sessionId) does the same from the session's id alone, with a watcher of its own that runs no tools.
    • join() turns the microphone on and joins, and rejects if it can't, for example when the user denies the microphone.
    • join({ camera: true }) also turns the camera on, on the back lens where there is one.
  • During the call: setMuted and setCameraEnabled.
  • Leaving: leave() leaves the call, and closes the session only if start opened it; an attached one keeps running. end() always closes it.
  • State: voice.get() returns the call, the client, muted, cameraEnabled and a status, and voice.subscribe reports changes.

A session whose voice is on can be joined from its id alone, such as one your server started for this user. Turn the camera on to let the agent look through it:

import { VoiceSession } from "@stream-io/stream-agents/video";

// `client` holds your apiKey and a user from setUser.
const voice = await VoiceSession.attach(client, sessionId);
await voice.join({ camera: true });

// Later: stop sharing the camera, and keep talking.
await voice.setCameraEnabled(false);

// Leaving an attached session keeps it running.
await voice.leave();

The attached watcher runs no tools. Attach to a session whose tools run on your server, as tools a server declared always do. A session another page opened with browser tools keeps them in that page: join its call from that page with VoiceSession.of(session) instead, so the tool calls reach the page that runs them.

The agent looks through the camera when it's asked about what it sees, if its config has the vision skill and a vision subagent. The SDK answers its requests for frames from the browser, with nothing else to wire up.

Joining needs a client made with your apiKey and a user from setUser, as in the quickstart. To join the call yourself instead, session.video() returns { client, call }, typed loosely because @stream-io/video-client is an optional peer.

To use the StreamChat and StreamVideoClient your app already holds, hand them over first with client.use({ chat: chatClient, video: videoClient }), so the session does not open second connections. In a React app, install @stream-io/video-react-sdk instead of @stream-io/video-client: it brings its own video client, and installing both risks two copies of it.

Kotlin

VoiceSession joins the call with Stream Video, signed in as the user you passed to setUser.

  • Joining:
    • VoiceSession.start(context, agents, options) opens a session with voice on.
    • VoiceSession.attach(context, agents, sessionId) joins one whose voice is already on.
    • join() turns the microphone on, joins, and plays the agent on the speaker. It doesn't throw: if joining fails, the error is in voice.failure. Ask for RECORD_AUDIO before you call it.
    • join(camera = true) also turns the camera on, on the back lens. It needs CAMERA too.
  • During the call: setMuted(muted) and setCameraEnabled(enabled).
  • Leaving: leave() leaves the call, and closes the session only if start opened it. end() always closes it.
  • State: call, isMuted, isCameraEnabled and failure are StateFlows. session is the conversation.

To use the ChatClient and StreamVideo your app already holds, hand them over first with agents.use(client) and agents.use(video), so the session does not open second connections. Stream Video keeps one client per process: one of your own that is signed in as another user makes joining fail.

Swift

VoiceSession joins the call with Stream Video, signed in as the user you passed to setUser.

  • Joining:
    • VoiceSession.start(agents:agent:) opens a session with voice on.
    • VoiceSession.attach(agents:sessionID:) joins one whose voice is already on.
    • join() turns the microphone on, joins, and plays the agent on the speaker. It doesn't throw: if joining fails, for example when the user denies the microphone, the error is in voice.failure.
    • join(camera: true) also turns the camera on, on the back lens where there is one.
  • During the call: setMuted(_:) and setCameraEnabled(_:).
  • Leaving: leave() leaves the call, and closes the session only if start opened it. end() always closes it.
  • State: VoiceSession is an ObservableObject that publishes call, isMuted, isCameraEnabled and failure. session is the conversation.

To use the ChatClient and StreamVideo your app already holds, hand them over first with agents.use(_:), so the session does not open second connections. An app with a StreamVideo of its own must hand it over: another one would become the instance Stream's call views and CallKit read. The quickstart builds a voice UI on VoiceSession.

A phone call is a session too. Each person who rings one of your numbers gets a new session. See accepting calls and making calls.

Voice UI: transcript, captions and status

A voice UI is three pieces: the agent's audio, the transcript with live captions, and a status that says whether the agent is listening, thinking or speaking.

The agent's status is one of these, the states the glow and wave views take:

State When
connecting Until this device has joined.
speaking While the call hears the agent.
listening While the caller is heard or transcribed.
thinking While the agent answers or its skills work, and until its reply is heard.
idle Otherwise, and after leave().

Speaking comes from the call and not from the events. spoke fires when a piece of the reply has been synthesized and sent to the call, not when its audio starts, and responded means the model finished, not that the reply was spoken.

Without the call, the session's state says what the agent is doing on any session, typed or spoken (session.state in JavaScript, Swift and Dart, session.conversation.value.state in Kotlin): idle, listening, responding, working (with the skills at work) or ended. The state follows the session's socket beside session.events(), so a loop reading events elsewhere is unaffected.

For latency timings, read session.events(): turn carries the turn's timings and spoke its time_to_first_byte_ms.

Not documented for Flutter yet. Showing React.

React
  • Audio: VoiceSession joins the call. Play the agent with <ParticipantsAudio /> from the Video React SDK, inside StreamVideo and StreamCall with the client and call from voice.get().
  • Transcript and captions: show session.chat()'s channel with AIConversation from stream-chat-react/ai-components, its messages turned into the conversation's with fromStreamMessage. What the user says shows as their turns, and the agent's replies stream in with markdown, with their reasoning and tool calls.
  • Status: voice.get().status is what the agent is doing, in the states VoiceGlow and VoiceWave from stream-chat-react/ai-components take. Read session.events() for latency timings, not for the status or the transcript.

Create the session from a user action such as a Start button, not at module scope: module-scope setup runs during server rendering and again on every hot reload, and asks for the microphone as soon as the page loads.

AgentVoice.tsx
"use client"; // Next.js App Router: these hooks run in the browser

import "@stream-io/stream-agents/chat";
import type { AgentHandle } from "@stream-io/stream-agents";
import { VoiceSession } from "@stream-io/stream-agents/video";
import {
  ParticipantsAudio,
  StreamCall,
  StreamVideo,
  useCallStateHooks,
} from "@stream-io/video-react-sdk";
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
import type {
  Channel as ChatChannel,
  MessagePaginatorState,
  StreamChat,
} from "stream-chat";
import { Chat, useStateStore } from "stream-chat-react";
import {
  AIConversation,
  fromStreamMessage,
  VoiceGlow,
} from "stream-chat-react/ai-components";
import "stream-chat-react/dist/css/index.css";
import "stream-chat-react/dist/css/ai-components.css";

// `agent` comes from a Client with your apiKey and a user from setUser, as in the quickstart.
const start = async (agent: AgentHandle) => {
  // The agent joins the session's call right away.
  const voice = await VoiceSession.start(agent);
  const { client, channel } = await voice.session.chat();
  const chatChannel = channel as ChatChannel;
  // Loads the transcript so far, and keeps it up to date as the call goes on.
  await chatChannel.watch();
  return { voice, chatClient: client as StreamChat, channel: chatChannel };
};

const itemsSelector = (state: MessagePaginatorState) => ({
  items: state.items,
});

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

const Conversation = ({
  voice,
  chatClient,
  channel,
}: Awaited<ReturnType<typeof start>>) => {
  const { call, client, failure, status } = useSyncExternalStore(
    voice.subscribe,
    voice.get,
  );

  // The transcript: the channel's messages, oldest first, as the AI components show them.
  const { items } = useStateStore(
    channel.messagePaginator.state,
    itemsSelector,
  );
  const messages = useMemo(
    () => (items ?? []).map((message) => fromStreamMessage(message)),
    [items],
  );

  // Joins from this device, with the microphone on. Run twice, as in Strict Mode, it joins once.
  // A failed join, such as a denied microphone, lands in `failure`, shown below.
  useEffect(() => {
    voice.join().catch(() => undefined);
  }, [voice]);

  return (
    <>
      <VoiceGlow state={status} />
      {failure && <p role="alert">{failure.message}</p>}
      {call && client && (
        <StreamVideo client={client}>
          <StreamCall call={call}>
            <AgentAudio />
          </StreamCall>
        </StreamVideo>
      )}
      {/* No composer while voice is on: a typed turn on a call is not marked finished, and
          the next responses.create is refused. The transcript scrolls inside itself, so it
          needs a height. */}
      <Chat client={chatClient}>
        <div style={{ height: 360 }}>
          <AIConversation messages={messages} />
        </div>
      </Chat>
      {/* Leaves the call and ends the session start() opened. */}
      <button onClick={() => void voice.leave()}>Hang up</button>
    </>
  );
};

export const AgentVoice = ({ agent }: { agent: AgentHandle }) => {
  const [started, setStarted] = useState<Awaited<ReturnType<typeof start>>>();

  if (!started) {
    return (
      <button onClick={async () => setStarted(await start(agent))}>
        Start
      </button>
    );
  }
  return <Conversation {...started} />;
};

AIConversation leaves out a reply with nothing to show, such as one the caller cut off and whose text was cleared. To style spoken turns or interrupted replies differently, pass your own UserMessage and AssistantMessage to AIConversation, and read those custom fields from the channel's message with the same id; the conversation view guide shows how. To let the caller cut the agent off, call session.interrupt(). When you leave, call voice.leave(), which ends the session start() opened. To keep the conversation going in writing instead, join with VoiceSession.of(session) and call session.voice.stop() after leave().

session.state has the same get and subscribe as voice. Read latency timings from event.frame, and each loop over session.events() is told everything from when it started. Start one in an effect and stop it in the effect's cleanup, so an effect that runs twice, as in Strict Mode, leaves one loop running.

The call stays up when the agent fails, so say so under the glow. session.conversation has the same get and subscribe, and its failure comes with failureContext, the part that failed (stt for speech recognition, llm, tts), until the agent next hears, answers or speaks. A tool that asks first waits in its approvals: show it over the call with AIToolApprovalCard and answer with session.decide(id, { allowed }). The voice mode of the React assistant sample does both.

Both work on any session, with or without a call:

AgentActivity.tsx
"use client"; // Next.js App Router: these hooks run in the browser

import type { Session, ToolApprovalRequest } from "@stream-io/stream-agents";
import { useSyncExternalStore } from "react";

// The approval's title with each {argument} filled in from the call.
const question = ({ approval, arguments: args }: ToolApprovalRequest) => {
  const values = JSON.parse(args || "{}") as Record<string, unknown>;
  return approval.title.replace(/\{(\w+)\}/g, (_, name: string) =>
    String(values[name] ?? ""),
  );
};

export const AgentActivity = ({ session }: { session: Session }) => {
  // idle, listening, responding, working (with the skills at work) or ended.
  const state = useSyncExternalStore(
    session.state.subscribe,
    session.state.get,
  );
  const { turns, failure, failureContext, approvals } = useSyncExternalStore(
    session.conversation.subscribe,
    session.conversation.get,
  );

  return (
    <>
      <p>
        {state.kind === "working"
          ? `Working: ${state.skills.join(", ")}`
          : state.kind}
      </p>
      {failure && (
        <p role="alert">
          The agent failed ({failureContext}): {failure}
        </p>
      )}
      {approvals.map((request) => (
        <p key={request.id}>
          {question(request)}
          <button onClick={() => session.decide(request.id, { allowed: true })}>
            Allow
          </button>
          <button
            onClick={() => session.decide(request.id, { allowed: false })}
          >
            Not now
          </button>
        </p>
      ))}
      <ol>
        {turns.map((turn) => (
          <li key={turn.id}>
            {turn.speaker === "agent" ? "Agent" : "You"}: {turn.text}
          </li>
        ))}
      </ol>
    </>
  );
};

Each snapshot keeps its identity until something changes, so a component re-renders only when the state does.

Compose
  • Audio: VoiceSession joins the call and plays the agent on the speaker, so it needs no view of yours.
  • Transcript and captions: show the session's channel with AIConversation from stream-chat-android-ai-compose, through the AgentTransport of the quickstart's chat UI. It streams the agent's replies in with markdown and shows the agent thinking.
  • Status: AIVoiceGlow and AIVoiceWave from stream-chat-android-ai-compose take an AIVoiceAgentState. Work it out from the call, which says who is speaking, and from the conversation's state. Read session.events() for latency timings, not for the status or the transcript.
VoiceScreen.kt
import android.Manifest
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.getstream.chat.android.ai.compose.conversation.AIConversationState
import io.getstream.chat.android.ai.compose.ui.component.AIConversation
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.StreamAgents
import io.getstream.agents.android.core.VoiceSession
import io.getstream.agents.android.core.channelState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch

// `agents` has your apiKey and a user from setUser, and AgentTransport and AgentMessage are the
// ones from the quickstart's chat UI.
@Composable
fun VoiceScreen(agents: StreamAgents) {
    val context = LocalContext.current
    val scope = rememberCoroutineScope()
    var voice by remember { mutableStateOf<VoiceSession?>(null) }
    // The call needs the microphone: the agent starts only once the user allows it.
    val microphone = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
        if (granted) {
            scope.launch {
                // The agent joins the session's call right away.
                voice = VoiceSession.start(context, agents, SessionOptions(agent = "my-agent"))
            }
        }
    }

    val started = voice
    if (started != null) {
        VoiceConversation(started)
    } else {
        Button(onClick = { microphone.launch(Manifest.permission.RECORD_AUDIO) }) { Text("Start") }
    }
}

@Composable
fun VoiceConversation(voice: VoiceSession) {
    val context = LocalContext.current
    val scope = rememberCoroutineScope()
    val conversation by voice.session.conversation.collectAsStateWithLifecycle()
    val failure by voice.failure.collectAsStateWithLifecycle()
    // The transcript is the session's channel, as the quickstart's chat UI shows it.
    val transcript by produceState<AIConversationState<AgentMessage>?>(null, voice) {
        val channel = voice.session.channelState(context, this)
        value = AIConversationState(AgentTransport(voice.session, channel, this), this)
    }

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

    Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) {
        VoiceStatus(voice, conversation.state)
        // Why joining failed, such as the microphone being taken away in Settings.
        failure?.let { Text("The call couldn't start: ${it.message}") }
        // The transcript, with each reply growing as it is generated.
        transcript?.let { AIConversation(state = it, modifier = Modifier.weight(1f)) }
        // Leaves the call and ends the session start() opened.
        Button(onClick = { scope.launch { voice.leave() } }) { Text("Hang up") }
    }
}
/** What the agent is doing, in the states the glow takes. */
@Composable
fun VoiceStatus(voice: VoiceSession, session: Conversation.State) {
    val call by voice.call.collectAsStateWithLifecycle()
    // The call's participants that are speaking, this device's user included.
    val speakers by remember(call) { call?.state?.activeSpeakers ?: MutableStateFlow(emptyList()) }
        .collectAsStateWithLifecycle()

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

To let the caller cut the agent off, call session.interrupt(). voice.leave() ends the session start opened. To keep the conversation going in writing instead, join with VoiceSession.attach(context, agents, sessionId) and call session.stopVoice() after leave().

AgentSession exposes conversation as a StateFlow, so a composable collecting it redraws as its state changes. Read latency timings from event.fields.

SwiftUI
  • Audio: VoiceSession joins the call and plays the agent on the speaker, so it needs no view of yours.
  • Transcript and captions: show session.chat()'s channel with AIConversationView from StreamChatAI, through the AgentTransport of the quickstart's chat UI. It streams the agent's replies in with markdown and shows the agent thinking.
  • Status: AIVoiceGlowView and AIVoiceWaveView from StreamChatAI take an AIVoiceAgentState. Work it out from the call, which says who is speaking, and from session.state. Read session.events() for latency timings, not for the status or the transcript.
VoiceScreen.swift
import StreamChatAI
import StreamVideo
import SwiftUI
import StreamAgents

// `agents` has your apiKey and a user from setUser, and `AgentTransport` and `AgentMessage`
// are the chat UI's, as in the quickstart.
struct VoiceScreen: View {
    let agents: StreamAgents
    @State private var started: (voice: VoiceSession, transcript: AgentTransport)?

    var body: some View {
        if let started {
            VoiceConversation(voice: started.voice, transcript: started.transcript)
        } else {
            Button("Start") {
                Task { started = try? await start() }
            }
        }
    }

    private func start() 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.
        await voice.session.start()
        return (voice, try await AgentTransport(voice.session))
    }
}

struct VoiceConversation: 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 {
                VoiceStatus(voice: voice, call: call.state)
            } else {
                AIVoiceGlowView(state: .connecting)
                    .frame(width: 240, height: 240)
            }
            // The transcript, with each reply streaming in as it is generated.
            // No composer while voice is on: a typed turn on a call is not marked
            // finished, and the next responses.create is refused.
            AIConversationView(conversation: transcript)
            // Leaves the call and ends the session start() opened.
            Button("Hang up", role: .destructive) {
                Task { await voice.leave() }
            }
        }
        // Joins from this device, with the microphone on and the agent on the speaker.
        .task { await voice.join() }
    }
}

/// What the agent is doing, in the states the glow takes.
struct VoiceStatus: 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.
        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
        }
    }
}

AIConversationView leaves out a reply with nothing to show, such as one the caller cut off whose text was cleared. To style spoken turns, interrupted replies or a reply still being generated, read those custom fields from the message's extraData in your AIMessage, as AgentMessage reads generating, and pass AIConversationView a view factory. To let the caller cut the agent off, call session.interrupt(). voice.leave() ends the session start opened. To keep the conversation going in writing instead, join with VoiceSession.attach(agents:sessionID:) and call session.stopVoice() after leave(). Add a microphone usage description to your Info.plist.

AgentSession is an ObservableObject, so a view observing the session redraws as its state changes. Read latency timings from event.fields.

Listing and searching sessions

sessions.query lists an agent's sessions, including ended ones. It takes a filter that narrows the list, a sort that orders it, a limit and a cursor. sessions.search finds sessions by words, and sessions.get reads one session by id.

Getting a session

sessions.get reads one session, running or ended. Once it has ended, your server is also told how it went: a review and what it cost.

Node.js
const session = await agent.sessions.get(id);
console.log(session.title, session.state, session.closed_at);
// On your server, an ended session carries its review and usage.
console.log(session.review_score, session.usage?.cost_micros);
Python
session = await agent.sessions.get(session_id)
print(session.state, session.modality, session.closed_at)
if session.usage:
    print(session.usage.input_tokens, session.usage.output_tokens, session.usage.cost_micros)
Ruby
session = agent.sessions.get(id)
p session.values_at("state", "modality", "closed_at")
usage = session["usage"]
p usage.values_at("input_tokens", "output_tokens", "cost_micros") if usage
PHP
$session = $agent->sessions->get($id)->created;
var_dump($session->state, $session->modality, $session->closedAt);
if ($session->usage !== null) {
    echo $session->usage->inputTokens, ' ', $session->usage->outputTokens, ' ', $session->usage->costMicros, PHP_EOL;
}
Go
session, err := agent.Sessions.Get(ctx, id)
if err != nil {
	log.Fatal(err)
}
fmt.Println(session.State, session.Modality, session.ClosedAt)
if session.Usage != nil {
	fmt.Println(session.Usage.InputTokens, session.Usage.OutputTokens, session.Usage.CostMicros)
}
.NET
var session = (await agent.Sessions.GetAsync(id)).Created;
Console.WriteLine($"{session.State} {session.Modality} {session.ClosedAt}");
if (session.Usage is { } usage)
{
    Console.WriteLine($"{usage.InputTokens} {usage.OutputTokens} {usage.CostMicros}");
}
Rust
let session = agent.sessions.get(id).await?;
println!("{} {} {:?}", session.state, session.modality, session.closed_at);
if let Some(usage) = &session.usage {
    println!("{} {} {}", usage.input_tokens, usage.output_tokens, usage.cost_micros);
}

A client reads the same session without the review and usage:

JavaScript
const session = await agent.sessions.get(id);
console.log(session.title, session.state, session.closed_at);
// The review and usage are only given to your server.
Kotlin
val session = agent.sessions.get(id)
println("${session.title} ${session.state} ${session.modality}")
// The review and usage are only given to your backend.
Swift
let session = try await agent.sessions.get(id)
print(session.title, session.state, session.modality)
// The review and usage are only given to your backend.
Dart
final session = await agent.sessions.get(id);
print('${session.title} ${session.state.name} ${session.modality.name}');
// The review and usage are only given to your backend.
Field What it says
title, description, custom, project_id Your labels, or the ones the router wrote for you
state live while the agent holds it, ended after
team The team its usage is counted under, from the token, the session or the agent's team tag
modality text, voice, or video once the agent has seen the user's video
llm, stt, tts, voice The models and voice routing picked, while it is running
sts, model_overwrites The speech-to-speech model holding a native conversation, and the overrides it was opened with
agent, config_id The agent name it was opened against, and the agent config it ran under
conversation_id The Stream Chat channel the conversation is kept in. Absent for an incognito session
forked_from The session it was forked from, empty for one opened fresh
created_at, last_response_at, closed_at When it began, last answered and ended
pinned_at When it was pinned. Absent for a session that is not pinned
end_reason, end_fault, end_stage Why it ended, whose doing that was, and how far it had got. Absent while it is running
review_score, review_notes How well the agent handled it, from 1 to 5, and why
summary What a model made of the conversation, written with the review
usage input_tokens (with cached_input_tokens), output_tokens, requests and cost_micros, in millionths of a dollar, over every model it called, typed and spoken
instructions, call_outcome What the agent was told, and how a call the router placed for it ended

usage is filled in once the session has ended, and the review and summary a few seconds later. A session nobody spoke in has no review. review_score, review_notes, summary, usage, instructions and call_outcome are only given to your server, so a client asks your server for them.

Querying

sessions.query takes four things, all optional:

  • filter: which sessions to list.
  • sort: the order to list them in.
  • limit: how many per page, 25 by default and up to 200.
  • cursor: where the next page starts. See Pagination.

With none of them, it lists every session of the agent, the most recently answered first. A client only ever gets its signed-in user's own sessions, whatever the filter says.

For example, the sessions of the support project:

JavaScript
const page = await agent.sessions.query({
  filter: { project_id: "support" },
  limit: 20,
});
const sessions = page.items;
Kotlin
val page = agent.sessions.query(
    filter = SessionFilter.ProjectId("support"),
    limit = 20,
)
val sessions = page.items
Swift
let page = try await agent.sessions.query(filter: .projectID("support"), limit: 20)
let sessions = page.items
Dart
final page = await agent.sessions.query(
  filter: {'project_id': 'support'},
  limit: 20,
);
final sessions = page.items;

The sessions query returns carry no review, summary or usage, on your server either. Read one with sessions.get for those.

Filter

A filter is an object of fields. Each field takes a value to match, written with an operator such as $eq (equals) or $in (any of a list). A plain value is short for $eq, so project_id: "support" and project_id: { "$eq": "support" } mean the same.

These are all the fields a filter takes:

Field Operators Who may use it
project_id $eq, or $in with up to 50 projects Client and server
text $q, a full-text search. See Searching Client and server
review_score $eq, $gt, $gte, $lt, $lte, with a score 1 to 5 Server only
user_id $eq, whose sessions to list Server only

There is also an agent field. The SDK sets it for you to the agent you called sessions on, so you never write it.

Several fields in one filter must all match. To combine filters in other ways, use $and (every filter matches) and $or (at least one matches). Each takes a list of 1 to 10 filters. They nest up to 3 deep, and a whole filter holds at most 30 field conditions. text and user_id go at the top of the filter or directly inside its top $and, never inside an $or.

A session nobody reviewed has no score, so it matches no review_score condition. A client that sends review_score or user_id is refused with a 400.

Most SDKs take the filter as a map with exactly these keys, so it reads the same in every language. The Kotlin and Swift SDKs have a typed SessionFilter instead, as the samples show. On the wire it is the body of POST /v1/agents/sessions/query:

{
  "filter": {
    "$or": [{ "project_id": "support" }, { "project_id": "billing" }]
  },
  "sort": [
    { "field": "pinned", "direction": -1 },
    { "field": "last_message_at", "direction": -1 }
  ],
  "limit": 20
}
Warning:

Earlier versions filtered on state, modality, created_at (created_since and created_before in the SDKs), custom, config_id, agent_id and team. These filters were removed. A request that uses one is refused with a 400 that names the field. updated_at is no longer a sort either: use last_message_at.

Sort

A sort is a list of { field, direction }. The direction is -1 for descending, which is the default, or 1 for ascending.

Sort Direction Who may use it
last_message_at Descending or ascending Client and server
pinned, then last_message_at pinned is descending only, last_message_at either way Client and server
review_score Descending or ascending Server only
relevance Descending only Client and server, for a search only

With no sort, sessions come back by last_message_at, descending: the session the agent answered most recently is first. A session the agent has not answered yet is placed by when it began.

Sorting by pinned lists the pinned sessions first, then the rest, each group by last_message_at. pinned on its own means the same as pinned followed by last_message_at.

Sorting by review_score lists the sessions nobody reviewed last, in either direction.

No other combination is offered. A sort the router does not offer is refused with a 400.

For example, a client lists its user's sessions in the support and billing projects, pinned ones first:

JavaScript
const page = await agent.sessions.query({
  filter: { $or: [{ project_id: "support" }, { project_id: "billing" }] },
  sort: [{ field: "pinned" }, { field: "last_message_at" }],
});

for (const session of page.items) {
  console.log(session.pinned_at ? "pinned" : "", session.title);
}
Kotlin
val page = agent.sessions.query(
    filter = SessionFilter.Or(
        SessionFilter.ProjectId("support"),
        SessionFilter.ProjectId("billing"),
    ),
    sort = listOf(
        SessionSort(SessionSortField.Pinned),
        SessionSort(SessionSortField.LastMessageAt),
    ),
)

for (session in page.items) {
    println("${if (session.pinnedAt != null) "pinned" else ""} ${session.title}")
}
Swift
let page = try await agent.sessions.query(
    filter: .or([.projectID("support"), .projectID("billing")]),
    sort: [.pinned, .lastMessageAt()]
)

for session in page.items {
    print(session.pinnedAt != nil ? "pinned" : "", session.title)
}
Dart
final page = await agent.sessions.query(
  filter: {
    r'$or': [
      {'project_id': 'support'},
      {'project_id': 'billing'},
    ],
  },
  sort: const [
    SessionSort(field: 'pinned'),
    SessionSort(field: 'last_message_at'),
  ],
);

for (final session in page.items) {
  print('${session.pinnedAt != null ? 'pinned' : ''} ${session.title}');
}

Your server can also filter and sort on the review. For example, the sessions the review scored 2 or lower, the lowest first. The list carries no review, so read each session for its score and notes:

Node.js
const page = await agent.sessions.query({
  filter: { review_score: { $lte: 2 } },
  sort: [{ field: "review_score", direction: 1 }],
  limit: 50,
});

for (const row of page.items) {
  const session = await agent.sessions.get(row.id);
  console.log(session.review_score, session.review_notes);
}
Python
page = await agent.sessions.query(
    filter={"review_score": {"$lte": 2}},
    sort=[{"field": "review_score", "direction": 1}],
    limit=50,
)

for row in page.items:
    session = await agent.sessions.get(row.id)
    print(session.review_score, session.review_notes)
Ruby
page = agent.sessions.query(
  filter: { review_score: { "$lte" => 2 } },
  sort: [{ field: "review_score", direction: 1 }],
  limit: 50
)

page["items"].each do |row|
  session = agent.sessions.get(row["id"])
  p session.values_at("review_score", "review_notes")
end
PHP
$page = $agent->sessions->query(
    filter: ['review_score' => ['$lte' => 2]],
    sort: [['field' => 'review_score', 'direction' => 1]],
    limit: 50,
);

foreach ($page->items as $row) {
    $session = $agent->sessions->get($row->id)->created;
    echo $session->reviewScore, ' ', $session->reviewNotes, PHP_EOL;
}
Go
page, err := agent.Sessions.Query(ctx, client.Query{
	Filter: client.Filter{"review_score": client.Filter{"$lte": 2}},
	Sort:   []client.Sort{{Field: client.SortReviewScore, Direction: 1}},
	Limit:  50,
})
if err != nil {
	log.Fatal(err)
}

for _, row := range page.Items {
	session, err := agent.Sessions.Get(ctx, row.Id)
	if err != nil {
		log.Fatal(err)
	}
	if session.ReviewScore != nil && session.ReviewNotes != nil {
		fmt.Println(*session.ReviewScore, *session.ReviewNotes)
	}
}
.NET
var page = await agent.Sessions.QueryAsync(new SessionQuery
{
    Filter = new()
    {
        ["review_score"] = new Dictionary<string, object?> { ["$lte"] = 2 },
    },
    Sort = [new SessionSort("review_score", 1)],
    Limit = 50,
});

foreach (var row in page.Items)
{
    var session = (await agent.Sessions.GetAsync(row.Id)).Created;
    Console.WriteLine($"{session.ReviewScore} {session.ReviewNotes}");
}
Rust
use serde_json::json;
use vision_agents::{types, Query};

let page = agent
    .sessions
    .query(Query {
        filter: json!({"review_score": {"$lte": 2}}),
        sort: vec![types::SessionSort {
            field: types::SessionSortField::ReviewScore,
            direction: types::SessionSortDirection::try_from(1).ok(),
        }],
        limit: Some(50),
        ..Default::default()
    })
    .await?;

for row in &page.items {
    let session = agent.sessions.get(&row.id).await?;
    println!("{:?} {:?}", session.review_score, session.review_notes);
}

Searching

sessions.search finds sessions by words. It reads what a conversation was named: the title, description, project and agent name. It does not read what was said.

search takes the words, then the same filter, limit and cursor as query. For example, the sessions about order 1042 in the support project:

JavaScript
const found = await agent.sessions.search("order 1042", {
  filter: { project_id: "support" },
  limit: 5,
});
const one = await agent.sessions.get(found.items[0]!.id);
Kotlin
val found = agent.sessions.search(
    "order 1042",
    filter = SessionFilter.ProjectId("support"),
    limit = 5,
)
val one = agent.sessions.get(found.items.first().id)
Swift
let found = try await agent.sessions.search(
    "order 1042",
    filter: .projectID("support"),
    limit: 5
)
let one = try await agent.sessions.get(found.items[0].id)
Dart
final found = await agent.sessions.search(
  'order 1042',
  filter: {'project_id': 'support'},
  limit: 5,
);
final one = await agent.sessions.get(found.items.first.id);

A search comes back best match first. That order is called relevance, and it is the only order a search has: search takes no sort.

search writes the filter's text condition for you, so leave text out of the filter you pass. In a query, the same search is text: { "$q": "order 1042" } in the filter. A filter holds one text, at its top or directly inside its top $and.

Incognito sessions are never listed or searchable.

What comes back are records, not live sessions: most of them are over. Read their turns with sessions.responses(id) (see Reading Responses and History), or continue one with a fork or by resuming it by its id.

Pinning a session

Pin a session to keep it at hand, such as a conversation a user wants at the top of their list. pin and unpin do it, from a client for its user's own sessions or from your server:

JavaScript
const pinned = await session.pin();
console.log(pinned.pinned_at);

await session.unpin();
// A session you only have the id of: agent.sessions.pin(id)
Kotlin
val pinned = session.pin()
println(pinned.pinnedAt)

session.unpin()
// A session you only have the id of: agent.sessions.pin(id)
Swift
let pinned = try await session.pin()
print(pinned.pinnedAt as Any)

try await session.unpin()
// A session you only have the id of: agent.sessions.pin(id)
Dart
final pinned = await session.pin();
print(pinned.pinnedAt);

await session.unpin();
// A session you only have the id of: agent.sessions.pin(id)

Both answer the session as it now is. A pinned session carries pinned_at, the time it was pinned. A session that is not pinned has no pinned_at.

  • To list the pinned sessions first, sort by pinned.
  • Pinning does not move a session in the last_message_at order.
  • Pinning a session that is already pinned keeps the time it was first pinned.
  • A session that has ended can still be pinned and unpinned.
  • An incognito session can't be pinned: the answer is a 400.

pin and unpin are short for update with pinned set to true or false, which pins along with a rename.

Pagination

Results come back a page at a time as { items, has_more, next_cursor }: 25 by default, up to 200 with limit. When has_more is true, pass next_cursor back as cursor, with the same filter and sort, for the next page. There is no total count.

For example, your server reads every session of the agent, 200 at a time, to send each one through analysis:

Node.js
let page = await agent.sessions.query({ limit: 200 });

while (true) {
  for (const session of page.items) {
    await analyze(session);
  }
  if (!page.has_more || !page.next_cursor) break;
  page = await agent.sessions.query({ limit: 200, cursor: page.next_cursor });
}
Python
cursor = ""

while True:
    page = await agent.sessions.query(limit=200, cursor=cursor)
    for session in page.items:
        await analyze(session)
    if not page.has_more:
        break
    cursor = page.next_cursor
Ruby
cursor = nil

loop do
  page = agent.sessions.query(limit: 200, cursor: cursor)
  page["items"].each { |session| analyze(session) }
  break unless page["has_more"]

  cursor = page["next_cursor"]
end
PHP
$cursor = null;

do {
    $page = $agent->sessions->query(limit: 200, cursor: $cursor);
    foreach ($page->items as $session) {
        analyze($session);
    }
    $cursor = $page->hasMore ? $page->nextCursor : null;
} while ($cursor !== null);
Go
query := client.Query{Limit: 200}

for {
	page, err := agent.Sessions.Query(ctx, query)
	if err != nil {
		log.Fatal(err)
	}
	for _, session := range page.Items {
		analyze(session)
	}
	if !page.HasMore || page.NextCursor == nil {
		break
	}
	query.Cursor = *page.NextCursor
}
.NET
string? cursor = null;

do
{
    var page = await agent.Sessions.QueryAsync(new SessionQuery { Limit = 200, Cursor = cursor });
    foreach (var session in page.Items)
    {
        await AnalyzeAsync(session);
    }
    cursor = page.HasMore ? page.NextCursor : null;
} while (cursor is not null);
Rust
use vision_agents::Query;

let mut cursor = None;

loop {
    let page = agent
        .sessions
        .query(Query { limit: Some(200), cursor, ..Default::default() })
        .await?;
    for session in &page.items {
        analyze(session).await?;
    }
    if !page.has_more {
        break;
    }
    cursor = page.next_cursor;
}

analyze stands for your own code. A client pages the same way, for a "load more" button for example.

A cursor only continues the sort it came from. Sent with another sort, it is refused with a 400, so start again from the first page when the sort changes.

A session's turns page with a cursor too, with limit and cursor on responses.list (25 by default, up to 200) and on the items (200 by default, up to 1000). items.all() follows the cursor for you, and so does items.unwind() where the SDK has it (not in Rust).

Info:

The page fields follow each SDK's casing, such as hasMore and nextCursor in Kotlin, Swift and Dart, and HasMore and NextCursor in Go and C#. JavaScript's page keeps the wire names, has_more and next_cursor, and Ruby answers string-keyed hashes, such as page["next_cursor"]. The filter's keys are the wire names in every SDK that takes a map, such as project_id.

Fork and interrupt

Fork

fork continues a conversation as a new session, with the parent's history carried across. The usual reason is to ask the same question of a different model or a different agent without losing the original answer. The parent is untouched and keeps running if it was.

JavaScript
// Fork options take the API's own snake_case names, except modelOverwrites, as on create.
const harder = await session.fork({
  modelOverwrites: { llm: "llm-thinking" },
  title: "Order 1042, thinking harder",
});

const { items: turns } = await session.responses.list();
const fromThere = await session.fork({ response_id: turns[0]!.id });
const fresh = await session.fork({ messages: false });
Kotlin
val forked = session.fork(
    ForkOptions(
        modelOverwrites = ModelOverwrites(llm = "llm-thinking"),
        title = "Order 1042, thinking harder",
    ),
)
// A fork comes back as a record: attach opens it as a live session, as it does any session by id.
val harder = agents.attach(forked.id)

val fromThere = session.fork(ForkOptions(responseId = turns.first().id))
val fresh = session.fork(ForkOptions(withoutHistory = true))
Swift
var options = ForkOptions()
options.modelOverwrites = ModelOverwrites(llm: "llm-thinking")
options.title = "Order 1042, thinking harder"
let forked = try await session.fork(options)
// A fork comes back as a record: attach opens it as a live session, as it does any session by id.
let harder = try await agents.attach(sessionID: forked.id)

let fromThere = try await session.fork(ForkOptions(responseID: turns[0].id))

var fromNothing = ForkOptions()
fromNothing.withoutHistory = true
let fresh = try await session.fork(fromNothing)
Dart
final harder = await session.fork(const ForkOptions(
  modelOverwrites: ModelOverwrites(llm: 'llm-thinking'),
  title: 'Order 1042, thinking harder',
));
// A fork is live, but not watched until start().
await harder.start();

final fromThere = await session.fork(ForkOptions(responseId: turns.first.id));
final fresh = await session.fork(const ForkOptions(withoutHistory: true));

A fork keeps the parent's title, description, project, custom labels and model overwrites, and anything you name replaces them: title, description, project_id, custom, model_overwrites and incognito. Naming agent or config_id runs the fork under that agent with its own models, dropping the parent's model overwrites. A fork of a session that ended runs on its agent config as it is now. response_id carries the history only up to the end of that response, and messages: false starts the same configuration over with no history; the two can't be combined. A fork writes its own transcript, so the two conversations never interleave.

To go back to an earlier response and carry on from there, fork at it with response_id. A session kept in Stream Chat can't be rewound in place: its transcript would bring the later turns back.

A fork is always held in writing, even of a session with voice on: talking to two agents at once is not a conversation. Start voice on the fork to talk to it. An incognito session cannot be forked, because nothing was recorded to fork from.

Interrupt

interrupt() abandons the reply being spoken. It goes over the socket a watched session holds open to the backend, alongside the other live calls: what the conversation does arrives on it as events, and you can send it text.

Method What it does
responses.create(text) Answer the text through the model, as though it had been said
say(text) Speak or write the text as it is, without the model. For a greeting you already know
interrupt() Abandon the reply being spoken
events() Everything the conversation does, until it ends
JavaScript
// say(text, { interrupt: true }) stops the reply in progress first.
session.say("Hi, I can help with your order.");
await session.responses.create("Where is order 1042?");

for await (const event of session.events()) {
  if (event.kind === "response_delta") console.log(event.text);
  if (event.kind === "responded") break;
}

// interrupt({ requestId }) stops only the reply answering that request, the request_id on its events.
session.interrupt();
Kotlin
val answer = launch {
    session.events()
        .dropWhile { it.type != "responding" }
        .takeWhile { it.type != "responded" }
        .collect { if (it.type == "response_delta") print(it.text) }
}
session.say("Hi, I can help with your order.")
session.responses.create("Where is order 1042?")
answer.join()

session.interrupt()
Swift
let events = session.events()
// say(_:interrupt: true) stops the reply in progress first.
try await session.say("Hi, I can help with your order.")
_ = try await session.responses.create("Where is order 1042?")

for await event in events {
    if event.kind == .responseDelta { print(event.text, terminator: "") }
    if event.kind == .responded { break }
}

// interrupt(requestID:) stops only the reply answering that request, the request_id on its events.
try await session.interrupt()
Dart
session.say('Hi, I can help with your order.');
final events = session.events();
await session.responses.create('Where is order 1042?');

await for (final event in events) {
  if (event.kind == AgentEventKind.responseDelta) stdout.write(event.text);
  if (event.kind == AgentEventKind.responded) break;
}

session.interrupt();

By default, what say sends waits for the reply in progress to finish.

The events you will use most are response_delta (a fragment of the answer), responded (the answer is complete; pending_work, pendingWork in JavaScript, stays true while tools or delegated work are outstanding), heard (what the caller said, on a call), tool_call and tool_ran, error and left (the session ended). Your SDK answers tool_call for you by running the tool you registered. In PHP that takes a dispatch worker: a session opened with $client->agent() doesn't run tools, and watch() needs the amphp/websocket-client package.

responses.create returns the response, whose id lets you follow one particular question.

Other session endpoints

A few more operations on one session have no SDK method on this page, and are called over HTTP:

Operation What it does Who
GET /v1/agents/sessions/{id}/tools The tools the session's model is offered, and where each came from Server
GET /v1/agents/sessions/{id}/commands/{request_id} What is known about one command sent with a request id Server
POST /v1/agents/sessions/{id}/commands/{request_id}/interrupt Stop that one command and nothing else Server
POST /v1/agents/sessions/{id}/rewind Go back to a response and carry on from there, for a session not kept in Stream Chat Client and server
POST /v1/agents/sessions/{id}/say, /interrupt The HTTP form of say and interrupt. A client sends them over the session's socket instead Server

Client vs server

Each call on this page is made in one place, not on both sides: in the client for its signed-in user's own conversations, or on your server for anyone's. The client reaches only that user's sessions: it can open, watch, ask, search, fork, rename, pin, close and delete them.

Your server reaches every session in your Stream app, so it can search and query them all, for admin screens for example. A running session kept in Stream Chat is the exception: to read, change or watch it while it runs, your server acts as its user with the X-Stream-User-Id header, or it gets a 404. To narrow a query to one user, filter on user_id, which only your server may do. For a phone line or another channel with no client, your server opens the sessions. See Auth.