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. It is a FastAPI backend running a Pydantic AI agent, bridged to a React useChat UI by the Vercel AI UI adapter, the same streaming protocol the whole AI SDK ecosystem speaks.

npx @jagreehal/ai-workshop fullstack-pydantic   # copies a fresh, history-free folder
cd fullstack-pydantic
npm install
uv sync --extra dev
npm run dev                                         # UI :5173 · API :8000 · trace viewer :4446

No API key needed: it runs on Ollama (granite4.1:3b) by default. Set GOOGLE_GENERATIVE_AI_API_KEY to switch to Gemini, the same switch as the rest of the workshop. 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 whole bridge between a Pydantic AI agent and the React UI is one line in app/main.py:

return await VercelAIAdapter.dispatch_request(request, agent=agent, sdk_version=6)

You edit app/agent.py (the agent + its tools) and app/tool_models.py (the tool shapes). You do not write the streaming plumbing or the React app.

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 five pieces:

server output model  ->  @agent.tool_plain  ->  generated schemas  ->  client tool registry  ->  UI card + switch

Read the existing path once before you add anything:

  1. app/tool_models.py The tool’s output shape: a BaseModel (e.g. TimeOutput) the model fills and the UI reads.
  2. app/agent.py The tool itself: an @agent.tool_plain function whose docstring is the description the model reads, and whose return type is that model.
  3. npm run gen:tools The bridge: regenerates src/tools/schemas/tools.json and src/tools/types.ts from your Python tools, so the UI knows the new tool’s shape with no schema kept in sync by hand. (Pydantic AI’s job; the TypeScript path infers the same types directly from the tool definition.)
  4. src/tools/time-tool.ts + src/tools/index.ts The client contract and registry: a hand-written tool() wrapper that reads the generated schema, and the chatTools registry it is added to. The registry is what gives useChat its typed tool-* parts; the generated files alone do nothing.
  5. src/client/components/chat-tool-parts.tsx + src/client/Chat.tsx The card and the switch: one component renders the tool part’s state / input / output, and one case 'tool-get_current_time': 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 five moves with different fields.

Here is the important shape in miniature:

# 1. the output model (app/tool_models.py)
class TimeOutput(BaseModel):
    ...

# 2. the tool (app/agent.py), docstring is the description, return type is the model
@agent.tool_plain
async def get_current_time() -> TimeOutput:
    """Get the current time. ..."""
    ...
// 3+4. after `npm run gen:tools`, wrap the generated schema and register it (src/tools/)
export const getCurrentTimeTool = tool({
  description: getCurrentTime.description,
  inputSchema: jsonSchema<GetCurrentTimeInput>(getCurrentTime.inputSchema),
  outputSchema: jsonSchema<GetCurrentTimeOutput>(getCurrentTime.outputSchema),
})

export const chatTools = {
  get_current_time: getCurrentTimeTool,
} as const
// 5. render its card (chat-tool-parts.tsx) and switch on the part type (Chat.tsx)
export function GetCurrentTimeToolPart({
  invocation,
}: {
  invocation: GetCurrentTimeToolInvocation
}) {
  // read invocation.state / invocation.input / invocation.output
}

case 'tool-get_current_time':
  return <GetCurrentTimeToolPart invocation={part} />

If you can trace those five 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 :4446 to see the same run as a span tree.

  2. Give TripMate a voice. Tighten instructions= in app/agent.py with a house style, a format rule, or a persona. Save; the API hot-reloads. Ask again and watch the tone shift.

  3. Add your own tool. Author get_weather the same way get_current_time is wired:

    • add a WeatherOutput model in app/tool_models.py,
    • write an @agent.tool_plain body in app/agent.py,
    • run npm run gen:tools to regenerate the schemas (tools.json + types.ts),
    • wrap it for the client: a getWeatherTool in src/tools/weather-tool.ts modeled on time-tool.ts, registered in chatTools in src/tools/index.ts with its types re-exported,
    • render its card: add a GetWeatherToolPart in src/client/components/chat-tool-parts.tsx and a case 'tool-get_weather': in src/client/Chat.tsx.

    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 shows only what a tool returned.

  • Generated files only carry the schema. npm run gen:tools writes tools.json and types.ts; the weather-tool.ts contract and the chatTools entry in src/tools/index.ts are yours to write. Skip them and tool-get_weather never typechecks, and no card 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.
  • Stale contract. If tools.get_weather is missing on the TypeScript side, the schemas are stale: re-run npm run gen:tools (or keep npm run dev running; it watches app/agent.py and app/tool_models.py and regenerates on save).
  • The model won’t call it. The docstring is the description the model reads. Like the clock, weather must be something the model can’t already know; a vague docstring 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

app/tool_models.py:

class WeatherOutput(BaseModel):
    location: str
    temperature_c: int
    condition: str

app/agent.py:

from app.tool_models import TimeOutput, WeatherOutput

@agent.tool_plain
async def get_weather(location: str) -> WeatherOutput:
    """Get the current weather for a location. Call this whenever the user asks about weather."""
    # A real app calls a weather API here; the shape is what matters for the demo.
    return WeatherOutput(location=location, temperature_c=19, condition="clear")

Then npm run gen:tools, and wrap the generated schema in src/tools/weather-tool.ts:

import { type InferUITool, jsonSchema, tool, type UIToolInvocation } from 'ai'

import tools from './schemas/tools.json'
import type { GetWeatherInput, GetWeatherOutput } from './types'

const getWeather = tools.get_weather

export const getWeatherTool = tool({
  description: getWeather.description,
  inputSchema: jsonSchema<GetWeatherInput>(getWeather.inputSchema),
  outputSchema: jsonSchema<GetWeatherOutput>(getWeather.outputSchema),
})

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 type { GetWeatherInput, GetWeatherOutput } from './types'
export { getWeatherTool }

Finally, in src/client/components/chat-tool-parts.tsx:

import type { GetWeatherToolInvocation } from '@/tools'

export function GetWeatherToolPart({
  invocation,
}: {
  invocation: GetWeatherToolInvocation
}) {
  return (
    <Tool defaultOpen={invocation.state === 'output-available'}>
      <ToolHeader state={invocation.state} type="tool-get_weather" />
      <ToolContent>
        {invocation.input ? <ToolInput input={invocation.input} /> : null}
        <ToolOutput
          errorText={invocation.errorText}
          output={
            invocation.state === 'output-available' ? (
              <p className="text-sm">
                {invocation.output.location}: {invocation.output.temperature_c}°C,{' '}
                {invocation.output.condition}
              </p>
            ) : null
          }
        />
      </ToolContent>
    </Tool>
  )
}

Wire case 'tool-get_weather': into the switch in Chat.tsx, the same way tool-get_current_time is handled.