Next.js chat with Live View
Build a Next.js App Router chat app where a Vercel AI SDK v6 agent drives a Steel cloud browser, with the Steel Live View embedded next to the chat.
This guide builds a Next.js chat UI on top of the v6 quickstart. The server route uses streamText with Steel tools; the client uses useChat and pulls the Steel live view out of the openSession tool's output part — specifically session.debugUrl, the interactive embed -- to render a live iframe of the browser next to the chat.
For a server-only agent (ToolLoopAgent.generate), see the Quickstart.
Requirements
-
Steel API key
-
Anthropic API key
-
Node.js 20+
Step 1: Create the project
git clone https://github.com/steel-dev/steel-cookbookcd steel-cookbook/examples/steel-ai-sdk-nextjs-starternpm installnpx playwright install chromium
Step 2: Environment variables
1STEEL_API_KEY=your-steel-api-key-here2ANTHROPIC_API_KEY=your-anthropic-api-key-here
Step 3: API route
streamText drives the loop. Tools are defined inside the handler so each request gets its own Steel session held in a closure. prepareStep phase-gates tools. onFinish/onAbort release the session. submitForm shows off v6's needsApproval — the tool call streams to the UI for confirmation before executing.
1import { anthropic } from "@ai-sdk/anthropic";2import {3convertToModelMessages,4stepCountIs,5streamText,6tool,7type UIMessage,8} from "ai";9import { chromium, type Browser, type Page } from "playwright";10import Steel from "steel-sdk";11import { z } from "zod";1213// Playwright needs the Node.js runtime (not Edge).14export const runtime = "nodejs";15export const maxDuration = 120;1617const STEEL_API_KEY = process.env.STEEL_API_KEY!;1819export async function POST(req: Request) {20const { messages } = (await req.json()) as { messages: UIMessage[] };21const steel = new Steel({ steelAPIKey: STEEL_API_KEY });2223let session: Awaited<ReturnType<typeof steel.sessions.create>> | null = null;24let browser: Browser | null = null;25let page: Page | null = null;2627const cleanup = async () => {28if (browser) await browser.close().catch(() => {});29if (session) await steel.sessions.release(session.id).catch(() => {});30};3132const result = streamText({33model: anthropic("claude-haiku-4-5"),34system: [35"You operate a Steel cloud browser via tools.",36"Workflow: (1) call openSession, (2) navigate to the target URL,",37"(3) call snapshot to see the page's text and links,",38"(4) only call extract when you need structured rows beyond what snapshot gives,",39"(5) reply to the user in plain English.",40"Prefer snapshot's links list over guessing selectors. Do not invent data.",41].join(" "),42messages: await convertToModelMessages(messages),43stopWhen: stepCountIs(15),44tools: {45openSession: tool({46description:47"Open a Steel cloud browser session. Call this exactly once, before anything else.",48inputSchema: z.object({}),49execute: async () => {50session = await steel.sessions.create({});51browser = await chromium.connectOverCDP(52`${session.websocketUrl}&apiKey=${STEEL_API_KEY}`53);54const ctx = browser.contexts()[0];55page = ctx.pages()[0] ?? (await ctx.newPage());56return {57sessionId: session.id,58liveViewUrl: session.sessionViewerUrl,59debugUrl: session.debugUrl,60};61},62}),63navigate: tool({64description: "Navigate the open session to a URL.",65inputSchema: z.object({ url: z.string().url() }),66execute: async ({ url }) => {67if (!page) throw new Error("openSession must be called first.");68await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45_000 });69return { url: page.url(), title: await page.title() };70},71}),72snapshot: tool({73description:74"Return a readable snapshot of the current page: title, URL, visible text (capped), and a list of links with their text and href. Call this BEFORE extract so you never have to guess CSS selectors.",75inputSchema: z.object({76maxChars: z.number().int().positive().max(10_000).default(4_000),77maxLinks: z.number().int().positive().max(200).default(50),78}),79execute: async ({ maxChars, maxLinks }) => {80if (!page) throw new Error("openSession must be called first.");81return (await page.evaluate(82({ maxChars, maxLinks }: { maxChars: number; maxLinks: number }) => {83const text = (document.body.innerText || "").slice(0, maxChars);84const links = Array.from(document.querySelectorAll("a[href]"))85.slice(0, maxLinks)86.map((a) => {87const anchor = a as HTMLAnchorElement;88const t = (anchor.innerText || anchor.textContent || "").trim().slice(0, 120);89return { text: t, href: anchor.href };90})91.filter((l) => l.text && l.href);92return { url: location.href, title: document.title, text, links };93},94{ maxChars, maxLinks }95)) as { url: string; title: string; text: string; links: { text: string; href: string }[] };96},97}),98extract: tool({99description:100"Extract structured data from the current page using CSS selectors.",101inputSchema: z.object({102rowSelector: z.string(),103fields: z.array(z.object({104name: z.string(),105selector: z.string(),106attr: z.string().optional(),107})).min(1).max(10),108limit: z.number().int().positive().max(20).default(10),109}),110execute: async ({ rowSelector, fields, limit }) => {111if (!page) throw new Error("openSession must be called first.");112// Batch the extraction inside one page.evaluate — N*M serial113// CDP calls would cost seconds on a cloud browser.114const items = (await page.evaluate(115({ rowSelector, fields, limit }: {116rowSelector: string;117fields: { name: string; selector: string; attr?: string }[];118limit: number;119}) => {120const rows = Array.from(121document.querySelectorAll(rowSelector)122).slice(0, limit);123return rows.map((row) => {124const item: Record<string, string> = {};125for (const f of fields) {126const el = f.selector127? (row.querySelector(f.selector) as Element | null)128: row;129if (!el) { item[f.name] = ""; continue; }130if (f.attr) {131item[f.name] = (el.getAttribute(f.attr) ?? "").trim();132} else {133const text = (el as HTMLElement).innerText ?? el.textContent ?? "";134item[f.name] = text.trim();135}136}137return item;138});139},140{ rowSelector, fields, limit }141)) as Record<string, string>[];142return { count: items.length, items };143},144}),145// v6's needsApproval: destructive tools stream to the UI without executing,146// wait for user confirmation, then resume. Wire up approval UI on the client147// for this to run end-to-end.148submitForm: tool({149description: "Submit a form on the current page. Requires user approval.",150inputSchema: z.object({151reason: z.string().describe("Why this submission is safe."),152}),153needsApproval: true,154execute: async ({ reason }) => {155return { submitted: false, note: `Demo only. Reason: ${reason}` };156},157}),158},159// Phase-gate: no one can navigate before the session is open, and the160// agent can't open a second session.161prepareStep: async ({ stepNumber, steps }) => {162const opened = steps.some((s: any) =>163s.toolCalls?.some((tc: any) => tc.toolName === "openSession")164);165if (stepNumber === 0 || !opened) return { activeTools: ["openSession"] };166return { activeTools: ["navigate", "snapshot", "extract", "submitForm"] };167},168onStepFinish: async ({ toolCalls, usage }) => {169const names = toolCalls?.map((t: any) => t.toolName).join(", ") || "";170console.log(` step: ${names || "(text)"} | ${usage?.totalTokens ?? 0} tokens`);171},172onFinish: cleanup,173onAbort: cleanup,174});175176return result.toUIMessageStreamResponse();177}
Step 4: Client page
useChat handles the streaming protocol. We walk message.parts to find the tool-openSession output, which contains both debugUrl (the interactive embed we render in the iframe) and liveViewUrl (the shareable viewer link).
1"use client";2import { useChat } from "@ai-sdk/react";3import { useMemo, useState } from "react";45export default function Page() {6const { messages, sendMessage, status } = useChat();7const [input, setInput] = useState("");89const { debugUrl, liveViewUrl } = useMemo(() => {10for (const m of messages) {11for (const part of (m.parts ?? []) as any[]) {12if (part?.type === "tool-openSession" && part?.output) {13return {14debugUrl: (part.output.debugUrl ?? null) as string | null,15liveViewUrl: (part.output.liveViewUrl ?? null) as string | null,16};17}18}19}20return { debugUrl: null, liveViewUrl: null };21}, [messages]);2223return (24<main style={{ display: "grid", gridTemplateColumns: "1fr 1.3fr", height: "100vh" }}>25<section>26{messages.map((m) => (27<div key={m.id}>28{(m.parts ?? []).map((part: any, i: number) => {29if (part.type === "text") return <span key={i}>{part.text}</span>;30if (String(part.type).startsWith("tool-")) {31return (32<pre key={i}>33{String(part.type)} {part.state} {JSON.stringify(part.input)}34</pre>35);36}37return null;38})}39</div>40))}41<form42onSubmit={(e) => {43e.preventDefault();44if (!input.trim()) return;45sendMessage({ text: input });46setInput("");47}}48>49<input value={input} onChange={(e) => setInput(e.target.value)} />50<button disabled={status !== "ready"}>Send</button>51</form>52</section>53<aside>54{liveViewUrl && (55<a href={liveViewUrl} target="_blank" rel="noreferrer">open in new tab ↗</a>56)}57{debugUrl ? (58<iframe59src={debugUrl}60sandbox="allow-same-origin allow-scripts"61style={{ width: "100%", height: "100%", border: 0 }}62/>63) : (64<div>Live View appears once the agent opens a session.</div>65)}66</aside>67</main>68);69}
session.debugUrl is the interactive embed — it streams WebRTC video at 25 fps and, by default (?interactive=true), accepts mouse/keyboard input so a user can take over the session. See Live Sessions for supported query params. session.sessionViewerUrl is the shareable viewer page.
Step 5: Run
npm run dev
Open http://localhost:3000 and ask:
Go to https://github.com/trending/python and tell me the top 3 AI/ML repos.
The agent opens a Steel session (the Live View iframe fills in), navigates, extracts, and replies.
Deploying to Vercel
- 1Push to GitHub.
- 2Import the repo on Vercel.
- 3Add
STEEL_API_KEYandANTHROPIC_API_KEYas Environment Variables. - 4Set Build Command to:
npx playwright install chromium && next build
v6 lets destructive tools require a user approval step with needsApproval: true. The tool call emits to the UI without executing; you confirm, and the stream resumes. Useful for Steel sessions that post forms, make purchases, or modify remote state.
Next Steps
-
Quickstart (ToolLoopAgent): /integrations/ai-sdk/quickstart
-
useChatreference: https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat -
Human-in-the-loop tools: https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling
-
Steel Sessions API: /overview/sessions-api/overview
-
This example on GitHub: https://github.com/steel-dev/steel-cookbook/tree/main/examples/steel-ai-sdk-nextjs-starter