Skip to content

Full-Stack: the agent behind a web UI

The Full-Stack track puts the agent you built in Foundations behind a real web UI. It needs no new agent concepts, only some frontend comfort, so you can take it straight after Foundations, no Patterns required.

Rather than build the app from scratch, you start from a ready, production-shaped template and build on it: a single Hono server on Node that serves both the React useChat UI and the AI SDK agent, streaming the Vercel AI data-stream protocol. Pure TypeScript, one process, deployable like any Node app.

npx @jagreehal/ai-workshop fullstack-hono   # copies a fresh, history-free folder
cd fullstack-hono
npm install
npm run dev                                     # Vite + Hono on :5173 · trace viewer :4445

No API key needed: it runs on Ollama (granite4.1:3b) by default. Open http://localhost:5173.

One tool is wired all the way through, end to end: get_current_time. The model has no clock of its own, so asking “what time is it?” makes it call the tool, and you see the call render as a tool-call card in the chat. That round-trip (agent → tool → card) is the thing to understand; everything else is yours to extend.

The agent, with its model, instructions, and tools, lives in src/server/agent.ts; the chat route is src/server/api.ts. The UI in src/client/ infers its tool-part types straight from the tool definitions, so there are no duplicate schemas to keep in sync.

You are not learning React here. You are learning the end-to-end SHAPE of one tool flowing through a web app.

get_current_time is already wired all the way through, and your job is to copy that shape for get_weather. There are four pieces:

server tool definition  ->  server tool registry  ->  streamed tool part  ->  UI card

Read the existing path once before you add anything:

  1. src/tools/time-tool.ts The tool itself: tool({ description, inputSchema, execute }), plus the exported UI types inferred from that tool.
  2. src/tools/index.ts The registry: the tool is added to chatTools, and its UI types are re-exported.
  3. src/client/components/chat-tool-parts.tsx The card: one component that renders the tool part’s state, input, and output.
  4. src/client/Chat.tsx The switch: one case 'tool-get_current_time': that picks that card when the stream contains that tool part.

That is the whole transfer pattern. get_weather is not a new frontend concept; it is the same four moves with different fields.

Here is the important shape in miniature:

// 1. define a tool
export const getCurrentTimeTool = tool({ ... })

// 2. register it
export const chatTools = {
  get_current_time: getCurrentTimeTool,
} as const

// 3. render its card
export function GetCurrentTimeToolPart({
  invocation,
}: {
  invocation: GetCurrentTimeToolInvocation
}) {
  // read invocation.state / invocation.input / invocation.output
}

// 4. switch on the streamed part type
case 'tool-get_current_time':
  return <GetCurrentTimeToolPart invocation={part} />

If you can trace those four pieces for the time tool, you have everything you need for the weather tool. The React side is just rendering typed data the tool already defined.

  1. Run it. Ask “what time is it?” and watch the get_current_time card appear, then resolve with the result. Open the trace viewer on :4445 to see the same run as a span tree.

  2. Give the assistant a voice. Tighten the instructions in src/server/agent.ts: a house style, a format rule, a persona. Save; Vite hot-reloads. Ask again and watch the tone shift.

  3. Add your own tool. Copy the get_current_time path, piece by piece, for get_weather:

    • define getWeatherTool in src/tools/weather-tool.ts with tool({ description, inputSchema, execute }),
    • export its inferred UI types from the same file,
    • register it in src/tools/index.ts by adding get_weather to chatTools and re-exporting its types,
    • render its streamed part with a GetWeatherToolPart in src/client/components/chat-tool-parts.tsx,
    • add case 'tool-get_weather': in src/client/Chat.tsx so the chat picks that card.

    Then ask “what’s the weather in Lisbon?” and watch your new card render.

The model can’t invent a real weather feed, so, like the clock, it has to call your tool. That is the whole point of the track: the UI only shows what a tool returned.

  • Tool not in the registry. If get_weather isn’t added to chatTools in src/tools/index.ts, the agent can’t call it and no card ever streams.
  • A plain-text answer, no card. You wired the tool but not the UI: add the GetWeatherToolPart component and the case 'tool-get_weather': in Chat.tsx. Miss the case and the part falls through to text.
  • Type errors on the card. The UI tool types are inferred from the tool definition, so fix the tool’s inputSchema/return shape, not the card’s types, when they disagree.
  • The model won’t call it. Like the clock, weather must be something the model can’t already know; a vague tool description lets it answer from memory instead. The description is the interface (f6).

Open the reference only after you’ve traced the existing time tool end to end once.

Reference solution: the get_weather tool

src/tools/weather-tool.ts:

import { trace } from 'autotel'
import { type InferUITool, tool, type UIToolInvocation } from 'ai'
import { z } from 'zod'

const fetchWeather = trace({ name: 'weather.fetch' }, () => async ({ location }: { location: string }) => {
  // A real app calls a weather API here; the shape is what matters for the demo.
  return { location, temperature_c: 19, condition: 'clear' as const }
})

export const getWeatherTool = tool({
  description: 'Get the current weather for a location.',
  inputSchema: z.object({ location: z.string().describe('The city') }),
  execute: async ({ location }) => fetchWeather({ location }),
})

export type GetWeatherUITool = InferUITool<typeof getWeatherTool>
export type GetWeatherToolInvocation = UIToolInvocation<typeof getWeatherTool>

src/tools/index.ts, add it to the set:

import { getWeatherTool } from './weather-tool'

export const chatTools = {
  get_current_time: getCurrentTimeTool,
  get_weather: getWeatherTool,
} as const

export type { GetWeatherToolInvocation, GetWeatherUITool } from './weather-tool'
export { getWeatherTool }

Then add a GetWeatherToolPart in chat-tool-parts.tsx (rendering invocation.output.location / temperature_c / condition) and wire case 'tool-get_weather': into the switch in Chat.tsx, the same way tool-get_current_time is handled.