SWIRLS_
Guides

Embed agent chat in your app

Connect a signed-in user of your app to a Swirls agent over the web channel, with conversations and history backed by the Swirls API. OIDC federation keeps access tokens on your server.

Swirls exposes any agent as a web chat surface through a web channel, and that surface speaks the Vercel AI SDK useChat protocol. This guide wires a signed-in user of your app to a Swirls agent, with their conversations and history stored in Swirls and listed through the API.

The SDK ships a Next.js adapter that does the server work for you: it authenticates the user, exchanges their identity for a short-lived Swirls token through OIDC federation, manages their sessions through the Swirls API, and streams chat traffic to the agent. You add one route and render the chat. The browser only ever talks to your own app.

A Swirls access token grants agent access for your whole organization. Treat it as a server credential. The adapter mints and uses it entirely on the server, and never sends it to the browser. Keep it that way in any code you add.

Architecture

The adapter route owns four responsibilities: authenticate the user with your own identity provider, exchange that identity for a Swirls token, manage the user's sessions through the Swirls API, and stream chat messages to the web channel.

Prerequisites

  • OIDC federation is configured for your organization. Follow OIDC Federation to register Clerk or Supabase, then confirm your identity provider mints JWTs carrying the Swirls audience.
  • The Swirls SDK is installed (@swirls/sdk). It provides the API client, the federated-auth helper, and the Next.js adapter used below.
  • The Vercel AI SDK is installed (ai and @ai-sdk/react) for the chat UI.

Step 1: Deploy a web channel

Bind your agent to a web channel in your .swirls files. The channel block name becomes part of the chat endpoint path, so give it a stable name.

agent concierge {
  label: "Concierge"
  secrets: vendor_keys
  model: "openai/gpt-4o-mini"
  maxSteps: 8
  system: @ts {
    return "You are a helpful concierge."
  }
}

channel web_concierge {
  label: "Concierge (Web)"
  platform: web
  integration: web
  agent: concierge
  mode: dm
  enabled: true
}

Deploy with git push or swirls deploy. The web channel is now bound to your agent under the channel block name (web_concierge). Find your projectId in the Portal. See Channels for the full channel block reference.

Step 2: Add the chat route

Add one catch-all route and export the handlers from @swirls/sdk/next. This is the whole server. The adapter lists and creates sessions, loads history, and streams messages, all with the user's token kept on the server.

Use an optional catch-all ([[...swirls]], double brackets). useChat posts to the mount root /api/swirls-chat, and a required catch-all ([...swirls]) would not match a path with no extra segment.

import { createNextSwirlsChatHandlers, clerkSubjectToken } from "@swirls/sdk/next"
import { auth } from "@clerk/nextjs/server"

export const { GET, POST } = createNextSwirlsChatHandlers({
  // Resolve the signed-in user's IdP JWT, carrying the Swirls audience.
  auth: clerkSubjectToken({ auth, template: "swirls" }),
  projectId: process.env.SWIRLS_PROJECT_ID!,
  channel: "web_concierge",
  agentName: "concierge",
})

The route serves four paths under its mount point (/api/swirls-chat):

MethodPathAction
GET…/sessionsList the user's conversations
POST…/sessionsCreate a conversation ({ title? })
GET…/sessions/:id/messagesLoad a conversation's history
POSTStream a useChat message to the agent

auth returns the user's identity-provider JWT for the request. The adapter exchanges it for a short-lived Swirls token through OIDC federation, once per request, and refreshes it for you. clerkSubjectToken builds that function from Clerk's server auth(). Pass your own auth from @clerk/nextjs/server. For another provider, supply your own (request) => Promise<string> that mints the user's JWT. See OIDC Federation for the audience setup.

Sessions are scoped to the authenticated identity. The federated token is the identity, and it stays on your server, so one user cannot read another user's conversations. A request with no active session returns 401.

Step 3: Render the chat and session list

The browser now has the adapter's routes. Render the user's conversations, let them start a new one, and pass the selected sessionId as the useChat id. That is what ties each message to the right conversation: the id travels to your route as body.id, and Swirls routes the message and its reply to that session.

"use client"
import { useEffect, useMemo, useState } from "react"
import { useChat } from "@ai-sdk/react"
import { DefaultChatTransport, type UIMessage } from "ai"

type Session = { sessionId: string; title: string | null; updatedAt: string }

export function AgentChat() {
  const [sessions, setSessions] = useState<Session[]>([])
  const [sessionId, setSessionId] = useState<string | null>(null)

  useEffect(() => {
    fetch("/api/swirls-chat/sessions").then((r) => r.json()).then(setSessions)
  }, [])

  async function newChat() {
    const { sessionId } = await fetch("/api/swirls-chat/sessions", {
      method: "POST",
    }).then((r) => r.json())
    setSessions((prev) => [{ sessionId, title: null, updatedAt: new Date().toISOString() }, ...prev])
    setSessionId(sessionId)
  }

  return (
    <div>
      <aside>
        <button onClick={newChat}>New chat</button>
        {sessions.map((s) => (
          <button key={s.sessionId} onClick={() => setSessionId(s.sessionId)}>
            {s.title ?? "Untitled conversation"}
          </button>
        ))}
      </aside>
      {sessionId && <Conversation key={sessionId} sessionId={sessionId} />}
    </div>
  )
}

function Conversation({ sessionId }: { sessionId: string }) {
  const [input, setInput] = useState("")
  const transport = useMemo(
    () => new DefaultChatTransport({ api: "/api/swirls-chat" }),
    [],
  )

  // The session id is the useChat id, so each message posts as `body.id` and
  // ties to this conversation. The agent's reply persists to the same session.
  const { messages, sendMessage, status, setMessages } = useChat({
    id: sessionId,
    transport,
  })

  // Hydrate stored history when the conversation opens.
  useEffect(() => {
    fetch(`/api/swirls-chat/sessions/${sessionId}/messages`)
      .then((r) => r.json())
      .then((data: { messages: UIMessage[] }) => setMessages(data.messages))
  }, [sessionId, setMessages])

  const busy = status === "submitted" || status === "streaming"

  function submit(e: React.FormEvent) {
    e.preventDefault()
    const text = input.trim()
    if (!text || busy) return
    setInput("")
    void sendMessage({ text })
  }

  return (
    <form onSubmit={submit}>
      {messages.map((m) => (
        <div key={m.id}>
          <strong>{m.role}:</strong>{" "}
          {m.parts.map((part, i) => (part.type === "text" ? <span key={i}>{part.text}</span> : null))}
        </div>
      ))}
      <input value={input} onChange={(e) => setInput(e.target.value)} placeholder="Ask the agent" />
    </form>
  )
}

Keying Conversation by sessionId remounts useChat per conversation, so each one carries its own id and history. Because the session id is the useChat id, it reaches your route as body.id, which is how Swirls binds the turn to the conversation that createWebSession made. The full useChat API is in the AI SDK docs.

How a message flows

  1. The browser loads the user's conversations from /api/swirls-chat/sessions. The adapter calls agents.listWebSessions with the user's token.
  2. The user opens an existing conversation or starts a new one. Its sessionId becomes the useChat id, and stored history loads through setMessages.
  3. A message posts to /api/swirls-chat. The adapter streams it to the web channel with the token attached.
  4. Swirls verifies the token, resolves the user's organization, and routes the message to the agent bound to that web channel, tied to the session.
  5. The agent's reply streams back through the adapter to the browser, and Swirls persists it to the session.

Token lifecycle

The adapter handles expiry for you. Swirls tokens last about 15 minutes with no refresh token. The adapter re-exchanges automatically as a token nears expiry, minting a fresh IdP JWT through your auth function. Because every request runs through your server, the browser never sees a token or its expiry.

Without Next.js

The adapter wraps a framework-agnostic core, @swirls/sdk/chat. Use it directly on any server to get the same four operations, then wire them to your own routes.

import { createSwirlsChat } from "@swirls/sdk/chat"
import { createFederatedTokenSource } from "@swirls/sdk/auth"

const chat = createSwirlsChat({
  apiKey: createFederatedTokenSource({ getSubjectToken: () => mintIdpJwt(userId) }),
  projectId: process.env.SWIRLS_PROJECT_ID!,
  channel: "web_concierge",
  agentName: "concierge",
})

await chat.listSessions()           // [{ sessionId, title, updatedAt }]
await chat.createSession({ title }) // { sessionId }
await chat.getMessages(sessionId)   // persisted history
return chat.send(request)           // a useChat-compatible streamed Response

Next steps

  • OIDC Federation: register your identity provider and configure the audience.
  • Channels: the full channel block reference and routing rules.
  • SDK reference: the client, the @swirls/sdk/auth helper, the chat core, and the Next.js adapter.
  • API reference: every API namespace and method.

On this page