- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Core Tech
Build an AI-Powered React Interface
You are a developer building SmartDesk, an internal productivity dashboard for a financial services firm. SmartDesk surfaces four AI-powered panels: a streaming chat assistant, a currency-conversion tool grounded by exchange-rate data, a marketing-copy generator for campaign headlines and taglines, and an email triage feed that annotates each message with a summary and sentiment score. The company's security policy requires that API credentials never touch the browser, so every OpenAI call is routed through lightweight server functions. Each panel is already stubbed in the starter; your job is to implement the AI logic that makes them live.
Lab Info
Table of Contents
-
Challenge
Step 1: Explore the SmartDesk starter project
Welcome to SmartDesk
You are a developer at a financial services firm building SmartDesk, an internal productivity dashboard. SmartDesk surfaces four AI-powered panels:
- A streaming chat assistant for general queries
- A currency-conversion tool grounded by exchange-rate data
- A marketing copy generator for campaign headlines and taglines
- An email triage feed that annotates each message with a summary and sentiment score
The company's security policy is firm: API credentials must never reach the browser. Every OpenAI call is routed through lightweight server functions in
src/server/. Your job is to implement the AI logic that makes each panel live. ## Which model each panel usesAll four panels use the same model,
gpt-4o-mini, but each one uses it differently:- Chat streams a reply as it is generated (Tasks 2.2 to 3.3).
- Currency calls your Zod-validated tool, then writes the final answer (Tasks 4.1 to 4.3).
- Marketing writes copy in the tone you choose (Task 5.1).
- Email triage returns a JSON summary and sentiment score (Tasks 5.2 and 5.3).
Every request goes through the Code Labs AI proxy using this lab's key. Code Labs provides text models, so the marketing panel writes copy rather than generating images. ## Repository layout
smart-desk/ ├── src/ │ ├── server/ │ │ ├── openaiClient.ts # Step 2 — singleton client │ │ ├── chatStream.ts # Step 2 — streaming generator │ │ ├── chatHandler.ts # Step 2 — SSE HTTP handler │ │ ├── currencyAssistant.ts # Step 4 — grounded tool calls │ │ ├── marketingCopy.ts # Step 5 — marketing copy endpoint │ │ └── emailEnrich.ts # Step 5 — annotation pipeline │ ├── hooks/ │ │ └── useChat.ts # Step 3 — streaming chat hook │ └── main.tsx # Wired up for you — do not edit ├── tests/ │ ├── step-2.test.ts │ ├── step-3.test.ts │ ├── step-4.test.ts │ └── step-5.test.ts └── package.jsonEvery file you will edit lives under
src/. Thetests/directory andsrc/main.tsxare already complete — do not modify them. ## Server function conventionsEach file in
src/server/exports one or more named functions. They are pure Node.js modules — no framework magic. The important conventions are:- No browser imports. Server files may import from
openai,zod, and Node built-ins only. - Single responsibility. Each file owns one concern: client setup, streaming, a specific assistant, and so on.
- Environment variables only. The OpenAI API key is read from
process.env.OPENAI_API_KEYat startup — never hardcoded and never passed through a browser request. You do not have to export it yourself in this lab: when you runnpm run dev, the dev server reads the key Code Labs created for this lab (the one shown at the top of the page) and points the client at the lab's AI proxy, soprocess.env.OPENAI_API_KEYis already set when your code runs.
The Vitest suite for each step imports your exported symbols directly and checks their runtime behaviour, so the function names and export shapes must match exactly what is described in each task. ## How the test suite is wired
Each test file imports the symbols you export and runs them in a controlled Node.js environment where the OpenAI HTTP calls are intercepted by mock handlers — you never need a real API key to pass the checks.
Run the full suite at any time:
npx --no-install vitest run --reporter=dotRun only the tests for the step you are working on:
npx --no-install vitest run tests/step-2.test.ts --reporter=dotGreen dots mean passing tasks. A failing task shows the assertion that broke and the line in your source file to investigate. With that map in hand, move on to Step 2 and wire up secure model access.
info> If you get stuck, you can refer to the provided solution code for each task, available in the
solutionfolder. -
Challenge
Step 2: Configure secure model access and introduce streaming
Step 2: Configure secure model access and introduce streaming
Before any panel can call the OpenAI API you need three things in place: a server-side client that reads credentials from the environment, a generator function that yields response chunks as they stream in, and an HTTP handler that serialises those chunks as server-sent events (SSE). Building them in this order means each piece has exactly one dependency on the piece before it — the client is a value, the generator consumes the client, the handler consumes the generator. ## Initialising the OpenAI client securely
The
openainpm package exposes a class constructor that accepts anapiKeyoption. The security requirement is simple: the key must come fromprocess.env, not from source code or a browser request. The recommended pattern is to read the variable once at module load time, fail immediately if it is absent, and export the resulting client as a singleton so all server functions share one connection pool.import OpenAI from 'openai'; const apiKey = process.env.OPENAI_API_KEY; if (!apiKey) throw new Error('OPENAI_API_KEY is not set'); export const openaiClient = new OpenAI({ apiKey }); ``` ## Streaming completions with async generators The OpenAI SDK returns an async iterable when you pass `stream: true`. An `async function*` generator is the cleanest way to adapt that iterable into a form callers can `for await` over — it hides the SDK's internal event structure and exposes only the string content that matters. ```ts async function* streamCompletion(messages: MessageParam[]): AsyncGenerator<string> { const stream = await openaiClient.chat.completions.create({ model: 'gpt-4o-mini', messages, stream: true, }); for await (const chunk of stream) { yield chunk.choices[0]?.delta?.content ?? ''; } }The nullish coalesce on
delta.contentis important: the first and last chunks in a stream often carry metadata but no text, and yielding an empty string for those keeps the consumer loop uniform. That includes chunks with nochoicesat all: the Code Labs AI proxy opens every stream with one, which is why the code readschoices[0]with?.. ## Writing chunks as server-sent eventsServer-sent events (SSE) are a simple HTTP streaming format: the server sets
Content-Type: text/event-streamand writes lines of the formdata: <payload>\n\n. Browsers parse this automatically through theEventSourceAPI. Each double newline signals the end of one event so the browser can fire theonmessagecallback immediately rather than waiting for more data. One detail to remember: an event's text can span several lines, but every line must start withdata:. A model reply that contains a line break therefore has to be written asdata: first line\ndata: second line\n\n, and the browser joins the lines back together with\n. -
Challenge
Step 3: Build the multi-turn streaming chat flow
Step 3: Build the multi-turn streaming chat flow
With the server streaming chunks over SSE, the React side needs three coordinated behaviours: dispatching the user's message along with the conversation so far, appending each arriving token to the live message bubble, and committing the fully assembled reply to a history structure the next request can read. All three live in the
useChathook — keeping them in one place means the hook's internal state is the single source of truth for the conversation. Sending comes first, because it is what opens the stream the other two respond to. ## Opening the EventSource with full historyThe browser's
EventSourceAPI only supportsGETrequests, so the message history must travel as a query parameter. Serialise the history array withJSON.stringifyand encode it withencodeURIComponentbefore appending it to the URL. When a new message arrives, wire up the event handlers before the connection opens so no early chunks are missed:const es = new EventSource(`/api/chat?history=${encodeURIComponent(JSON.stringify(history.current))}`); let assembled = ''; es.onopen = () => setIsStreaming(true); es.onmessage = (e) => { assembled += e.data; appendChunk(e.data); }; es.onerror = () => { es.close(); commitAssistantReply(assembled); };appendChunkandcommitAssistantReplyare still stubs at this point. Calling them anyway means each one starts working the moment you implement it in the tasks that follow. ## Updating React state safely with functional updatersWhen multiple SSE events arrive in rapid succession, each
setMessagescall must see the result of the previous one. The functional updater form —setMessages(prev => ...)— guarantees this because React serialises the queue of updater functions rather than batching raw values. The pattern for appending a chunk looks like:setMessages(prev => { const updated = [...prev]; const last = { ...updated[updated.length - 1] }; last.content += chunk; updated[updated.length - 1] = last; return updated; });Spreading both the array and the target object ensures React detects a new reference at every level — direct mutation would silently skip re-renders. ## Storing history in a ref, not state
Conversation history is sent to the server but never rendered directly, so storing it in a
useRefis the right call — a ref update does not schedule a re-render, eliminating the flicker that would occur if every new token also triggered a history state change. The ref's.currentarray is mutated in place:const historyRef = useRef<Message[]>([]); // ... historyRef.current.push({ role: 'assistant', content });Because
sendMessagereads the same ref when it builds the next URL, a committed reply reaches the very next request without any prop drilling. -
Challenge
Step 4: Ground currency answers with instructions and validated tools
Step 4: Ground currency answers with instructions and validated tools
A general-purpose chat model will happily answer questions about cooking or poetry — that is not what the currency panel is for. Grounding the assistant requires two mechanisms working together: a system instruction that sets the assistant's role and explicitly forbids off-topic answers, and a tool the model can call to retrieve a real exchange rate rather than guessing one. Zod ties the two together by providing both the runtime schema that validates the model's tool call arguments and the JSON Schema the OpenAI API needs to describe the tool's parameters. ## System instructions as the first message
A system message is simply a
{ role: 'system', content: '...' }entry prepended to the messages array. Its content is processed by the model before any user turn, making it the most reliable place to establish persona and constraints. A well-formed instruction names the assistant's role, states what it will do, and explicitly states what it will not do:You are a currency-conversion assistant for a financial services firm. You only answer questions about currency exchange, conversion rates, and related financial topics. Politely decline all other requests.The test checks for a specific phrase — keep the wording exact. ## Describing tools with Zod and zod-to-json-schema
OpenAI function calling requires a JSON Schema for each tool's
parameters. Writing that schema by hand and keeping it in sync with your TypeScript types is error-prone. Thezod-to-json-schemapackage converts a Zod schema to the exact JSON Schema shape the API expects:import { z } from 'zod'; import zodToJsonSchema from 'zod-to-json-schema'; export const convertCurrencySchema = z.object({ from: z.string(), to: z.string(), amount: z.number(), }); export function buildCurrencyTools() { return [{ type: 'function' as const, function: { name: 'convertCurrency', description: 'Convert an amount from one currency to another.', parameters: zodToJsonSchema(convertCurrencySchema), }, }]; }Exporting the Zod schema separately lets the request handler reuse it for validation without reimporting the tool definition. ## The tool-call round trip
When the model decides to call a tool, it returns a response with
finish_reason: 'tool_calls'and atool_callsarray on the message. Your code must:- Detect the
tool_callsstop reason. - Parse and validate the JSON arguments the model produced.
- Execute the local function (the rate lookup and multiplication).
- Append both the assistant's tool-call message and a
{ role: 'tool', ... }message carrying the result. - Make a second completion request — this time without the
toolsparameter — so the model can compose a natural-language answer from the real computed value.
The second call is what produces the text the user actually reads. Without it, the model's response is the raw tool-call object, not a sentence.
- Detect the
-
Challenge
Step 5: Generate marketing copy and enrich emails with AI annotations
Step 5: Generate marketing copy and enrich emails with AI annotations
The final step wires up the two remaining panels. The marketing panel accepts a structured request from the React form and asks
gpt-4o-minito write copy in the chosen tone; the email enrichment pipeline runs every message through a completion that returns a machine-readable summary and sentiment score. Both use Zod as the validation layer — for the marketing endpoint it validates the inbound request, and for email enrichment it validates the outbound completion response. ## Structured inputs for marketing copyThe tone you pass into a system prompt should come from a known list, not from whatever string the form happens to send. Rather than letting an unexpected value reach the prompt, validate the request body with Zod at the server function boundary. Zod's
.default()modifier is particularly useful here — it means the React form can omit thetonefield entirely and still produce a valid prompt:const MarketingRequestSchema = z.object({ product: z.string().min(1), tone: z.enum(['professional', 'playful', 'urgent']).default('professional'), });With this schema in place,
MarketingRequestSchema.parse(body)either returns a fully typed object with a guaranteed-valid tone, or throws aZodErrorwith field-level detail before any API call is made. The validatedtonethen goes into the system message, and theproductgoes into the user message. ## Validating AI outputs, not just inputsZod is equally valuable on the way out of an AI call. When you instruct the model to return JSON and then parse its output, the Zod schema acts as a runtime contract between the model's text generation and your application's type system. If the model ignores the format instruction — or the API changes — the schema parse throws immediately with a clear message rather than silently propagating
undefinedinto the UI.The
response_format: { type: 'json_object' }option available ongpt-4o-miniand newer models provides a first line of defence: the API guarantees syntactically valid JSON. Zod's schema parse is the second line: it guarantees the JSON has the expected shape and field types. ## Running the enrichment completionThe system message for email enrichment needs to be precise: tell the model the exact JSON shape it must produce, list the valid sentiment values, and instruct it to respond with nothing else. A concrete example in the prompt greatly reduces the rate of schema validation failures:
You are an email analysis assistant. Respond ONLY with a JSON object in this exact shape: { "summary": "<one sentence>", "sentiment": "positive" | "neutral" | "negative" } Do not include any other text, markdown, or explanation.Combining this instruction with
response_format: { type: 'json_object' }and a finalEmailAnnotationSchema.parsegives you three independent layers of correctness checking on every annotation that reaches the triage feed. ## Where the React panels call your functionsYou do not need to change any React code in this step, but it helps to know how the panels in
src/App.tsxuse the functions you wrote:MarketingPanelkeeps the product and tone in state, then posts{ product, tone }to/api/marketingand renders thecopyin the response.EmailTriagePanelposts each email to/api/email-enrichand shows the returned summary and sentiment. The Simulate incoming email button adds a new message to the list and annotates it as soon as it arrives.
Open
src/App.tsxand find the twofetchcalls to see the request shape each of your server functions receives. -
Challenge
Step 6: Run the application
Every task is done, and each one was graded the moment you completed it. What you have not done yet is watch the whole application run at once -- which is the only place the pieces you wrote separately become a single working program.
Start the application
Open the Terminal tab and start the dev server:
npm run devIt serves the application on port 5173. Switch to the Web Browser tab to use it: the page is running the functions you implemented in the previous steps, so what you see is your own code rather than a demonstration of it.
Leave the server running while you look around. It reloads on save, so you can change a file and see the result without restarting anything.
What the page costs
Every panel action sends a real request through the Code Labs AI proxy and spends tokens from this lab's budget -- the header shows the running total against the limit of 10,000 tokens per session. A chat message or a marketing request is one call, a currency conversion is up to two (the tool call and the final answer), and Triage inbox sends one call per email. The tests never call the API, so they use none of the budget.
If a panel stays empty or shows something unexpected, check the terminal where
npm run devis running: it logs the reason for every failed request, such as a task that is not finished yet or an invalid form value. ### Run the checks yourselfThe lab ran each task's test for you as you went, but they are ordinary tests and you can run them whenever you like. The whole suite:
npx --no-install vitest run --reporter=dotOr one task at a time:
bash runTest.sh "task 2.1"Each failure names the task it belongs to, so the output tells you which step to go back to rather than which line to stare at. Two things worth trying before you finish: break something on purpose and watch the matching check fail, then put it back. Knowing what a failure looks like is worth as much as knowing what a pass looks like, and it costs you nothing here.
When you are done, everything you wrote is still in the editor tabs -- the lab is yours to keep reading.
About the author
Real skill practice before real-world application
Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.
Learn by doing
Engage hands-on with the tools and technologies you’re learning. You pick the skill, we provide the credentials and environment.
Follow your guide
All labs have detailed instructions and objectives, guiding you through the learning process and ensuring you understand every step.
Turn time into mastery
On average, you retain 75% more of your learning if you take time to practice. Hands-on labs set you up for success to make those skills stick.