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.
Get the template
Section titled “Get the template”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.
What you start with
Section titled “What you start with”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:
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.
The mechanic, on the tool already there
Section titled “The mechanic, on the tool already there”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:
Read the existing path once before you add anything:
app/tool_models.pyThe tool’s output shape: aBaseModel(e.g.TimeOutput) the model fills and the UI reads.app/agent.pyThe tool itself: an@agent.tool_plainfunction whose docstring is the description the model reads, and whose return type is that model.npm run gen:toolsThe bridge: regeneratessrc/tools/schemas/tools.jsonandsrc/tools/types.tsfrom 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.)src/tools/time-tool.ts+src/tools/index.tsThe client contract and registry: a hand-writtentool()wrapper that reads the generated schema, and thechatToolsregistry it is added to. The registry is what givesuseChatits typedtool-*parts; the generated files alone do nothing.src/client/components/chat-tool-parts.tsx+src/client/Chat.tsxThe card and the switch: one component renders the tool part’sstate/input/output, and onecase '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:
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.
Build it
Section titled “Build it”-
Run it. Ask “what time is it?” and watch the
get_current_timecard appear, then resolve with the result. Open the trace viewer on :4446 to see the same run as a span tree. -
Give TripMate a voice. Tighten
instructions=inapp/agent.pywith a house style, a format rule, or a persona. Save; the API hot-reloads. Ask again and watch the tone shift. -
Add your own tool. Author
get_weatherthe same wayget_current_timeis wired:- add a
WeatherOutputmodel inapp/tool_models.py, - write an
@agent.tool_plainbody inapp/agent.py, - run
npm run gen:toolsto regenerate the schemas (tools.json+types.ts), - wrap it for the client: a
getWeatherToolinsrc/tools/weather-tool.tsmodeled ontime-tool.ts, registered inchatToolsinsrc/tools/index.tswith its types re-exported, - render its card: add a
GetWeatherToolPartinsrc/client/components/chat-tool-parts.tsxand acase 'tool-get_weather':insrc/client/Chat.tsx.
Then ask “what’s the weather in Lisbon?” and watch your new card render.
- add a
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:toolswritestools.jsonandtypes.ts; theweather-tool.tscontract and thechatToolsentry insrc/tools/index.tsare yours to write. Skip them andtool-get_weathernever typechecks, and no card streams. - A plain-text answer, no card. You wired the tool but not the UI: add the
GetWeatherToolPartcomponent and thecase 'tool-get_weather':inChat.tsx. Miss the case and the part falls through to text. - Stale contract. If
tools.get_weatheris missing on the TypeScript side, the schemas are stale: re-runnpm run gen:tools(or keepnpm run devrunning; it watchesapp/agent.pyandapp/tool_models.pyand 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:
app/agent.py:
Then npm run gen:tools, and wrap the generated schema in src/tools/weather-tool.ts:
src/tools/index.ts, add it to the set:
Finally, in src/client/components/chat-tool-parts.tsx:
Wire case 'tool-get_weather': into the switch in Chat.tsx, the same way
tool-get_current_time is handled.