Featured resource
2026 Tech Forecast
2026 Tech Forecast

1,500+ tech insiders, business leaders, and Pluralsight Authors share their predictions on what’s shifting fastest and how to stay ahead.

Download the forecast
  • Lab
    • Libraries: If you want this lab, consider one of these libraries.
    • Core Tech
Labs

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 platform
Lab Info
Level
Beginner
Last updated
Oct 29, 2026
Duration
35m

Contact sales

By clicking submit, you agree to our Privacy Policy and Terms of Use, and consent to receive marketing emails from Pluralsight.
Table of Contents
  1. 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 uses

    All 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.json
    

    Every file you will edit lives under src/. The tests/ directory and src/main.tsx are already complete — do not modify them. ## Server function conventions

    Each 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_KEY at startup — never hardcoded and never passed through a browser request. You do not have to export it yourself in this lab: when you run npm 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, so process.env.OPENAI_API_KEY is 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=dot
    

    Run only the tests for the step you are working on:

    npx --no-install vitest run tests/step-2.test.ts --reporter=dot
    

    Green 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 solution folder.

  2. 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 openai npm package exposes a class constructor that accepts an apiKey option. The security requirement is simple: the key must come from process.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.content is 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 no choices at all: the Code Labs AI proxy opens every stream with one, which is why the code reads choices[0] with ?.. ## Writing chunks as server-sent events

    Server-sent events (SSE) are a simple HTTP streaming format: the server sets Content-Type: text/event-stream and writes lines of the form data: <payload>\n\n. Browsers parse this automatically through the EventSource API. Each double newline signals the end of one event so the browser can fire the onmessage callback immediately rather than waiting for more data. One detail to remember: an event's text can span several lines, but every line must start with data:. A model reply that contains a line break therefore has to be written as data: first line\ndata: second line\n\n, and the browser joins the lines back together with \n.

  3. 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 useChat hook — 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 history

    The browser's EventSource API only supports GET requests, so the message history must travel as a query parameter. Serialise the history array with JSON.stringify and encode it with encodeURIComponent before 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); };
    

    appendChunk and commitAssistantReply are 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 updaters

    When multiple SSE events arrive in rapid succession, each setMessages call 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 useRef is 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 .current array is mutated in place:

    const historyRef = useRef<Message[]>([]);
    // ...
    historyRef.current.push({ role: 'assistant', content });
    

    Because sendMessage reads the same ref when it builds the next URL, a committed reply reaches the very next request without any prop drilling.

  4. 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. The zod-to-json-schema package 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 a tool_calls array on the message. Your code must:

    1. Detect the tool_calls stop reason.
    2. Parse and validate the JSON arguments the model produced.
    3. Execute the local function (the rate lookup and multiplication).
    4. Append both the assistant's tool-call message and a { role: 'tool', ... } message carrying the result.
    5. Make a second completion request — this time without the tools parameter — 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.

  5. 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-mini to 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 copy

    The 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 the tone field 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 a ZodError with field-level detail before any API call is made. The validated tone then goes into the system message, and the product goes into the user message. ## Validating AI outputs, not just inputs

    Zod 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 undefined into the UI.

    The response_format: { type: 'json_object' } option available on gpt-4o-mini and 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 completion

    The 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 final EmailAnnotationSchema.parse gives you three independent layers of correctness checking on every annotation that reaches the triage feed. ## Where the React panels call your functions

    You do not need to change any React code in this step, but it helps to know how the panels in src/App.tsx use the functions you wrote:

    • MarketingPanel keeps the product and tone in state, then posts { product, tone } to /api/marketing and renders the copy in the response.
    • EmailTriagePanel posts each email to /api/email-enrich and 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.tsx and find the two fetch calls to see the request shape each of your server functions receives.

  6. 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 dev
    

    It 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 dev is 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 yourself

    The 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=dot
    

    Or 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

Pluralsight’s AI authoring technology is designed to accelerate the creation of hands-on, technical learning experiences. Serving as a first-pass content generator, it produces structured lab drafts aligned to learning objectives defined by Pluralsight’s Curriculum team. Each lab is then enhanced by our Content team, who configure the environments, refine instructions, and conduct rigorous technical and quality reviews. The result is a collaboration between artificial intelligence and human expertise, where AI supports scale and efficiency, and Pluralsight experts ensure accuracy, relevance, and instructional quality, helping learners build practical skills with confidence.

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.

Get started with Pluralsight