Vercel Logo

Web Surface

The agent is headless. The CLI is one surface. A web chat UI is another. The interesting thing is that the agent code doesn't change between them. The differences are all in the wrapper.

This lesson is a concept walkthrough. The CLI is the working demo; the web surface is the architectural sketch. The point is to make the separation obvious enough that you could build the web surface on a Friday afternoon without touching the agent.

Outcome

You can describe how a web surface reuses the headless agent, what the surface adds on top (persistence, streaming over HTTP, components for tool results), and where the boundary between agent and surface lives.

The Two Surfaces Side by Side

CLIWeb
OutputTerminal textChat bubbles
Tool callsstderr linesTool result components
Approvalstdin promptButton group
LifetimeProcess exitSession persistence
Streamingprocess.stdout.writeServer-sent events
InputOne shot from argvContinuous from the textarea

The agent code is the same column. The surface code is the column to its right.

What the Web Surface Adds

Persistence

A CLI session dies when the terminal closes. A web session shouldn't. The surface persists the conversation:

await db.saveMessages(sessionId, messages);
 
const messages = await db.loadMessages(sessionId);
const result = await agent.stream({ prompt, messages });

The agent doesn't know about the database. It receives messages (or doesn't) and proceeds. The surface decides whether to load and save.

Streaming over HTTP

The CLI streams to a TTY. The web surface streams to a browser. Server-sent events are the easy mechanism:

src/route.ts (sketch)
export async function POST(req: Request) {
  const { prompt, sessionId } = await req.json();
  const messages = await loadMessages(sessionId);
 
  const stream = new ReadableStream({
    async start(controller) {
      const result = await agent.stream({ prompt, messages });
      for await (const chunk of result.fullStream) {
        controller.enqueue(`data: ${JSON.stringify(chunk)}\n\n`);
      }
      controller.close();
    },
  });
 
  return new Response(stream, {
    headers: { "Content-Type": "text/event-stream" },
  });
}

Same chunk shape the CLI consumes, just over HTTP instead of stdout. The client renders each chunk as it arrives.

Tool results as components

The CLI renders tool results as text. The web surface can render them as React components:

ToolCLI renderingWeb rendering
readFile path and line countCode block with syntax highlighting
grepMatch count and first matchesSearch results with file links
bashCommand and exit codeTerminal output with exit code badge
writePath and bytesDiff view
editPath and "1 replacement"Inline diff
askUserQuestion textButton group with the options

The agent emits the same chunks. The surface chooses how to display them.

Resumable streams

A user closes the browser tab mid-response. They reopen it five minutes later. A web surface can resume the stream from where it left off (if the agent is still running) or pick up the persisted state (if it isn't). The CLI can't do this because there's no surface to come back to.

This is the place where persistence and streaming meet. The session is the unit of work. The stream is one render of that session.

The agent does not know there is a web

None of the web surface code goes inside the agent. The agent gets prompt and optional messages. It returns a stream of chunks. Everything else (auth, persistence, layout, components) lives in the surface. If you find yourself adding "is this web?" branches to the agent, that's the seam slipping. Pull the special case back out into the surface.

What's Missing in the Build-Along

This module's working code is the CLI. There's no web surface in the course repo. The decision is intentional. Building a working web frontend would take a module of its own (or several), and the teaching point is about separation, not about React.

If you want to try it: start with a Next.js route that wraps agent.stream(), pipe the chunks as SSE, and write a small client that consumes the events. You'll know your separation is clean if the agent code you wrote in Module 1 through Module 9 runs without changes.

Try It

This is a concept lesson. Check yourself:

  1. Without looking back, list what changes between CLI and web surfaces
  2. Sketch the data flow for a single user prompt in the web case. Where does persistence happen? Where does the stream cross the network?
  3. Pick a tool from your harness. Write down the CLI rendering, then sketch the React component for the web rendering. Are they reading the same chunk shape?

Commit

No code in this lesson. The next module (extensibility) is where event-based hooks open the door for tool result components and inline approval to plug in cleanly.

Done-When

  • You can explain why the agent code doesn't change between surfaces
  • You can sketch persistence, streaming, and component rendering for a web surface
  • You can identify what stays in the agent vs. what moves to the surface
Build a streaming web surface

Set up a Next.js route that wraps agent.stream({ prompt, messages }). Pipe each chunk as a server-sent event. On the client, consume the events with EventSource or fetch plus ReadableStream. Render text-delta chunks into a chat bubble. Render tool-call chunks as a spinner with the tool name. Replace the spinner with a small result card when tool-result arrives. Add a cancel button that aborts the stream and stores the partial result. The interesting part is the visual transition between text and tool calls without layout jank. How do you handle it?

Was this helpful?

supported.