Build a chat UI

A browser chat that streams the agent's reply through your own backend, with a sidebar, titles and follow-ups.

This guide builds a chat on Next.js: a route that streams a turn to the browser, a hook that renders it, and server actions for the sidebar. The shape carries over to any backend and frontend: the browser talks to your server, and your server talks to the Agent Platform.

browser ──(your session cookie)──▶ your backend ──(project key + X-User-Id)──▶ Agent Platform

The project key never reaches the browser, and your backend decides who the user is using its own login.

1. A client per signed-in user

Put this where only server code can import it. getCurrentUser is your own session lookup.

lib/agent.ts
import "server-only"
import { AgentPlatformClient } from "@aiceafrica/agent-platform"
import { getCurrentUser } from "@/lib/auth" // yours

export const GRAPH_ID = "agent_platform"

export async function agentForCurrentUser() {
  const user = await getCurrentUser()
  if (!user) return null
  return new AgentPlatformClient({
    baseUrl: process.env.AICE_AGENT_URL!,
    apiKey: process.env.AICE_API_KEY!,
    userId: user.id, // a UUID
  })
}

A client per request is fine: construction makes no network call. It has to be per user, because the user is fixed when the client is built.

2. A route that streams one turn

The route runs the turn on the platform and re-emits just what the browser needs, as Server-Sent Events: the thread ID, text deltas, and finally the title and follow-ups.

app/api/chat/route.ts
import { agentForCurrentUser, GRAPH_ID } from "@/lib/agent"

export async function POST(req: Request) {
  const client = await agentForCurrentUser()
  if (!client) return new Response("Unauthorized", { status: 401 })

  const { threadId, message } = (await req.json()) as { threadId?: string; message: string }
  // Create the thread on first send, so opening "New chat" doesn't leave empty threads.
  const id = threadId ?? (await client.threads.create()).thread_id

  const encoder = new TextEncoder()
  const body = new ReadableStream({
    async start(controller) {
      const send = (event: string, data: unknown) =>
        controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`))

      send("thread", { threadId: id })
      try {
        for await (const chunk of client.runs.stream(id, GRAPH_ID, {
          input: { messages: [{ role: "user", content: message }] },
          streamMode: ["messages-tuple"],
          signal: req.signal, // stop the upstream stream if the browser goes away
        })) {
          if (chunk.event !== "messages") continue
          const [m] = chunk.data as [{ id: string; type?: string; content?: unknown }, unknown]
          // Only the assistant's text; tool results arrive as their own messages.
          if (m.type?.startsWith("tool")) continue
          if (typeof m.content === "string" && m.content) {
            send("delta", { id: m.id, text: m.content })
          }
        }
        // The turn finished normally: the one point to ask for a title and follow-ups.
        const post = await client.suggestions.createForThread(id).catch(() => null)
        send("done", post ?? {})
      } catch {
        // A dropped upstream stream doesn't mean the run failed: tell the
        // browser to reload the thread rather than resend.
        send("interrupted", { threadId: id })
      } finally {
        controller.close()
      }
    },
  })

  return new Response(body, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache, no-transform",
      "X-Accel-Buffering": "no", // keep proxies such as nginx from buffering the stream
    },
  })
}

What this route deliberately does:

  • Title generation happens once, only after a normal finish. Not per chunk and not on error. Its failure is swallowed because a missing title is cosmetic.
  • Deltas are forwarded with their message id, so the browser can append to the right message.
  • A failure becomes interrupted, not an error. The run may well still be finishing on the server.

Check your host streams responses

Some serverless platforms buffer the whole response, or cap its duration below a long agent turn. Run this route on a runtime that streams and allows a few minutes per request.

3. Reading the stream in the browser

fetch plus a small parser. EventSource won't do here because it can't send a POST body.

lib/use-chat.ts
"use client"
import { useCallback, useRef, useState } from "react"

export type ChatMessage = { id: string; role: "user" | "assistant"; text: string }
export type Suggestion = { title: string; prompt: string }

export function useChat(initialThreadId?: string) {
  const [threadId, setThreadId] = useState(initialThreadId)
  const [messages, setMessages] = useState<ChatMessage[]>([])
  const [suggestions, setSuggestions] = useState<Suggestion[]>([])
  const [status, setStatus] = useState<"idle" | "streaming" | "interrupted">("idle")
  const abort = useRef<AbortController | null>(null)

  const send = useCallback(
    async (text: string) => {
      setSuggestions([])
      setMessages((m) => [...m, { id: crypto.randomUUID(), role: "user", text }])
      setStatus("streaming")
      abort.current = new AbortController()

      const res = await fetch("/api/chat", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ threadId, message: text }),
        signal: abort.current.signal,
      })
      if (!res.ok || !res.body) {
        setStatus("interrupted")
        return
      }

      const reader = res.body.pipeThrough(new TextDecoderStream()).getReader()
      let buffer = ""
      for (;;) {
        const { value, done } = await reader.read()
        if (done) break
        buffer += value
        // Events are separated by a blank line; keep any partial event for the next read.
        const events = buffer.split("\n\n")
        buffer = events.pop() ?? ""
        for (const raw of events) {
          const event = raw.match(/^event: (.*)$/m)?.[1]
          const data = JSON.parse(raw.match(/^data: (.*)$/m)?.[1] ?? "{}")
          if (event === "thread") setThreadId(data.threadId)
          if (event === "delta") appendDelta(data.id, data.text)
          if (event === "done") setSuggestions(data.suggestions ?? [])
          if (event === "interrupted") setStatus("interrupted")
        }
      }
      setStatus((s) => (s === "streaming" ? "idle" : s))
    },
    [threadId],
  )

  // Chunks are deltas: append to the message with this id, never replace it.
  function appendDelta(id: string, text: string) {
    setMessages((m) => {
      const i = m.findIndex((x) => x.id === id)
      if (i === -1) return [...m, { id, role: "assistant", text }]
      const next = [...m]
      next[i] = { ...next[i], text: next[i].text + text }
      return next
    })
  }

  const stop = useCallback(() => abort.current?.abort(), [])

  return { threadId, messages, setMessages, suggestions, status, send, stop }
}

When status is interrupted, reload the thread with the loadThread action below. Don't resend the message.

4. The sidebar, transcript, rename and delete

These are ordinary server actions: the client is scoped to the signed-in user, so none of them takes a user parameter.

app/chat/actions.ts
"use server"
import { agentForCurrentUser } from "@/lib/agent"

async function agent() {
  const client = await agentForCurrentUser()
  if (!client) throw new Error("Not signed in")
  return client
}

export async function listThreads(offset = 0) {
  const client = await agent()
  const rows = await client.threads.search({
    limit: 31, // one extra row tells you there's another page
    offset,
    sortBy: "updated_at",
    sortOrder: "desc",
  })
  return {
    threads: rows.slice(0, 30).map((t) => ({
      id: t.thread_id,
      title: (t.metadata?.thread_name as string | undefined) || "New conversation",
    })),
    hasMore: rows.length > 30,
  }
}

export async function loadThread(threadId: string) {
  const client = await agent()
  const state = await client.threads.getState(threadId)
  const messages = (state.values as { messages?: { id: string; type: string; content: unknown }[] }).messages ?? []
  return messages
    .filter((m) => (m.type === "human" || m.type === "ai") && typeof m.content === "string" && m.content)
    .map((m) => ({
      id: m.id,
      role: m.type === "human" ? ("user" as const) : ("assistant" as const),
      text: m.content as string,
    }))
}

export async function renameThread(threadId: string, name: string) {
  const client = await agent()
  // titled_by: "user" stops the next turn overwriting the name.
  await client.threads.update(threadId, { metadata: { thread_name: name, titled_by: "user" } })
}

export async function deleteThread(threadId: string) {
  const client = await agent()
  await client.threads.delete(threadId) // permanent: confirm in the UI first
}

export async function starters() {
  const client = await agent()
  return client.suggestions.starters()
}

After a done event, refresh the sidebar: the thread now has its generated title.

Before you ship

  • The project key is only in server environment variables, and no client bundle imports lib/agent.ts.
  • Every route and action gets the user from your session, never from the request body.
  • Title generation runs once per finished turn, never on error.
  • Deltas are appended per message id.
  • A dropped stream reloads the thread instead of resending.
  • There's a stop button, and deleting asks for confirmation.
  • A 404 on a thread in the URL shows the empty state.
  • You've read runs that succeed but say no: refusals arrive as normal replies.

On this page