User connection
It's common to want the agent to act as the person it's talking to. For instance, "Which Linear issues are assigned to me?" only works with that person's own Linear.
A user connection is an account one of your users connects. The agent uses it only in that user's conversations.
Let each user connect
Add a connector by its name:
name: assistant # an internal assistant for your team
mode: text
llm: llm-fast
connectors:
- linear # each user signs in to their own LinearThe user signs in at Linear, and the agent never sees their password.
A test in the terminal has no signed-in user, so it leaves linear out and tells you so. Test it in the Stream Dashboard, where you are the signed-in user:
curl -fsSL https://getstream.io/cli/beta/install.sh | bash # the agents commands are in the beta CLI
getstream agents test assistant --sync --dashboardThe CLI prints the address of the agent's test chat in the Dashboard. Open it and ask: "Which Linear issues are assigned to me?". A Connect Linear button shows under the reply while the conversation runs. Sign in at Linear, and the button turns into "Linear connected" as the agent goes on to list your issues.
To check whose account the agent uses:
getstream agents tools assistantCONNECTOR NAME ACCOUNT GRANT REQUIRED SANDBOX
linear linear Each user's own All tools no noTo see the tools of one user's connection, run getstream agents connections tools --user <user id> linear, as in Tools. No CLI command creates or lists a user connection: getstream agents connections list shows only the app's own.
Help one user in the Dashboard
In the Stream Dashboard, go to Agents > Library > Connectors and open Linear. Under Connections, filter by End user ID with the ID your server opens sessions for. The Status column shows whether that user's connection works.
"Needs reconnect" means Linear no longer accepts the user's sign-in. The user signs in again, as in Reconnect.
Connect in your app's chat with the API
In your own app, the reply asks the same way: the call's step waits for the user to sign in, and the AI components show the Connect Linear card on it. Give each session a returnUrl on your page's origin, where the sign-in comes back, and an approver that runs it:
Not documented for Flutter yet. Showing JavaScript.
"use client"; // Next.js App Router: these hooks run in the browser
import type { Session } from "@stream-io/stream-agents";
import { useMemo } from "react";
import type { LocalMessage } from "stream-chat";
import {
AIClientIdentity,
AIConversation,
createPopupAuthorizer,
fromStreamMessage,
type AIToolApprover,
} from "stream-chat-react/ai-components";
import "stream-chat-react/dist/css/index.css";
import "stream-chat-react/dist/css/ai-components.css";
// Where the sign-in comes back. Pass the same URL to sessions.create, with `api` the
// Client the user signed in with: api.agent("assistant").sessions.create({ returnUrl })
// Empty while a page renders on the server, where there is no window.
export const connectedPage = () =>
typeof window === "undefined"
? ""
: `${window.location.origin}/connected.html`;
// `messages` are the messages of the channel session.chat() opened, loaded with
// `channel.watch()`.
export const ConnectedConversation = ({
session,
userId,
messages,
}: {
session: Session;
userId: string;
messages: LocalMessage[];
}) => {
const approver = useMemo<AIToolApprover>(
() => ({
userId,
clientId: AIClientIdentity.installId(),
// Answers the questions of the tools this page registered for the session.
decide: async (call, allowed) => session.decide(call.id, { allowed }),
authorizer: createPopupAuthorizer({
returnUrl: connectedPage(),
decline: async (call) => session.declineLogin(call.id),
}),
}),
[session, userId],
);
const conversation = useMemo(
() => messages.map((message) => fromStreamMessage(message)),
[messages],
);
return <AIConversation approver={approver} messages={conversation} />;
};// Where the sign-in comes back: your app's own scheme. Pass the same URL when the session opens:
// agents.agent("assistant").chat(SessionOptions(returnUrl = "com.example.assistant:/connected"))
val approver = AIToolApprover(
userId = agents.backend.user?.id.orEmpty(),
clientId = AIClientIdentity.installId(context),
scope = viewModelScope,
authorizer = AIToolAuthorizer(
connect = { _, url -> SignIns.open(context, url) },
decline = { call -> session.declineLogin(call.id) },
),
) { call, allowed ->
// Answers the questions of the tools this app gave the session.
session.decide(call.id, allowed)
}import Foundation
import StreamAgents
import StreamChatAI
import SwiftUI
// Where the sign-in comes back: your app's own URL scheme, in reverse domain form.
@MainActor
func openConnectedConversation() async throws -> AgentTransport {
var options = SessionOptions(agent: "assistant")
options.returnURL = URL(string: "com.example.assistant:/connected")
return try await AgentTransport(agents.chat(options))
}
// `AgentTransport` and `AgentMessage` are the quickstart's.
struct ConnectedConversation: View {
@StateObject private var conversation: AIConversation<AgentMessage>
init(transport: AgentTransport) {
var approver = transport.approver
// Opens the sign-in in the system's sign-in sheet, and declines it on Not now.
approver.authorizer = AIToolAuthorizer(callbackScheme: "com.example.assistant") { call in
try await transport.session.declineLogin(call.id)
}
_conversation = StateObject(
wrappedValue: AIConversation(transport: transport, approver: approver, actions: [.rate])
)
}
var body: some View {
AIConversationView(conversation: conversation)
.safeAreaInset(edge: .bottom) {
AIComposerView(conversation: conversation)
}
}
}SignIns opens the page in a Custom Tab and resumes once the browser comes back, as in the Android SDK's Tool sign-ins, which has its activity code and manifest entry. Give the approver to the conversation's AIConversationState, as the quickstart's agentConversation does.
AIToolAuthorizer comes with StreamChatAI from version 5.15.0 of the Stream Chat Swift SDK. Tool sign-ins in the iOS guide covers the card, what happens when the user taps Connect, and a card in your own design.
AIConversation, createPopupAuthorizer and AIClientIdentity come from stream-chat-react/ai-components.
session.decide answers only the tools your page registered. A connector call held for the user's approval by require_approval is answered by that user or your backend over the session's socket, which the JavaScript SDK has no call for yet.
The return page reports how the sign-in went and closes the popup. It imports a package, so build it with your app's bundler as a second page, such as connected.html in Vite's build.rollupOptions.input:
<script type="module">
import { completeConnectorSignIn } from "stream-chat-react/ai-components";
completeConnectorSignIn();
</script>Connect opens the sign-in in a popup, and the same reply goes on once the user is in. Not now calls session.declineLogin, so the call runs nothing and the agent says the user didn't connect. On iOS and Android, name your app's own scheme in reverse domain form, such as com.example.assistant:/connected, as the session's returnURL or returnUrl, and the sign-in card opens the page in the system's sign-in sheet. The React, Swift and Android assistant samples do this.
A sign-in comes back only to the return_url the session was created with, so a conversation started on a phone finishes its sign-ins on that phone.
Connect from your app with the API
Your app can also show its own Connect button. Your server starts the sign-in, your app opens it in a popup, and the popup comes back to your page:
sequenceDiagram
participant B as Your backend
participant F as Your frontend
participant R as Stream router
actor U as User
participant P as Provider
B->>R: POST /v1/agents/connections
R-->>B: id, status pending
B->>R: POST /v1/agents/connections/{id}/authorizations
R-->>B: launch_url, handoff_token
B-->>F: launch_url, handoff_token
F->>R: open launch_url in a popup
R-->>F: va.connector.oauth.ready
F->>R: va.connector.oauth.handoff with handoff_token
R->>P: the popup goes to the provider
U->>P: signs in and approves
P->>R: the popup comes back to the router
R-->>F: the popup goes to return_url?connection_id=...&status=connectedYour server: create the connection
$STREAM_ACCELERATION_URL is your router's URL, and $SERVER_TOKEN is a server token: an HS256 JWT of {"server": true} signed with your API secret. X-Stream-User-Id and owner.user_id name the same user:
curl -X POST "$STREAM_ACCELERATION_URL/v1/agents/connections" \
-H "api_key: $STREAM_API_KEY" -H "stream-auth-type: jwt" \
-H "Authorization: Bearer $SERVER_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Stream-User-Id: alice" \
-d '{"connector_id": "linear", "owner": {"type": "user", "user_id": "alice"}}'It returns the connection, pending, with its id. Keep the id with your user: you need it to check the status and to reconnect.
Your server: start the sign-in
Use that id as $CONNECTION_ID:
curl -X POST "$STREAM_ACCELERATION_URL/v1/agents/connections/$CONNECTION_ID/authorizations" \
-H "api_key: $STREAM_API_KEY" -H "stream-auth-type: jwt" \
-H "Authorization: Bearer $SERVER_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Stream-User-Id: alice" \
-d '{"return_url": "https://app.example.com/settings/integrations"}'It returns a launch_url and a handoff_token for your app. The sign-in expires 10 minutes later, and both calls are server-side only.
return_url must be https, or http on localhost. Without return_url, the sign-in goes back to the dashboard.
In your app: open the popup
Open the popup from a page on the return_url's origin: the popup takes the handoff token from that origin only. Wait for the popup's ready message, then post it the token:
Not documented for Kotlin yet. Showing JavaScript.
Not documented for Swift yet. Showing JavaScript.
Not documented for Flutter yet. Showing JavaScript.
const popup = window.open(
"about:blank",
"connect",
"popup,width=520,height=720",
);
const { launchUrl, handoffToken } = await startConnect(); // your backend makes both calls
const routerOrigin = new URL(launchUrl).origin;
popup.location = launchUrl;
window.addEventListener("message", (event) => {
if (event.source !== popup || event.origin !== routerOrigin) return;
if (event.data?.type !== "va.connector.oauth.ready") return;
popup.postMessage(
{ type: "va.connector.oauth.handoff", handoff_token: handoffToken },
routerOrigin,
);
});The handoff token works once.
Your return page: tell the opening page
When the sign-in ends, the popup lands on your return_url:
https://app.example.com/settings/integrations?connection_id=...&status=connectedstatus is one of:
connected: the user signed in, and the account is stored.denied: the user cancelled or refused at the provider.failed: the sign-in didn't complete.account_mismatch: the user signed in to a different provider account than the connection holds, or than their email allows. Nothing is stored, and a reconnect keeps the old grant.
Pass status on to the opening page, for instance with a same-origin BroadcastChannel, then close the popup.
Your server: check the status
curl "$STREAM_ACCELERATION_URL/v1/agents/connections/$CONNECTION_ID" \
-H "api_key: $STREAM_API_KEY" -H "stream-auth-type: jwt" \
-H "Authorization: Bearer $SERVER_TOKEN" \
-H "X-Stream-User-Id: alice"status |
What it means | What to do |
|---|---|---|
pending |
Nobody has signed in yet. It stays pending after a failed sign-in |
Start a sign-in again |
connected |
The user signed in, and the agent can use the account | Nothing |
needs_reauthorization |
The provider no longer accepts the credential | Reconnect |
disconnected |
The connection was deleted | Create a new one |
Reconnect with the API
Start a new sign-in on the same connection, and open its launch_url the same way. Until the user finishes, the connection stays as it was.
OAuth clients
To put your app's name on the consent screen, set your own OAuth client. See Sign-in and OAuth clients.