Build Client Brief Architect

Build an AI app that turns client briefs into one-page website plans.

Introduction

30 Second Summary

A promising website enquiry can arrive as a jumble of goals, design ideas, deadlines, and extra features. Without a clear scope, a simple quote can grow into work the price never covered.

In this project, you will build Client Brief Architect, an AI-assisted discovery app that turns a fictional client request into a scoped plan for a $350 one-page website. An Express server keeps the Gemini API key out of browser code.

What You'll Build

A fictional bakery brief becomes a polished dashboard with a project name, audience, recommended sections, core features, design direction, and next steps that all fit the $350 offer.

By the end of this project, you'll have:

  • A client-ready $350 offer that shows four package boundaries before a potential client shares a brief.
  • A secure planning flow that sends fictional briefs through the server while keeping the Gemini API key away from the browser.
  • A responsive result dashboard with separate cards for Summary, Audience, Recommended Sections, Core Features, Design Direction, and Next Steps.
  • Secret Mission: Add a deadline field plus a Scope and Delivery Risks card for work outside the $350 package.

Are there any prerequisites?

Basic familiarity with HTML, CSS, and JavaScript is enough. Use only fictional client information because free-tier prompts and responses may be used to improve Google products.

Before We Start

This first checkpoint gives you a clear commitment before any hands-on work begins. You are creating Client Brief Architect to support fictional client discovery for a focused $350 one-page website package.

Set Up the JavaScript Workspace

Client Brief Architect needs a local JavaScript runtime before its browser can use a secure server. Node.js runs that server code on your computer.

Visual Studio Code keeps the project in one workspace. Express gives the future browser a server route to the Gemini API without adding framework overhead.

In this step, get ready to:
  • Prepare Visual Studio Code with Node.js v24.21.0.
  • Create the project workspace with the exact Express dependency.
  • Secure the Gemini API key in the current terminal session.
Install and verify your tools

Visual Studio Code provides the editor that holds your project files. Your starting point depends on whether it is already installed.

✔️ Visual Studio Code is installed

Visual Studio Code is ready. Continue below to open a fresh window through your operating system search.

ⓧ I need to install Visual Studio Code

Use the official installation path for your operating system. This keeps the editor updates compatible with your computer.

macOS

Windows

  • Open the official Visual Studio Code setup guide.
  • Download the User Setup .exe file.
  • Run the installer.
  • Complete the prompts using the recommended settings.

Linux

  • Open the official Visual Studio Code setup guide.
  • Select the package instructions for your Linux distribution.
  • Complete the documented installation steps.
  • Press Cmd+Space on macOS or the Windows key on Windows to open system search.
  • Type Visual Studio Code into system search.
  • Press Enter to open Visual Studio Code.
  • Use your application launcher to search for Visual Studio Code on Linux.
  • Select Visual Studio Code from the Linux search results.
  • Select View from the Visual Studio Code menu bar.
  • Select Terminal to open a fresh integrated terminal.
  • Check the installed Node.js version by running this command:
node --version

What does this command check?

The command asks Node.js to print its installed version. This project uses v24.21.0 so the server can use the built-in Fetch API later.

✔️ I see v24.21.0

Your JavaScript runtime is ready. The exact version confirms that your environment matches the project.

ⓧ I see an older version

The installed runtime is below the project version. Upgrade it before creating the workspace.

  • Close Visual Studio Code.
  • Open the official Node.js download page.
  • Choose Node.js v24.21.0 LTS for your operating system.
  • Run the official installer on macOS or Windows.
  • Follow the official package instructions for your Linux distribution.
  • Return to Visual Studio Code through your operating system search.
  • Open a fresh integrated terminal from the View menu.
  • Confirm the upgraded version by running this command:
node --version

What should you see?

The fresh terminal should print v24.21.0. Reopening Visual Studio Code lets the terminal read the updated Node.js installation.

ⓧ Command not found

Your computer cannot find Node.js yet. Install the project version before continuing.

  • Close Visual Studio Code.
  • Open the official Node.js download page.
  • Choose Node.js v24.21.0 LTS for your operating system.
  • Run the official installer on macOS or Windows.
  • Follow the official package instructions for your Linux distribution.
  • Return to Visual Studio Code through your operating system search.
  • Open a fresh integrated terminal from the View menu.
  • Confirm the installation by running this command:
node --version

What should you see?

The fresh terminal should print v24.21.0. That output confirms that Visual Studio Code can access Node.js.

Still seeing the wrong Node.js version?

Close every Visual Studio Code window after installing Node.js. Reopen the editor so its terminal receives the updated system path.

On Linux, confirm that you followed the package instructions for your distribution. Help me diagnose why the Visual Studio Code terminal cannot find Node.js v24.21.0.

That is the first gate cleared. Your editor can now run the JavaScript server you build next.

Create the project workspace

A Visual Studio Code workspace keeps the terminal focused on one named folder. The project configuration records the exact server dependency required by Client Brief Architect.

  • Select File from the Visual Studio Code menu bar.
  • Select Open Folder....
  • Select your Desktop in the file picker.
  • Select New Folder.
  • Enter client-brief-architect as the folder name.
  • Select Select Folder on Windows or Linux.
  • Select Open on macOS.
  • Confirm that you trust the empty folder if Visual Studio Code shows a workspace trust prompt.

You will see client-brief-architect at the top of the Explorer sidebar. The folder is now the active workspace.

  • Select View from the Visual Studio Code menu bar.
  • Select Terminal to open a terminal inside client-brief-architect.
  • Create the initial npm package by running this command:
npm init -y

What does this command create?

  • The npm init command creates the package configuration for the workspace.
  • The -y option accepts the default answers without opening a questionnaire.
  • The generated package.json gives npm a place to record scripts and dependencies.
  • Confirm that package.json appears under client-brief-architect in the Explorer sidebar.

Package file missing?

Confirm that the terminal path ends in client-brief-architect. A terminal opened before the folder became your workspace may still point somewhere else.

Help me find why npm did not create package.json in my client-brief-architect folder.

  • Install the exact Express dependency by running this command:
npm install express@5.2.1 --save-exact

What does this command install?

  • The command downloads Express version 5.2.1 into the workspace.
  • The --save-exact option records that exact version in package.json.
  • An exact dependency keeps the project behavior consistent across installations.
  • Confirm that the terminal returns to its prompt without an installation error.
  • Confirm that the dependencies section in package.json lists Express as 5.2.1.

Express installation failed?

Confirm that the Node.js version check succeeded before retrying the installation. A network restriction can also prevent npm from downloading the package.

Help me diagnose why npm cannot install Express 5.2.1 in my client-brief-architect workspace.

The generated package contains defaults that this project does not need. A focused configuration gives the server one clear start command.

  • Select package.json in the Explorer sidebar.
  • Replace its entire contents with the configuration below:
{
  "name": "client-brief-architect",
  "version": "1.0.0",
  "private": true,
  "description": "Plan a scoped one-page website from a fictional client brief.",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "5.2.1"
  }
}

What does this configuration control?

  • The private setting prevents accidental publication as an npm package.
  • The start script defines how npm launches server.js.
  • The dependency entry pins Express to version 5.2.1.
  • Save package.json.
  • Confirm that the saved file shows the start script as node server.js.

Seeing a JSON warning?

Check that every key uses double quotes. Confirm that the final dependency entry has no trailing comma.

Help me compare my package.json with the required Client Brief Architect configuration.

  • Right-click client-brief-architect in the Explorer sidebar.
  • Select New Folder.
  • Enter public as the folder name.
  • Press Enter to create the folder.

The Explorer sidebar now shows an empty public folder beside package.json. This folder is ready for the browser files in the next step.

Protect the Gemini API key

An environment variable gives the server access to a credential without storing it in project files. Google AI Studio provides the key used for Gemini requests.

Keep the credential private

Handling a new API key can feel risky. You will store it only in the current terminal session without printing its value or placing it in browser code.

  • Open the Google AI Studio API Keys page in your browser.
  • Sign in with your Google account if prompted.
  • Copy the automatically created key if Google AI Studio displays one.
  • Select Create API key if no key is available.
  • Copy the newly created key.
  • Return to the Visual Studio Code terminal from earlier.

macOS

  • Set the key for the current terminal by running the command below with YOUR_API_KEY replaced by your copied key:
export GEMINI_API_KEY="YOUR_API_KEY"

What does this command do?

The command sets GEMINI_API_KEY only in the current terminal environment. Closing that terminal removes the session-scoped value.

Windows

  • Set the key for the current PowerShell session by running the command below with YOUR_API_KEY replaced by your copied key:
$Env:GEMINI_API_KEY = "YOUR_API_KEY"

What does this command do?

The command sets GEMINI_API_KEY only in the current PowerShell environment. Closing that terminal removes the session-scoped value.

Linux

  • Set the key for the current terminal by running the command below with YOUR_API_KEY replaced by your copied key:
export GEMINI_API_KEY="YOUR_API_KEY"

What does this command do?

The command sets GEMINI_API_KEY only in the current terminal environment. Closing that terminal removes the session-scoped value.

Before you run the final checks, what two lines do you expect the terminal to print?

  • Verify the Node.js version and the presence of the key by running these commands:
node --version
node -e "console.log(process.env.GEMINI_API_KEY ? 'Gemini key loaded' : 'Gemini key missing')"

What should you see?

The first line prints v24.21.0. The second line prints Gemini key loaded without revealing the credential.

Seeing Gemini key missing?

Run the environment command again in the same terminal used to set the key. A new terminal does not inherit a value that was set only in an earlier terminal session.

Confirm that the variable name is exactly GEMINI_API_KEY. Help me diagnose why my safe environment check prints Gemini key missing.

Your workspace is ready to support secure Gemini requests. The runtime works without exposing the key in the terminal output.

✔️ Awesome, I've got everything!

Your Visual Studio Code workspace now has the exact package configuration. The current terminal can access the Gemini API key without displaying it.

ⓧ I'd like to double check the full code

{
  "name": "client-brief-architect",
  "version": "1.0.0",
  "private": true,
  "description": "Plan a scoped one-page website from a fictional client brief.",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "5.2.1"
  }
}
  • Confirm that Visual Studio Code has client-brief-architect open as the workspace.
  • Confirm that package.json matches the full file above.
  • Confirm that the empty public folder appears in the Explorer sidebar.
  • Confirm that Node.js reports v24.21.0.
  • Confirm that Express is pinned to 5.2.1.
  • Confirm that the current terminal reports Gemini key loaded without showing the key value.

Your JavaScript workspace is prepared. Next, you will turn the empty browser folder into the $350 offer and fictional brief screen.

Build the $350 Offer and Brief Screen

A productized service needs clear boundaries before a potential client shares a request. Your interface will present the $350 offer before collecting a fictional brief.

This first browser flow will display the submitted request without interpreting it. Seeing that limitation gives you a concrete baseline for the next stage of the project.

In this step, get ready to:
  • Serve the browser files through Express.
  • Build the responsive offer screen.
  • Echo a fictional brief into the results area.
Start the local Express server

The browser needs a local server that can deliver your files from the public folder. JSON parsing also prepares the server to accept brief data in a later step.

  • Use the new-file control in the VS Code file sidebar to create server.js inside client-brief-architect.
  • Paste this server code into server.js:
const express = require("express");

const app = express();
const port = 3000;

app.use(express.json({ limit: "20kb" }));
app.use(express.static("public"));

app.listen(port, (error) => {
  if (error) {
    throw error;
  }

  console.log(`Client Brief Architect is running at http://localhost:${port}`);
});

What Does This Server Do?

  • The express.json() middleware prepares the server to read JSON request bodies.
  • The express.static("public") middleware serves the browser files stored in public.
  • The listener starts the application on port 3000.
  • Save server.js.
  • Start the local server from the existing VS Code terminal by running:
npm start

What Should You See?

The terminal prints Client Brief Architect is running at http://localhost:3000. Keep this terminal running while you build the page.

Server Not Starting?

Confirm that server.js sits beside package.json. Check that the terminal is still inside client-brief-architect.

If the port is already occupied, stop the earlier process with Ctrl+C before running the start command again.

Ask for help with the local server error.

Build the offer screen

The page needs to establish the package before asking for a brief. A visible price plus four scope boundaries gives the request a commercial frame.

  • Use the new-file control in the VS Code file sidebar to create index.html inside public.
  • Paste the document shell into public/index.html:
<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Client Brief Architect</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <main class="shell">
    <header class="hero">
      <p class="eyebrow">AI project discovery</p>
      <h1>Client Brief Architect</h1>
      <p>Turn a website request into a practical plan for a clearly scoped starter package.</p>
    </header>
  </main>

  <script src="app.js"></script>
</body>
</html>

What Does This Structure Create?

The document shell connects style.css in the page head. The hero introduces the discovery tool before the offer appears.

  • Save public/index.html.
  • Open http://localhost:3000 in your browser.

You will see the Client Brief Architect heading with its introductory sentence. Your local server is now delivering the browser page.

Page Not Loading?

Confirm that the terminal still shows the running server. Check that index.html is inside public.

Ask for help finding the missing page.

  • Insert the package offer immediately after the closing </header> tag in public/index.html:
    <section class="offer" aria-labelledby="offer-title">
      <div>
        <p class="eyebrow">Starter website package</p>
        <h2 id="offer-title">One-page business website</h2>
      </div>
      <p class="offer-price">$350</p>
      <ul>
        <li>One responsive page</li>
        <li>Client-supplied copy and images</li>
        <li>One revision round</li>
        <li>Custom scope quoted separately</li>
      </ul>
    </section>

How Does the Offer Protect Scope?

The offer names the price before listing what the client receives. The fourth boundary makes work outside the starter package a separate quote.

  • Save public/index.html.
  • Refresh the browser page.

You will see the $350 offer below the hero. Four bullets define the package boundaries.

Offer Missing From the Page?

Confirm that the offer section sits inside <main class="shell">. Check that every opening tag has its matching closing tag.

Ask for help checking the offer markup.

  • Insert the brief panel after the closing offer </section> tag in public/index.html:
    <section class="panel input-panel">
      <p class="notice">Use fictional information only. Free-tier prompts may be used to improve Google products.</p>

      <form id="brief-form">
        <label for="brief">Fictional client brief</label>
        <textarea
          id="brief"
          name="brief"
          minlength="30"
          maxlength="3000"
          required
          placeholder="Example: A neighborhood bakery needs a warm, mobile-friendly page that shows its menu, tells its story, and makes custom cake enquiries easier."
        ></textarea>
        <button id="submit-button" type="submit">Generate Project Plan</button>
      </form>

      <p id="status-message" class="status" role="status" aria-live="polite"></p>
    </section>

Why Use Fictional Briefs?

Free-tier prompts may be used to improve Google products. The notice keeps confidential client information out of this learning project.

The form gives the textarea a required minimum length. The live status area provides a place for progress or feedback.

  • Save public/index.html.
  • Refresh the browser page.

You will see the fictional-data notice above the brief field. The Generate Project Plan button sits below the textarea.

Form Controls Not Showing?

Confirm that the form uses id="brief-form". Check that the textarea uses id="brief".

Ask for help checking the brief form structure.

  • Insert the first results cards after the closing input-panel </section> tag in public/index.html:
    <section id="results" class="results" hidden>
      <div class="result-heading">
        <p class="eyebrow">Generated concept</p>
        <h2 id="project-name"></h2>
      </div>

      <article class="card card-wide">
        <h3>Summary</h3>
        <p id="summary"></p>
      </article>

      <article class="card">
        <h3>Audience</h3>
        <p id="audience"></p>
      </article>

      <article class="card">
        <h3>Design Direction</h3>
        <p id="design-direction"></p>
      </article>
    </section>

Why Start the Results Hidden?

The hidden attribute keeps empty result cards out of view. The browser script reveals the area after a brief is submitted.

  • Save public/index.html.
  • Use your browser developer tools to inspect #results.

The DOM contains the hidden results section. The page remains focused on the offer until a submission occurs.

Results Section Missing From the DOM?

Confirm that #results sits inside the main shell. Check that you inserted it after the input panel.

Ask for help locating the hidden results section.

  • Insert the remaining result cards immediately before the closing tag for #results:
      <article class="card">
        <h3>Recommended Sections</h3>
        <ul id="sections"></ul>
      </article>

      <article class="card">
        <h3>Core Features</h3>
        <ul id="features"></ul>
      </article>

      <article class="card card-wide">
        <h3>Next Steps</h3>
        <ol id="next-steps"></ol>
      </article>

What Will These Cards Hold?

The cards reserve named destinations for sections, features, and delivery actions. Their identifiers let the browser populate each area without inserting raw markup.

  • Save public/index.html.
  • Inspect #results in the browser developer tools again.

You will find six named result cards plus the generated-concept heading. The entire section remains hidden until the form script reveals it.

Cards Outside the Results Section?

Confirm that the three new article elements appear before the closing tag for #results. A misplaced closing tag can push them outside the results grid.

Ask for help checking the card nesting.

The page now contains every part of the offer and intake flow. Styling will turn that structure into a responsive client-facing screen.

  • Use the new-file control in the VS Code file sidebar to create style.css inside public.
  • Paste the page foundation into public/style.css:
:root {
  color-scheme: light;
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  color: #172033;
  background: #eef2ff;
}

* {
  box-sizing: border-box;
}

body {
  min-height: 100vh;
  margin: 0;
  background:
    radial-gradient(circle at top left, rgba(124, 58, 237, 0.22), transparent 34rem),
    linear-gradient(145deg, #f8fafc, #eef2ff);
}

button,
textarea,
input {
  font: inherit;
}

What Does the Foundation Control?

The root rules establish the type system plus the light purple palette. Universal border sizing keeps padded controls within their intended widths.

  • Save public/style.css.
  • Refresh the browser page.

You will see a pale purple gradient behind the page. The text changes to the shared system font stack.

Background Still Plain?

Confirm that style.css is inside public beside index.html.

Ask for help checking the stylesheet connection.

  • Add the shell and hero layout at the bottom of public/style.css:
.shell {
  width: min(1080px, calc(100% - 2rem));
  margin: 0 auto;
  padding: 4rem 0;
}

.hero {
  max-width: 720px;
  margin-bottom: 2rem;
}

.hero h1 {
  margin: 0.25rem 0 0.75rem;
  font-size: clamp(2.4rem, 7vw, 5rem);
  line-height: 0.98;
}

.hero > p:last-child {
  max-width: 620px;
  margin: 0;
  color: #526078;
  font-size: 1.15rem;
}

How Does the Hero Scale?

The shell caps the content width while preserving space at both edges. The clamp() value lets the main heading grow with the viewport.

  • Save public/style.css.
  • Refresh the browser page.

You will see the page centered within a wide shell. The Client Brief Architect heading now scales prominently above the offer.

Hero Still Unstyled?

Check that the page uses class="shell" and class="hero" exactly. A missing class prevents these selectors from matching.

Ask for help matching the hero selectors.

  • Add the eyebrow and surface styles at the bottom of public/style.css:
.eyebrow {
  margin: 0;
  color: #6d28d9;
  font-size: 0.78rem;
  font-weight: 800;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

.offer,
.panel,
.card,
.result-heading {
  border: 1px solid rgba(100, 116, 139, 0.2);
  border-radius: 1.25rem;
  background: rgba(255, 255, 255, 0.92);
  box-shadow: 0 18px 55px rgba(67, 56, 202, 0.1);
}

.offer {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 0.75rem 2rem;
  margin-bottom: 1rem;
  padding: clamp(1.25rem, 4vw, 2rem);
}

How Do the Surfaces Fit Together?

The shared surface rules give the offer, panel, and cards one visual language. The offer grid keeps its title opposite the price on wider screens.

  • Save public/style.css.
  • Refresh the browser page.

You will see the offer inside a rounded white surface. The purple eyebrow labels now appear in uppercase.

Surface Styles Not Appearing?

Confirm that the selector list ends with an opening brace. Check that every rule closes with a matching brace.

Ask for help checking the shared CSS surfaces.

  • Add the offer details at the bottom of public/style.css:
.offer h2 {
  margin: 0.35rem 0 0;
}

.offer-price {
  margin: 0;
  color: #5b21b6;
  font-size: clamp(2.25rem, 6vw, 4rem);
  font-weight: 900;
}

.offer ul {
  grid-column: 1 / -1;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0.5rem 2rem;
  margin: 0.5rem 0 0;
  padding-left: 1.25rem;
  color: #526078;
}

Why Make the Price Prominent?

The large purple price makes the commercial offer immediately visible. The two-column scope list lets potential clients scan the package boundaries quickly.

  • Save public/style.css.
  • Refresh the browser page.

You will see the $350 price aligned with the offer title. The four package boundaries sit in two columns on a wide screen.

Price or Scope Layout Looks Wrong?

Confirm that the price uses class="offer-price". Check that the scope list remains inside the offer section.

Ask for help checking the offer layout.

  • Add the input-panel spacing at the bottom of public/style.css:
.input-panel {
  padding: clamp(1.25rem, 4vw, 2rem);
}

.notice {
  margin-top: 0;
  border-left: 4px solid #7c3aed;
  padding: 0.75rem 1rem;
  color: #4c1d95;
  background: #f5f3ff;
}

label {
  display: block;
  margin-bottom: 0.55rem;
  font-weight: 750;
}

How Does the Notice Stand Out?

The tinted background separates the fictional-data notice from the form. Its purple border connects the warning to the page palette.

  • Save public/style.css.
  • Refresh the browser page.

You will see the notice inside a pale purple strip. The brief label now sits clearly above the textarea.

Notice Blending Into the Form?

Confirm that the notice paragraph uses class="notice". Check the border and background declarations for missing punctuation.

Ask for help checking the notice style.

  • Add the form-control styles at the bottom of public/style.css:
textarea,
input {
  width: 100%;
  border: 1px solid #cbd5e1;
  border-radius: 0.85rem;
  padding: 0.9rem 1rem;
  color: #172033;
  background: #ffffff;
}

textarea {
  min-height: 190px;
  resize: vertical;
}

textarea:focus,
input:focus {
  border-color: #7c3aed;
  outline: 3px solid rgba(124, 58, 237, 0.16);
}

How Do the Controls Stay Usable?

The textarea fills the available width with a comfortable minimum height. The visible focus outline helps keyboard users track the active field.

  • Save public/style.css.
  • Refresh the browser page.
  • Select the fictional brief textarea.

You will see a rounded full-width textarea. A purple outline appears while the field has focus.

Textarea Still Uses Browser Defaults?

Confirm that the field is a <textarea> element. Check that the preceding CSS rule closes before the textarea selector begins.

Ask for help checking the form-control CSS.

  • Add the button and status styles at the bottom of public/style.css:
button {
  margin-top: 1rem;
  border: 0;
  border-radius: 999px;
  padding: 0.85rem 1.2rem;
  color: #ffffff;
  background: #6d28d9;
  font-weight: 800;
  cursor: pointer;
}

button:hover {
  background: #5b21b6;
}

button:disabled {
  cursor: wait;
  opacity: 0.65;
}

.status {
  min-height: 1.5rem;
  margin-bottom: 0;
  color: #475569;
}

.status[data-state="error"] {
  color: #b91c1c;
}

.status[data-state="success"] {
  color: #166534;
}

What Do the Status Styles Communicate?

The button uses the page accent color as the primary action. Status states reserve red for errors plus green for successful results.

  • Save public/style.css.
  • Refresh the browser page.
  • Move your pointer over the Generate Project Plan button.

You will see a rounded purple button below the textarea. Its background becomes darker while your pointer is over it.

Button Not Changing on Hover?

Confirm that the button element remains inside the form. Check that the button:hover selector follows the main button rule.

Ask for help checking the button states.

  • Add the results layout at the bottom of public/style.css:
.results {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 1rem;
  margin-top: 1.5rem;
}

.result-heading,
.card {
  padding: 1.35rem;
}

.result-heading,
.card-wide {
  grid-column: 1 / -1;
}

.result-heading h2,
.card h3 {
  margin: 0.35rem 0 0.75rem;
}

.card p,
.card li {
  color: #526078;
  line-height: 1.65;
}

.card ul,
.card ol {
  margin-bottom: 0;
  padding-left: 1.25rem;
}

How Will the Results Read?

The results use two columns for compact cards. Wide cards span both columns so summaries plus next steps have more room.

  • Save public/style.css.
  • Refresh the browser page.

The page still hides the empty results area. Its grid styles are ready for the echoed brief you will reveal next.

Results Break After Submission?

Check that the results section uses both id="results" and class="results". Confirm that each result item keeps its card class.

Ask for help checking the results grid.

  • Add the mobile layout at the bottom of public/style.css:
@media (max-width: 700px) {
  .shell {
    padding: 2rem 0;
  }

  .offer,
  .results {
    grid-template-columns: 1fr;
  }

  .offer ul,
  .card-wide {
    grid-column: auto;
  }

  .offer ul {
    grid-template-columns: 1fr;
  }
}

What Changes on Small Screens?

The media query turns the offer plus results into single-column layouts below 700px. The package boundaries stack vertically for easier reading.

  • Save public/style.css.
  • Narrow the browser window below 700px.

You will see the offer title plus price stack into one column. The four scope boundaries also form one vertical list.

Mobile Layout Still Has Two Columns?

Confirm that the media query sits after the main offer and results rules. Check that its closing brace appears at the end of the stylesheet.

Ask for help checking the responsive breakpoint.

Echo the brief and verify the screen

The form currently collects input without showing what happened to it. A temporary echo makes the browser interaction visible while preserving the limitation of manual interpretation.

  • Use the new-file control in the VS Code file sidebar to create app.js inside public.
  • Paste the temporary echo behavior into public/app.js:
const form = document.querySelector("#brief-form");
const briefInput = document.querySelector("#brief");
const submitButton = document.querySelector("#submit-button");
const statusMessage = document.querySelector("#status-message");
const results = document.querySelector("#results");

function setStatus(message, state = "") {
  statusMessage.textContent = message;
  statusMessage.dataset.state = state;
}

form.addEventListener("submit", (event) => {
  event.preventDefault();

  const brief = briefInput.value.trim();

  if (!brief) {
    results.hidden = true;
    setStatus("Please enter a fictional brief.", "error");
    return;
  }

  document.querySelector("#project-name").textContent = "Echoed fictional brief";
  document.querySelector("#summary").textContent = brief;
  results.hidden = false;
  setStatus("Project plan ready.", "success");
});

What Does the Echo Script Do?

  • The selectors connect the script to the form, input, button, status message, and results area.
  • The submit listener prevents the browser from reloading the page.
  • The temporary result copies the fictional brief into the Summary card.
  • The status helper updates the message plus its visual state.
  • Save public/app.js.
  • Refresh http://localhost:3000.
  • Paste A neighborhood bakery needs a warm, mobile-friendly page that shows its menu, tells its story, and makes custom cake enquiries easier. into the fictional brief field.

Before you submit the form, do you expect the brief to become a project plan or remain unchanged?

  • Select Generate Project Plan.

You will see the results area appear below the form. The Summary card repeats the fictional brief exactly as entered.

That limitation is intentional. The interface can collect and display a request, but it cannot yet turn the request into a useful website plan.

Brief Not Appearing?

Confirm that app.js is inside public beside the other browser files. Check that the script tag appears near the end of index.html.

Confirm that the form plus result identifiers match the script exactly. A typo in #brief-form or #results prevents the interaction.

Ask for help debugging the temporary echo.

✔️ Awesome, I've got everything!

Your local Express app now presents the complete $350 offer. It also echoes a submitted fictional brief into the responsive result dashboard.

ⓧ I'd like to double check the full code

{
  "name": "client-brief-architect",
  "version": "1.0.0",
  "private": true,
  "description": "Plan a scoped one-page website from a fictional client brief.",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "5.2.1"
  }
}
const express = require("express");

const app = express();
const port = 3000;

app.use(express.json({ limit: "20kb" }));
app.use(express.static("public"));

app.listen(port, (error) => {
  if (error) {
    throw error;
  }

  console.log(`Client Brief Architect is running at http://localhost:${port}`);
});
<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Client Brief Architect</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <main class="shell">
    <header class="hero">
      <p class="eyebrow">AI project discovery</p>
      <h1>Client Brief Architect</h1>
      <p>Turn a website request into a practical plan for a clearly scoped starter package.</p>
    </header>

    <section class="offer" aria-labelledby="offer-title">
      <div>
        <p class="eyebrow">Starter website package</p>
        <h2 id="offer-title">One-page business website</h2>
      </div>
      <p class="offer-price">$350</p>
      <ul>
        <li>One responsive page</li>
        <li>Client-supplied copy and images</li>
        <li>One revision round</li>
        <li>Custom scope quoted separately</li>
      </ul>
    </section>

    <section class="panel input-panel">
      <p class="notice">Use fictional information only. Free-tier prompts may be used to improve Google products.</p>

      <form id="brief-form">
        <label for="brief">Fictional client brief</label>
        <textarea
          id="brief"
          name="brief"
          minlength="30"
          maxlength="3000"
          required
          placeholder="Example: A neighborhood bakery needs a warm, mobile-friendly page that shows its menu, tells its story, and makes custom cake enquiries easier."
        ></textarea>
        <button id="submit-button" type="submit">Generate Project Plan</button>
      </form>

      <p id="status-message" class="status" role="status" aria-live="polite"></p>
    </section>

    <section id="results" class="results" hidden>
      <div class="result-heading">
        <p class="eyebrow">Generated concept</p>
        <h2 id="project-name"></h2>
      </div>

      <article class="card card-wide">
        <h3>Summary</h3>
        <p id="summary"></p>
      </article>

      <article class="card">
        <h3>Audience</h3>
        <p id="audience"></p>
      </article>

      <article class="card">
        <h3>Design Direction</h3>
        <p id="design-direction"></p>
      </article>

      <article class="card">
        <h3>Recommended Sections</h3>
        <ul id="sections"></ul>
      </article>

      <article class="card">
        <h3>Core Features</h3>
        <ul id="features"></ul>
      </article>

      <article class="card card-wide">
        <h3>Next Steps</h3>
        <ol id="next-steps"></ol>
      </article>
    </section>
  </main>

  <script src="app.js"></script>
</body>
</html>
:root {
  color-scheme: light;
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  color: #172033;
  background: #eef2ff;
}

* {
  box-sizing: border-box;
}

body {
  min-height: 100vh;
  margin: 0;
  background:
    radial-gradient(circle at top left, rgba(124, 58, 237, 0.22), transparent 34rem),
    linear-gradient(145deg, #f8fafc, #eef2ff);
}

button,
textarea,
input {
  font: inherit;
}

.shell {
  width: min(1080px, calc(100% - 2rem));
  margin: 0 auto;
  padding: 4rem 0;
}

.hero {
  max-width: 720px;
  margin-bottom: 2rem;
}

.hero h1 {
  margin: 0.25rem 0 0.75rem;
  font-size: clamp(2.4rem, 7vw, 5rem);
  line-height: 0.98;
}

.hero > p:last-child {
  max-width: 620px;
  margin: 0;
  color: #526078;
  font-size: 1.15rem;
}

.eyebrow {
  margin: 0;
  color: #6d28d9;
  font-size: 0.78rem;
  font-weight: 800;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

.offer,
.panel,
.card,
.result-heading {
  border: 1px solid rgba(100, 116, 139, 0.2);
  border-radius: 1.25rem;
  background: rgba(255, 255, 255, 0.92);
  box-shadow: 0 18px 55px rgba(67, 56, 202, 0.1);
}

.offer {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 0.75rem 2rem;
  margin-bottom: 1rem;
  padding: clamp(1.25rem, 4vw, 2rem);
}

.offer h2 {
  margin: 0.35rem 0 0;
}

.offer-price {
  margin: 0;
  color: #5b21b6;
  font-size: clamp(2.25rem, 6vw, 4rem);
  font-weight: 900;
}

.offer ul {
  grid-column: 1 / -1;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0.5rem 2rem;
  margin: 0.5rem 0 0;
  padding-left: 1.25rem;
  color: #526078;
}

.input-panel {
  padding: clamp(1.25rem, 4vw, 2rem);
}

.notice {
  margin-top: 0;
  border-left: 4px solid #7c3aed;
  padding: 0.75rem 1rem;
  color: #4c1d95;
  background: #f5f3ff;
}

label {
  display: block;
  margin-bottom: 0.55rem;
  font-weight: 750;
}

textarea,
input {
  width: 100%;
  border: 1px solid #cbd5e1;
  border-radius: 0.85rem;
  padding: 0.9rem 1rem;
  color: #172033;
  background: #ffffff;
}

textarea {
  min-height: 190px;
  resize: vertical;
}

textarea:focus,
input:focus {
  border-color: #7c3aed;
  outline: 3px solid rgba(124, 58, 237, 0.16);
}

button {
  margin-top: 1rem;
  border: 0;
  border-radius: 999px;
  padding: 0.85rem 1.2rem;
  color: #ffffff;
  background: #6d28d9;
  font-weight: 800;
  cursor: pointer;
}

button:hover {
  background: #5b21b6;
}

button:disabled {
  cursor: wait;
  opacity: 0.65;
}

.status {
  min-height: 1.5rem;
  margin-bottom: 0;
  color: #475569;
}

.status[data-state="error"] {
  color: #b91c1c;
}

.status[data-state="success"] {
  color: #166534;
}

.results {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 1rem;
  margin-top: 1.5rem;
}

.result-heading,
.card {
  padding: 1.35rem;
}

.result-heading,
.card-wide {
  grid-column: 1 / -1;
}

.result-heading h2,
.card h3 {
  margin: 0.35rem 0 0.75rem;
}

.card p,
.card li {
  color: #526078;
  line-height: 1.65;
}

.card ul,
.card ol {
  margin-bottom: 0;
  padding-left: 1.25rem;
}

@media (max-width: 700px) {
  .shell {
    padding: 2rem 0;
  }

  .offer,
  .results {
    grid-template-columns: 1fr;
  }

  .offer ul,
  .card-wide {
    grid-column: auto;
  }

  .offer ul {
    grid-template-columns: 1fr;
  }
}
const form = document.querySelector("#brief-form");
const briefInput = document.querySelector("#brief");
const submitButton = document.querySelector("#submit-button");
const statusMessage = document.querySelector("#status-message");
const results = document.querySelector("#results");

function setStatus(message, state = "") {
  statusMessage.textContent = message;
  statusMessage.dataset.state = state;
}

form.addEventListener("submit", (event) => {
  event.preventDefault();

  const brief = briefInput.value.trim();

  if (!brief) {
    results.hidden = true;
    setStatus("Please enter a fictional brief.", "error");
    return;
  }

  document.querySelector("#project-name").textContent = "Echoed fictional brief";
  document.querySelector("#summary").textContent = brief;
  results.hidden = false;
  setStatus("Project plan ready.", "success");
});

Your offer now gives the brief a clear commercial frame. Next, you will send that request through the secure server boundary and receive the first AI-generated plan.

Connect the First AI Response

Your offer page now captures a fictional brief. The temporary echo still leaves the interpretation work with you.

This step gives your Express server the interpretation job through the Gemini API. You will display the first answer as prose so you can judge whether it supports the result dashboard.

In this step, get ready to:
  • Add a server-side Gemini request.
  • Extract the model output text from the Gemini response.
  • Connect the brief form to the new API endpoint.
Add the Gemini request

A server-side environment variable lets Express authenticate with Gemini. The browser never receives the credential.

  • Stop the running server by pressing Ctrl+C in the Visual Studio Code terminal.
  • In server.js, locate const port = 3000;.
  • Add the Gemini endpoint address plus the output extractor below that line by pasting this code:
const geminiUrl = "https://generativelanguage.googleapis.com/v1beta/interactions";

function extractOutputText(interaction) {
  const steps = Array.isArray(interaction.steps) ? interaction.steps : [];

  return steps
    .filter((step) => step.type === "model_output")
    .flatMap((step) => Array.isArray(step.content) ? step.content : [])
    .filter((block) => block.type === "text" && typeof block.text === "string")
    .map((block) => block.text)
    .join("\n");
}

What does the extractor do?

  • The geminiUrl value points the server to the Gemini interactions endpoint.
  • The extractOutputText() function starts with the response's steps array.
  • The filters keep only model_output steps plus their text content blocks.
  • The final join() combines the selected text into one prose response.
  • Scroll to app.listen(port, (error) => { near the bottom of server.js.
  • Add the new route immediately above it by pasting this code:
app.post("/api/plan", async (req, res) => {
  const brief = typeof req.body?.brief === "string" ? req.body.brief.trim() : "";
  const apiKey = process.env.GEMINI_API_KEY;
  if (!apiKey) {
    return res.status(500).json({ error: "The server is missing its Gemini API key." });
  }
  try {
    const response = await fetch(geminiUrl, {
      method: "POST",
      headers: { "Content-Type": "application/json", "x-goog-api-key": apiKey },
      body: JSON.stringify({
        model: "gemini-3.8-flash",
        store: false,
        system_instruction: "You are a web product strategist. Convert fictional client briefs into concise plans for a $350 one-page starter website. Keep the plan within one responsive page, client-supplied copy and images, one revision round, and no advanced custom functionality.",
        input: brief,
      })
    });
    if (!response.ok) {
      return res.status(502).json({ error: "Gemini could not generate a plan right now." });
    }
    const interaction = await response.json();
    const outputText = extractOutputText(interaction);
    if (!outputText) {
      throw new Error("Gemini returned no model output text.");
    }
    return res.json({ plan: outputText });
  } catch (error) {
    return res.status(500).json({ error: "The plan could not be processed. Please try again." });
  }
});

What does this route do?

  • The POST /api/plan route reads the trimmed fictional brief from req.body.
  • The x-goog-api-key header authenticates the server with the key stored in GEMINI_API_KEY.
  • The system_instruction keeps the response inside the fixed $350 one-page package.
  • The store: false setting requests a stateless interaction.
  • The route checks response.ok before extracting the generated prose.
  • Save server.js.
  • Restart the local server in the same terminal by running:
npm start

What should you see?

The Node.js process should print Client Brief Architect is running at http://localhost:3000. The restarted server now includes the Gemini route.

Does the server fail to restart?

Confirm that server.js is saved. Make sure you restarted the server in the terminal where GEMINI_API_KEY remains set.

Check that the new route sits above app.listen().

Help me diagnose my Gemini route without exposing my API key.

✔️ Awesome, I've got everything!

Your server is running with the Gemini request route plus the model-output extractor.

ⓧ I'd like to double check the full code

const express = require("express");

const app = express();
const port = 3000;
const geminiUrl = "https://generativelanguage.googleapis.com/v1beta/interactions";

app.use(express.json({ limit: "20kb" }));
app.use(express.static("public"));

function extractOutputText(interaction) {
  const steps = Array.isArray(interaction.steps) ? interaction.steps : [];

  return steps
    .filter((step) => step.type === "model_output")
    .flatMap((step) => Array.isArray(step.content) ? step.content : [])
    .filter((block) => block.type === "text" && typeof block.text === "string")
    .map((block) => block.text)
    .join("\n");
}

app.post("/api/plan", async (req, res) => {
  const brief = typeof req.body?.brief === "string" ? req.body.brief.trim() : "";
  const apiKey = process.env.GEMINI_API_KEY;
  if (!apiKey) {
    return res.status(500).json({ error: "The server is missing its Gemini API key." });
  }
  try {
    const response = await fetch(geminiUrl, {
      method: "POST",
      headers: { "Content-Type": "application/json", "x-goog-api-key": apiKey },
      body: JSON.stringify({
        model: "gemini-3.8-flash",
        store: false,
        system_instruction: "You are a web product strategist. Convert fictional client briefs into concise plans for a $350 one-page starter website. Keep the plan within one responsive page, client-supplied copy and images, one revision round, and no advanced custom functionality.",
        input: brief,
      })
    });
    if (!response.ok) {
      return res.status(502).json({ error: "Gemini could not generate a plan right now." });
    }
    const interaction = await response.json();
    const outputText = extractOutputText(interaction);
    if (!outputText) {
      throw new Error("Gemini returned no model output text.");
    }
    return res.json({ plan: outputText });
  } catch (error) {
    return res.status(500).json({ error: "The plan could not be processed. Please try again." });
  }
});

app.listen(port, (error) => {
  if (error) {
    throw error;
  }

  console.log(`Client Brief Architect is running at http://localhost:${port}`);
});
Send the brief from the browser

A REST request carries the brief from the browser to your server. A JSON response carries the generated plan back without exposing the Gemini credential.

  • Switch back to public/app.js.
  • Locate the existing form.addEventListener("submit", ...) listener.
  • Replace the entire listener with this version:
form.addEventListener("submit", async (event) => {
  event.preventDefault();

  const brief = briefInput.value.trim();
  if (!brief) {
    results.hidden = true;
    statusMessage.textContent = "Please enter a fictional client brief.";
    return;
  }

  try {
    const response = await fetch("/api/plan", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ brief })
    });
    const data = await response.json();
    if (!response.ok) {
      throw new Error(data.error || "The request failed.");
    }
    results.textContent = data.plan;
    results.hidden = false;
    statusMessage.textContent = "Project plan ready.";
  } catch (error) {
    results.hidden = true;
    statusMessage.textContent = error.message;
  }
});

How does the browser connection work?

  • The listener stops the form's normal page reload.
  • The fetch() call sends { brief } to /api/plan as JSON.
  • The response.ok check catches server or Gemini failures.
  • The result area receives the returned prose through textContent.
  • Save public/app.js.
  • Refresh http://localhost:3000 in your browser.

You should still see the $350 offer plus the fictional brief form. The result area remains hidden until you submit a brief.

Does the page stop responding?

Confirm that the five selector declarations remain above the new event listener. A missing form or briefInput reference prevents the listener from starting.

Help me check why my brief form no longer submits.

✔️ Awesome, I've got everything!

Your form now sends the fictional brief to the Express endpoint and displays the returned prose.

ⓧ I'd like to double check the full code

const form = document.querySelector("#brief-form");
const briefInput = document.querySelector("#brief");
const submitButton = document.querySelector("#submit-button");
const statusMessage = document.querySelector("#status-message");
const results = document.querySelector("#results");

form.addEventListener("submit", async (event) => {
  event.preventDefault();

  const brief = briefInput.value.trim();
  if (!brief) {
    results.hidden = true;
    statusMessage.textContent = "Please enter a fictional client brief.";
    return;
  }

  try {
    const response = await fetch("/api/plan", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ brief })
    });
    const data = await response.json();
    if (!response.ok) {
      throw new Error(data.error || "The request failed.");
    }
    results.textContent = data.plan;
    results.hidden = false;
    statusMessage.textContent = "Project plan ready.";
  } catch (error) {
    results.hidden = true;
    statusMessage.textContent = error.message;
  }
});
Test the first AI plan

The browser and server now share a complete request path. Use fictional information because free-tier prompts may be used to improve Google products.

  • Paste this fictional request into the brief textarea:
A neighborhood bakery needs a warm, mobile-friendly page that shows its menu, tells its story, and makes custom cake enquiries easier.

Why use this brief?

The bakery request is fictional. It also fits a focused one-page website well enough to test whether Gemini follows the package boundaries.

Before you submit it, which details do you expect Gemini to include in its one-page plan?

  • Select Generate Project Plan.

You should see a readable one-page website plan as one continuous block of prose. You now have a live AI response traveling through your server without exposing the key to browser code.

Why the prose falls short

The answer may mention an audience plus useful sections. Their names can change between requests.

The browser has no guaranteed field for features or next steps. That unreliable shape makes reusable result cards difficult to build.

Your app now turns a fictional client brief into a live Gemini plan. Next, you will give that response a reliable shape for the result cards.

Turn Prose Into Reliable Project Cards

Your Express endpoint now turns a fictional brief into readable Gemini prose. That result proves the browser can reach the model through your server.

A polished result dashboard needs predictable fields. In this step, structured output gives every project plan the same shape before the browser renders it.

In this step, get ready to:
  • Constrain each Gemini response with a project-plan schema.
  • Parse each response into a validated plan object.
  • Render the plan as named project cards.
Define the project plan schema

A JSON Schema describes the fields Gemini must return. Your schema covers the specific decisions needed for a one-page website plan.

  • Switch back to server.js in VS Code.
  • Find the geminiUrl declaration near the top of the file.
  • Start the planSchema declaration beneath that line by adding this first section:
const planSchema = {
  type: "object",
  properties: {
    project_name: {
      type: "string",
      description: "A short, memorable name for the proposed one-page website."
    },
    summary: {
      type: "string",
      description: "A concise explanation of the website and the value it provides."
    },
    audience: {
      type: "string",
      description: "The primary audience for the website."
    },
    sections: {
      type: "array",
      items: { type: "string" },
      description: "The recommended sections for the one-page website."
    },
    features: {
      type: "array",
      items: { type: "string" },
      description: "The most valuable features that fit the starter package."
    },

What does this schema section define?

  • The project_name field gives the proposal a short title.
  • The summary field explains the proposed website.
  • The audience field identifies who the page serves.
  • The sections field stores the recommended page sections as strings.
  • The features field keeps the feature list within the starter package.
  • Continue the same planSchema declaration immediately after the features property by adding the remaining fields:
    design_direction: {
      type: "string",
      description: "A practical visual and interaction direction."
    },
    next_steps: {
      type: "array",
      items: { type: "string" },
      description: "The next discovery or delivery actions."
    }
  },
  required: [
    "project_name",
    "summary",
    "audience",
    "sections",
    "features",
    "design_direction",
    "next_steps"
  ],
  additionalProperties: false
};

How does the schema protect the contract?

The required array makes every named field part of each plan. The additionalProperties setting prevents unexpected fields from changing the contract.

Array fields define string items. This gives the browser a predictable list to render.

The schema exists on your server. The Gemini request must reference it before the model follows that structure.

  • Find the input: brief field inside the Gemini request body.
  • Add a comma after the existing brief value.
  • Add the response format directly beneath that field by pasting this code:
        response_format: {
          type: "text",
          mime_type: "application/json",
          schema: planSchema
        }

What does the response format change?

  • The type field keeps the model output available as text.
  • The mime_type field tells Gemini to produce JSON.
  • The schema field constrains that JSON to your project-plan contract.
  • Save server.js.
  • Return to the terminal that is running the local server.
  • Press Ctrl+C to stop the current process.
  • Restart the local server by running this command:
npm start

What should the terminal show?

The terminal prints the local Client Brief Architect address. This confirms the updated server starts without a JavaScript syntax error.

  • Return to http://localhost:3000 in your browser.
  • Paste this fictional request into the brief field:
Example: A neighborhood bakery needs a warm, mobile-friendly page that shows its menu, tells its story, and makes custom cake enquiries easier.

Why use this brief?

This fictional request contains an audience need. It also contains enough page goals to fill each part of the schema.

  • Select Generate Project Plan.

You should see JSON-shaped text containing fields such as project_name and sections. The existing temporary result area still presents that response as one text block.

Good progress. Gemini now returns predictable material for the dashboard you are about to build.

Still seeing ordinary prose?

Check that response_format is inside the object passed to JSON.stringify(). Confirm that its schema value is planSchema.

Restart the server after saving server.js. A running Node.js process does not reload the file automatically.

Help me check why my Gemini response is not following the planSchema response format.

Validate the structured response

Schema-constrained output gives the response a dependable structure. Runtime validation adds another boundary before the server trusts that data.

  • Return to server.js in VS Code.
  • Find the closing brace of extractOutputText().
  • Add the validation helpers beneath extractOutputText() by pasting this code:
function isStringArray(value) {
  return Array.isArray(value) && value.every((item) => typeof item === "string");
}

function isPlan(value) {
  return Boolean(
    value &&
    typeof value === "object" &&
    typeof value.project_name === "string" &&
    typeof value.summary === "string" &&
    typeof value.audience === "string" &&
    isStringArray(value.sections) &&
    isStringArray(value.features) &&
    typeof value.design_direction === "string" &&
    isStringArray(value.next_steps)
  );
}

What do the validators check?

  • The isStringArray() helper confirms that a value is an array containing only strings.
  • The isPlan() helper checks each required string field.
  • The same helper checks sections, features, and next_steps with the array validator.
  • Find the outputText check inside the POST /api/plan route.
  • Remove the temporary response that returns the prose text.
  • Parse the output beneath the existing empty-output check by adding this code:
    const plan = JSON.parse(outputText);

    if (!isPlan(plan)) {
      throw new Error("Gemini returned an unexpected project plan shape.");
    }

    return res.json({ plan });

How does the server approve a plan?

The JSON.parse() call converts the model text into a JavaScript value. The validator checks that value before the server returns it.

A valid response leaves the server as { plan }. This creates a narrow contract for the browser.

  • Save server.js.
  • Return to the terminal running the server.
  • Press Ctrl+C to stop the previous process.
  • Check the updated server for syntax errors by running this command:
npm start

What does this check prove?

The terminal prints the local app address when Node.js can load the schema and validator functions. Keep this process running for the browser work.

Does the server stop during startup?

Check the closing braces around planSchema and isPlan(). A missing brace prevents Node.js from loading server.js.

Help me find a syntax or placement error in my server-side plan schema and validators.

✔️ Awesome, I've got everything!

Your server now requests schema-constrained JSON. It parses that response before returning a validated plan.

ⓧ I'd like to double check the full code

const express = require("express");

const app = express();
const port = 3000;
const geminiUrl = "https://generativelanguage.googleapis.com/v1beta/interactions";

const planSchema = {
  type: "object",
  properties: {
    project_name: {
      type: "string",
      description: "A short, memorable name for the proposed one-page website."
    },
    summary: {
      type: "string",
      description: "A concise explanation of the website and the value it provides."
    },
    audience: {
      type: "string",
      description: "The primary audience for the website."
    },
    sections: {
      type: "array",
      items: { type: "string" },
      description: "The recommended sections for the one-page website."
    },
    features: {
      type: "array",
      items: { type: "string" },
      description: "The most valuable features that fit the starter package."
    },
    design_direction: {
      type: "string",
      description: "A practical visual and interaction direction."
    },
    next_steps: {
      type: "array",
      items: { type: "string" },
      description: "The next discovery or delivery actions."
    }
  },
  required: [
    "project_name",
    "summary",
    "audience",
    "sections",
    "features",
    "design_direction",
    "next_steps"
  ],
  additionalProperties: false
};

app.use(express.json({ limit: "20kb" }));
app.use(express.static("public"));

function extractOutputText(interaction) {
  const steps = Array.isArray(interaction.steps) ? interaction.steps : [];

  return steps
    .filter((step) => step.type === "model_output")
    .flatMap((step) => Array.isArray(step.content) ? step.content : [])
    .filter((block) => block.type === "text" && typeof block.text === "string")
    .map((block) => block.text)
    .join("\n");
}

function isStringArray(value) {
  return Array.isArray(value) && value.every((item) => typeof item === "string");
}

function isPlan(value) {
  return Boolean(
    value &&
    typeof value === "object" &&
    typeof value.project_name === "string" &&
    typeof value.summary === "string" &&
    typeof value.audience === "string" &&
    isStringArray(value.sections) &&
    isStringArray(value.features) &&
    typeof value.design_direction === "string" &&
    isStringArray(value.next_steps)
  );
}

app.post("/api/plan", async (req, res) => {
  const brief = typeof req.body?.brief === "string" ? req.body.brief.trim() : "";
  const apiKey = process.env.GEMINI_API_KEY;

  if (!apiKey) {
    return res.status(500).json({ error: "The server is missing its Gemini API key." });
  }

  try {
    const response = await fetch(geminiUrl, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "x-goog-api-key": apiKey
      },
      body: JSON.stringify({
        model: "gemini-3.8-flash",
        store: false,
        system_instruction: "You are a web product strategist. Convert fictional client briefs into concise plans for a $350 one-page starter website. Keep the plan within one responsive page, client-supplied copy and images, one revision round, and no advanced custom functionality.",
        input: brief,
        response_format: {
          type: "text",
          mime_type: "application/json",
          schema: planSchema
        }
      })
    });

    if (!response.ok) {
      console.error("Gemini API request failed with status", response.status);
      return res.status(502).json({ error: "Gemini could not generate a plan right now." });
    }

    const interaction = await response.json();
    const outputText = extractOutputText(interaction);

    if (!outputText) {
      throw new Error("Gemini returned no model output text.");
    }

    const plan = JSON.parse(outputText);

    if (!isPlan(plan)) {
      throw new Error("Gemini returned an unexpected project plan shape.");
    }

    return res.json({ plan });
  } catch (error) {
    console.error(error);
    return res.status(500).json({ error: "The plan could not be processed. Please try again." });
  }
});

app.listen(port, (error) => {
  if (error) {
    throw error;
  }

  console.log(`Client Brief Architect is running at http://localhost:${port}`);
});

How to use this reference

Compare this file with your server.js from top to bottom. Keep your API key in the terminal environment only.

Build and render the project cards

The browser now receives a plan object with stable field names. A structured DOM layout can give each decision its own card.

  • Switch back to public/index.html in VS Code.
  • Find the existing results section with id="results".
  • Remove the temporary prose output from that section.
  • Start the new results dashboard inside the same section by adding this first group:
    <section id="results" class="results" hidden>
      <div class="result-heading">
        <p class="eyebrow">Generated concept</p>
        <h2 id="project-name"></h2>
      </div>

      <article class="card card-wide">
        <h3>Summary</h3>
        <p id="summary"></p>
      </article>

      <article class="card">
        <h3>Audience</h3>
        <p id="audience"></p>
      </article>

      <article class="card">
        <h3>Design Direction</h3>
        <p id="design-direction"></p>
      </article>

What does the first card group show?

The result heading gives the generated concept its project name. The first cards present the summary, intended audience, and design direction as separate decisions.

  • Continue inside the same results section by adding the list-based cards beneath the Design Direction card:
      <article class="card">
        <h3>Recommended Sections</h3>
        <ul id="sections"></ul>
      </article>

      <article class="card">
        <h3>Core Features</h3>
        <ul id="features"></ul>
      </article>

      <article class="card card-wide">
        <h3>Next Steps</h3>
        <ol id="next-steps"></ol>
      </article>
    </section>

Why use lists for these fields?

Sections and features work as scannable bullet lists. Next steps use an ordered list because their sequence matters.

  • Save public/index.html.

✔️ Awesome, I've got everything!

Your hidden results area now contains a named destination for every plan field.

ⓧ I'd like to double check the full code

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Client Brief Architect</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <main class="shell">
    <header class="hero">
      <p class="eyebrow">AI project discovery</p>
      <h1>Client Brief Architect</h1>
      <p>Turn a website request into a practical plan for a clearly scoped starter package.</p>
    </header>

    <section class="offer" aria-labelledby="offer-title">
      <div>
        <p class="eyebrow">Starter website package</p>
        <h2 id="offer-title">One-page business website</h2>
      </div>
      <p class="offer-price">$350</p>
      <ul>
        <li>One responsive page</li>
        <li>Client-supplied copy and images</li>
        <li>One revision round</li>
        <li>Custom scope quoted separately</li>
      </ul>
    </section>

    <section class="panel input-panel">
      <p class="notice">Use fictional information only. Free-tier prompts may be used to improve Google products.</p>

      <form id="brief-form">
        <label for="brief">Fictional client brief</label>
        <textarea
          id="brief"
          name="brief"
          minlength="30"
          maxlength="3000"
          required
          placeholder="Example: A neighborhood bakery needs a warm, mobile-friendly page that shows its menu, tells its story, and makes custom cake enquiries easier."
        ></textarea>
        <button id="submit-button" type="submit">Generate Project Plan</button>
      </form>

      <p id="status-message" class="status" role="status" aria-live="polite"></p>
    </section>

    <section id="results" class="results" hidden>
      <div class="result-heading">
        <p class="eyebrow">Generated concept</p>
        <h2 id="project-name"></h2>
      </div>

      <article class="card card-wide">
        <h3>Summary</h3>
        <p id="summary"></p>
      </article>

      <article class="card">
        <h3>Audience</h3>
        <p id="audience"></p>
      </article>

      <article class="card">
        <h3>Design Direction</h3>
        <p id="design-direction"></p>
      </article>

      <article class="card">
        <h3>Recommended Sections</h3>
        <ul id="sections"></ul>
      </article>

      <article class="card">
        <h3>Core Features</h3>
        <ul id="features"></ul>
      </article>

      <article class="card card-wide">
        <h3>Next Steps</h3>
        <ol id="next-steps"></ol>
      </article>
    </section>
  </main>

  <script src="app.js"></script>
</body>
</html>

How to use this reference

Compare the full file with public/index.html. Check every result element ID because the browser script uses those IDs as rendering targets.

The card containers are ready. The browser script now needs to place each plan value into its matching element.

  • Switch back to public/app.js in VS Code.
  • Find the closing brace of setStatus().
  • Add the list and plan rendering helpers beneath setStatus() by pasting this code:
function fillList(elementId, items) {
  const list = document.querySelector(`#${elementId}`);
  list.replaceChildren();

  for (const item of items) {
    const listItem = document.createElement("li");
    listItem.textContent = item;
    list.appendChild(listItem);
  }
}

function renderPlan(plan) {
  document.querySelector("#project-name").textContent = plan.project_name;
  document.querySelector("#summary").textContent = plan.summary;
  document.querySelector("#audience").textContent = plan.audience;
  document.querySelector("#design-direction").textContent = plan.design_direction;
  fillList("sections", plan.sections);
  fillList("features", plan.features);
  fillList("next-steps", plan.next_steps);
  results.hidden = false;
}

How does safe rendering work?

  • The fillList() helper clears an old list before adding the new items.
  • Each item becomes an li element created by the browser.
  • The textContent property inserts model values as text.
  • The renderPlan() helper maps every validated field to its named card.
  • Find the temporary success code that writes returned prose into the results area.
  • Replace that temporary rendering code with these lines:
    renderPlan(data.plan);
    setStatus("Project plan ready.", "success");

What changes in the success path?

The browser reads the plan property returned by the server. It passes that object to one rendering function before showing the success status.

  • Save public/app.js.
  • Return to the Client Brief Architect page in your browser.
  • Refresh the page.

Before you submit the same brief again, do you expect one prose block or separate project cards?

  • Paste the same fictional bakery brief into the brief field.
  • Select Generate Project Plan.

You should see separate cards for Summary, Audience, Recommended Sections, Core Features, Design Direction, and Next Steps. Each list should contain individual items instead of a JSON string.

That is the contract working end to end. The same model response now powers a reusable result dashboard.

Do the cards stay empty?

Check that every selector in renderPlan() matches an ID in public/index.html exactly. Confirm that the success path calls renderPlan(data.plan).

Check the terminal for a validation message if the server rejects the model output. Restart the server after every saved change to server.js.

Help me diagnose why my validated plan is not appearing in the named result cards.

✔️ Awesome, I've got everything!

Your browser now turns a validated project plan into a responsive set of named cards.

ⓧ I'd like to double check the full code

const form = document.querySelector("#brief-form");
const briefInput = document.querySelector("#brief");
const submitButton = document.querySelector("#submit-button");
const statusMessage = document.querySelector("#status-message");
const results = document.querySelector("#results");

function setStatus(message, state = "") {
  statusMessage.textContent = message;
  statusMessage.dataset.state = state;
}

function fillList(elementId, items) {
  const list = document.querySelector(`#${elementId}`);
  list.replaceChildren();

  for (const item of items) {
    const listItem = document.createElement("li");
    listItem.textContent = item;
    list.appendChild(listItem);
  }
}

function renderPlan(plan) {
  document.querySelector("#project-name").textContent = plan.project_name;
  document.querySelector("#summary").textContent = plan.summary;
  document.querySelector("#audience").textContent = plan.audience;
  document.querySelector("#design-direction").textContent = plan.design_direction;
  fillList("sections", plan.sections);
  fillList("features", plan.features);
  fillList("next-steps", plan.next_steps);
  results.hidden = false;
}

form.addEventListener("submit", async (event) => {
  event.preventDefault();

  const brief = briefInput.value.trim();
  setStatus("Building your project plan...");

  const response = await fetch("/api/plan", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify({ brief })
  });

  const data = await response.json();

  if (!response.ok) {
    throw new Error(data.error || "The request failed.");
  }

  renderPlan(data.plan);
  setStatus("Project plan ready.", "success");
});

How to use this reference

Compare this file with public/app.js from top to bottom. Focus on the helper placement and the data.plan success path.

Your AI response now has a dependable shape and a client-ready presentation. Next, you will protect that experience from invalid input and failed requests.

Harden the User Experience

Your structured cards now turn a fictional client brief into a usable website plan. This final step protects that experience when the input or request falls outside the happy path.

A client-facing offer earns trust by responding clearly to invalid input or a failed request. You will add runtime validation plus loading states and recovery paths across the browser and server.

In this step, get ready to:
  • Validate the brief boundaries in the browser and server.
  • Manage loading states and stale results.
  • Test error recovery and successful plan generation.
Add matching brief checks

Browser validation gives immediate feedback. Server validation protects your Express endpoint when a request bypasses the form.

  • In public/app.js, find the const brief = briefInput.value.trim(); line inside the form submission listener.
  • Replace that line with this validation block:
  const brief = briefInput.value.trim();

  if (brief.length < 30) {
    results.hidden = true;
    setStatus("Please enter at least 30 characters.", "error");
    return;
  }

How does the browser check work?

  • The trimmed value prevents spaces from counting as useful brief content.
  • The length check stops briefs below 30 characters.
  • The hidden result area removes any plan left over from an earlier submission.
  • The status message explains how the learner can fix the input.
  • Save public/app.js.
  • Refresh http://localhost:3000 to load the updated browser script.
  • Enter Short bakery brief in the fictional client brief field.
  • Select Generate Project Plan.

The form blocks the short brief. You will see validation feedback instead of a request or an old result.

Does the short brief still submit?

Confirm that you saved public/app.js. Refresh the page so the browser loads the latest script.

Check that the comparison uses brief.length < 30 inside the existing form submission listener.

Help me debug the browser brief validation.

The textarea limits normal browser input to 3,000 characters. The server still needs its own lower and upper boundaries because callers can send requests without using your page.

  • In server.js, locate the brief declaration and the existing apiKey declaration inside POST /api/plan.
  • Replace that section with this guarded version:
  const brief = typeof req.body?.brief === "string" ? req.body.brief.trim() : "";

  if (brief.length < 30) {
    return res.status(400).json({ error: "Please enter at least 30 characters." });
  }

  if (brief.length > 3000) {
    return res.status(400).json({ error: "Please keep the brief under 3,000 characters." });
  }

  const apiKey = process.env.GEMINI_API_KEY;

  if (!apiKey) {
    return res.status(500).json({ error: "The server is missing its Gemini API key." });
  }

What do these guards protect?

  • The first guard rejects missing or short brief data with a 400 response.
  • The second guard rejects briefs above 3,000 characters with a 400 response.
  • The GEMINI_API_KEY check reads the credential from the server environment.
  • The missing-key path returns a clear 500 response before contacting the Gemini API.
  • Save server.js.
  • Stop the running server by pressing Ctrl+C in its terminal.
  • Restart the updated server by running:
npm start

What does the restart load?

The command runs the start script from package.json. The new server process loads both brief boundaries and the missing-key check.

You will see the Client Brief Architect local address in the terminal. The server is now enforcing the same brief boundaries as the page.

Does the restarted server fail?

Confirm that the previous process stopped before starting another one. A process that still owns port 3000 prevents the replacement server from listening.

Help me restart the updated Express server.

Handle every request outcome

A request has four visible phases: ready, loading, success, or failure. The interface should show one current phase while keeping stale plan cards out of view.

  • In public/app.js, find the try { line inside the form submission listener.
  • Place this loading-state block immediately before that line:
  submitButton.disabled = true;
  results.hidden = true;
  setStatus("Building your project plan...");

What changes during loading?

  • The disabled button prevents duplicate submissions while one request is active.
  • The hidden results remove a previous plan before the new response arrives.
  • The live status region tells the user that the request is still in progress.
  • Save public/app.js.
  • Refresh the browser to load the updated request state.
  • Enter a fictional brief with at least 30 characters.
  • Select Generate Project Plan.

The button becomes disabled while the status reads Building your project plan.... The existing cards stay hidden until the new plan is ready.

Does the button remain active?

Confirm that the loading-state lines sit after the short-brief guard. They must appear before the request begins.

Help me debug the loading state in public/app.js.

An upstream failure can return a valid HTTP response with an unsuccessful status. Node.js fetch() requires your server to inspect that status explicitly.

  • In server.js, locate the closing line of the Gemini fetch() call.
  • Insert this response check before const interaction = await response.json();.
    if (!response.ok) {
      console.error("Gemini API request failed with status", response.status);
      return res.status(nymph502).json({ error: "Gemini could not generate a plan right now." });
    }

How is an upstream failure contained?

The status check stops unsuccessful Gemini responses before the server attempts to parse model output. The browser receives a 502 response with a useful message.

  • Save server.js.
  • Stop the current server by pressing Ctrl+C.
  • Restart the hardened server by running:
npm start

What is active now?

The restarted process now checks the Gemini response status before reading its JSON body. Failed upstream requests return a controlled error to the page.

Does the server stop after a request?

Check that the response guard is inside the existing try block. Confirm that the status code is 502.

Help me place the Gemini response check correctly.

The browser also needs to distinguish a successful response from a server error. A finally block restores the button after every outcome.

  • In public/app.js, find const data = await response.json(); inside the form submission listener.
  • Replace everything from that line through the end of the listener with this final request tail:
    const data = await response.json();

    if (!response.ok) {
      throw new Error(data.error || "The request failed.");
    }

    renderPlan(data.plan);
    setStatus("Project plan ready.", "success");
  } catch (error) {
    setStatus(error.message || "Something went wrong. Please try again.", "error");
  } finally {
    submitButton.disabled = false;
  }
});

How does the browser recover?

  • The browser parses the server response before checking its status.
  • An unsuccessful status throws the server-provided error message.
  • A successful response renders the validated plan cards.
  • The catch path reports JSON or network failures in the live status region.
  • The finally path re-enables the button after success or failure.
  • Save public/app.js.
  • Refresh the browser to load the final request lifecycle.
  • Submit a valid fictional brief.

The button disables during the request. A successful response reveals the plan cards and changes the status to Project plan ready..

Does the button stay disabled?

Confirm that submitButton.disabled = false; sits inside the finally block. Check the browser console for a missing brace near the end of the listener.

Help me debug the request cleanup path.

Test failure and recovery paths

Your final test moves through invalid input, missing credentials, and a successful request. Each state should leave the page ready for the next attempt.

  • Refresh the browser to start with an empty result area.
  • Enter Short bakery brief in the fictional client brief field.
  • Select Generate Project Plan.

The request stays blocked because the brief is below 30 characters. No previous plan remains visible.

Your API key is scoped to the original terminal session. A second terminal lets you test the missing-credential response without displaying or deleting the secret.

  • Stop the server in the original terminal by pressing Ctrl+C.
  • Create a second terminal from the VS Code terminal panel.
  • Confirm that the second terminal starts inside client-brief-architect.
  • Start the server without the session-scoped key by running:
npm start

Why is the key missing here?

An environment variable set in one terminal belongs to that terminal session. The second server process starts without GEMINI_API_KEY.

  • Refresh http://localhost:3000.
  • Enter a valid fictional brief with at least 30 characters.
  • Select Generate Project Plan.

The page shows The server is missing its Gemini API key. The button becomes available again after the failed request.

Do you see a different failure?

Confirm that the second terminal did not inherit a manually configured key. Check that the browser is connected to the server running in that terminal.

Help me verify the missing-key test safely.

  • Stop the server in the second terminal by pressing Ctrl+C.
  • Switch back to the original terminal from earlier.
  • Restore the server with the existing session-scoped key by running:
npm start

What does this restore?

The original terminal still holds GEMINI_API_KEY for its current session. The restarted server can authenticate its Gemini request without placing the key in browser code.

Before you submit the final brief, which loading state and result cards do you expect the completed app to show?

  • Refresh http://localhost:3000.
  • Enter A neighborhood bakery needs a warm, mobile-friendly page that shows its menu, tells its story, and makes custom cake enquiries easier. in the fictional client brief field.
  • Select Generate Project Plan.

The button disables while the live status announces that the plan is being built. The completed response shows separate Summary, Audience, Recommended Sections, Core Features, Design Direction, and Next Steps cards.

How are malformed responses handled?

The server parses model text with JSON.parse. A malformed response moves into the existing catch path.

The isPlan validator rejects missing fields or incorrect value types. Only a validated plan reaches the browser.

✔️ Awesome, I've got everything!

Your app now protects the $350 offer with browser checks, server checks, loading feedback, controlled errors, and a recoverable request flow.

ⓧ I'd like to double check the full code

Compare each project file with these final references. Keep your Gemini API key outside every file.

{
  "name": "client-brief-architect",
  "version": "1.0.0",
  "private": true,
  "description": "Plan a scoped one-page website from a fictional client brief.",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "5.2.1"
  }
}

This manifest keeps the exact Express dependency and starts the app through server.js.

const express = require("express");

const app = express();
const port = 3000;
const geminiUrl = "https://generativelanguage.googleapis.com/v1beta/interactions";

const planSchema = {
  type: "object",
  properties: {
    project_name: {
      type: "string",
      description: "A short, memorable name for the proposed one-page website."
    },
    summary: {
      type: "string",
      description: "A concise explanation of the website and the value it provides."
    },
    audience: {
      type: "string",
      description: "The primary audience for the website."
    },
    sections: {
      type: "array",
      items: { type: "string" },
      description: "The recommended sections for the one-page website."
    },
    features: {
      type: "array",
      items: { type: "string" },
      description: "The most valuable features that fit the starter package."
    },
    design_direction: {
      type: "string",
      description: "A practical visual and interaction direction."
    },
    next_steps: {
      type: "array",
      items: { type: "string" },
      description: "The next discovery or delivery actions."
    }
  },
  required: [
    "project_name",
    "summary",
    "audience",
    "sections",
    "features",
    "design_direction",
    "next_steps"
  ],
  additionalProperties: false
};

app.use(express.json({ limit: "20kb" }));
app.use(express.static("public"));

function extractOutputText(interaction) {
  const steps = Array.isArray(interaction.steps) ? interaction.steps : [];

  return steps
    .filter((step) => step.type === "model_output")
    .flatMap((step) => Array.isArray(step.content) ? step.content : [])
    .filter((block) => block.type === "text" && typeof block.text === "string")
    .map((block) => block.text)
    .join("\n");
}

function isStringArray(value) {
  return Array.isArray(value) && value.every((item) => typeof item === "string");
}

function isPlan(value) {
  return Boolean(
    value &&
    typeof value === "object" &&
    typeof value.project_name === "string" &&
    typeof value.summary === "string" &&
    typeof value.audience === "string" &&
    isStringArray(value.sections) &&
    isStringArray(value.features) &&
    typeof value.design_direction === "string" &&
    isStringArray(value.next_steps)
  );
}

app.post("/api/plan", async (req, res) => {
  const brief = typeof req.body?.brief === "string" ? req.body.brief.trim() : "";

  if (brief.length < 30) {
    return res.status(400).json({ error: "Please enter at least 30 characters." });
  }

  if (brief.length > 3000) {
    return res.status(400).json({ error: "Please keep the brief under 3,000 characters." });
  }

  const apiKey = process.env.GEMINI_API_KEY;

  if (!apiKey) {
    return res.status(500).json({ error: "The server is missing its Gemini API key." });
  }

  try {
    const response = await fetch(geminiUrl, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "x-goog-api-key": apiKey
      },
      body: JSON.stringify({
        model: "gemini-3.8-flash",
        store: false,
        system_instruction: "You are a web product strategist. Convert fictional client briefs into concise plans for a $350 one-page starter website. Keep the plan within one responsive page, client-supplied copy and images, one revision round, and no advanced custom functionality.",
        input: brief,
        response_format: {
          type: "text",
          mime_type: "application/json",
          schema: planSchema
        }
      })
    });

    if (!response.ok) {
      console.error("Gemini API request failed with status", response.status);
      return res.status(502).json({ error: "Gemini could not generate a plan right now." });
    }

    const interaction = await response.json();
    const outputText = extractOutputText(interaction);

    if (!outputText) {
      throw new Error("Gemini returned no model output text.");
    }

    const plan = JSON.parse(outputText);

    if (!isPlan(plan)) {
      throw new Error("Gemini returned an unexpected project plan shape.");
    }

    return res.json({ plan });
  } catch (error) {
    console.error(error);
    return res.status(500).json({ error: "The plan could not be processed. Please try again." });
  }
});

app.listen(port, (error) => {
  if (error) {
    throw error;
  }

  console.log(`Client Brief Architect is running at http://localhost:${port}`);
});

This server reference contains the final brief validation, credential guard, upstream status check, structured-output parsing, and runtime plan validation.

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Client Brief Architect</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <main class="shell">
    <header class="hero">
      <p class="eyebrow">AI project discovery</p>
      <h1>Client Brief Architect</h1>
      <p>Turn a website request into a practical plan for a clearly scoped starter package.</p>
    </header>

    <section class="offer" aria-labelledby="offer-title">
      <div>
        <p class="eyebrow">Starter website package</p>
        <h2 id="offer-title">One-page business website</h2>
      </div>
      <p class="offer-price">$350</p>
      <ul>
        <li>One responsive page</li>
        <li>Client-supplied copy and images</li>
        <li>One revision round</li>
        <li>Custom scope quoted separately</li>
      </ul>
    </section>

    <section class="panel input-panel">
      <p class="notice">Use fictional information only. Free-tier prompts may be used to improve Google products.</p>

      <form id="brief-form">
        <label for="brief">Fictional client brief</label>
        <textarea
          id="brief"
          name="brief"
          minlength="30"
          maxlength="3000"
          required
          placeholder="Example: A neighborhood bakery needs a warm, mobile-friendly page that shows its menu, tells its story, and makes custom cake enquiries easier."
        ></textarea>
        <button id="submit-button" type="submit">Generate Project Plan</button>
      </form>

      <p id="status-message" class="status" role="status" aria-live="polite"></p>
    </section>

    <section id="results" class="results" hidden>
      <div class="result-heading">
        <p class="eyebrow">Generated concept</p>
        <h2 id="project-name"></h2>
      </div>

      <article class="card card-wide">
        <h3>Summary</h3>
        <p id="summary"></p>
      </article>

      <article class="card">
        <h3>Audience</h3>
        <p id="audience"></p>
      </article>

      <article class="card">
        <h3>Design Direction</h3>
        <p id="design-direction"></p>
      </article>

      <article class="card">
        <h3>Recommended Sections</h3>
        <ul id="sections"></ul>
      </article>

      <article class="card">
        <h3>Core Features</h3>
        <ul id="features"></ul>
      </article>

      <article class="card card-wide">
        <h3>Next Steps</h3>
        <ol id="next-steps"></ol>
      </article>
    </section>
  </main>

  <script src="app.js"></script>
</body>
</html>

This page reference contains the final $350 offer, bounded brief field, live status region, and named result cards.

:root {
  color-scheme: light;
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  color: #172033;
  background: #eef2ff;
}

* {
  box-sizing: border-box;
}

body {
  min-height: 100vh;
  margin: 0;
  background:
    radial-gradient(circle at top left, rgba(124, 58, 237, 0.22), transparent 34rem),
    linear-gradient(145deg, #f8fafc, #eef2ff);
}

button,
textarea,
input {
  font: inherit;
}

.shell {
  width: min(1080px, calc(100% - 2rem));
  margin: 0 auto;
  padding: 4rem 0;
}

.hero {
  max-width: 720px;
  margin-bottom: 2rem;
}

.hero h1 {
  margin: 0.25rem 0 0.75rem;
  font-size: clamp(2.4rem, 7vw, 5rem);
  line-height: 0.98;
}

.hero > p:last-child {
  max-width: 620px;
  margin: 0;
  color: #526078;
  font-size: 1.15rem;
}

.eyebrow {
  margin: 0;
  color: #6d28d9;
  font-size: 0.78rem;
  font-weight: 800;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

.offer,
.panel,
.card,
.result-heading {
  border: 1px solid rgba(100, 116, 139, 0.2);
  border-radius: 1.25rem;
  background: rgba(255, 255, 255, 0.92);
  box-shadow: 0 18px 55px rgba(67, 56, 202, 0.1);
}

.offer {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 0.75rem 2rem;
  margin-bottom: 1rem;
  padding: clamp(1.25rem, 4vw, 2rem);
}

.offer h2 {
  margin: 0.35rem 0 0;
}

.offer-price {
  margin: 0;
  color: #5b21b6;
  font-size: clamp(2.25rem, 6vw, 4rem);
  font-weight: 900;
}

.offer ul {
  grid-column: 1 / -1;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0.5rem 2rem;
  margin: 0.5rem 0 0;
  padding-left: 1.25rem;
  color: #526078;
}

.input-panel {
  padding: clamp(1.25rem, 4vw, 2rem);
}

.notice {
  margin-top: 0;
  border-left: 4px solid #7c3aed;
  padding: 0.75rem 1rem;
  color: #4c1d95;
  background: #f5f3ff;
}

label {
  display: block;
  margin-bottom: 0.55rem;
  font-weight: 750;
}

textarea,
input {
  width: 100%;
  border: 1px solid #cbd5e1;
  border-radius: 0.85rem;
  padding: 0.9rem 1rem;
  color: #172033;
  background: #ffffff;
}

textarea {
  min-height: 190px;
  resize: vertical;
}

textarea:focus,
input:focus {
  border-color: #7c3aed;
  outline: 3px solid rgba(124, 58, 237, 0.16);
}

button {
  margin-top: 1rem;
  border: 0;
  border-radius: 999px;
  padding: 0.85rem 1.2rem;
  color: #ffffff;
  background: #6d28d9;
  font-weight: 800;
  cursor: pointer;
}

button:hover {
  background: #5b21b6;
}

button:disabled {
  cursor: wait;
  opacity: 0.65;
}

.status {
  min-height: 1.5rem;
  margin-bottom: 0;
  color: #475569;
}

.status[data-state="error"] {
  color: #b91c1c;
}

.status[data-state="success"] {
  color: #166534;
}

.results {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 1rem;
  margin-top: 1.5rem;
}

.result-heading,
.card {
  padding: 1.35rem;
}

.result-heading,
.card-wide {
  grid-column: 1 / -1;
}

.result-heading h2,
.card h3 {
  margin: 0.35rem 0 0.75rem;
}

.card p,
.card li {
  color: #526078;
  line-height: 1.65;
}

.card ul,
.card ol {
  margin-bottom: 0;
  padding-left: 1.25rem;
}

@media (max-width: 700px) {
  .shell {
    padding: 2rem 0;
  }

  .offer,
  .results {
    grid-template-columns: 1fr;
  }

  .offer ul,
  .card-wide {
    grid-column: auto;
  }

  .offer ul {
    grid-template-columns: 1fr;
  }
}

This stylesheet reference contains the final responsive layout plus the loading, success, and error presentation states.

const form = document.querySelector("#brief-form");
const briefInput = document.querySelector("#brief");
const submitButton = document.querySelector("#submit-button");
const statusMessage = document.querySelector("#status-message");
const results = document.querySelector("#results");

function setStatus(message, state = "") {
  statusMessage.textContent = message;
  statusMessage.dataset.state = state;
}

function fillList(elementId, items) {
  const list = document.querySelector(`#${elementId}`);
  list.replaceChildren();

  for (const item of items) {
    const listItem = document.createElement("li");
    listItem.textContent = item;
    list.appendChild(listItem);
  }
}

function renderPlan(plan) {
  document.querySelector("#project-name").textContent = plan.project_name;
  document.querySelector("#summary").textContent = plan.summary;
  document.querySelector("#audience").textContent = plan.audience;
  document.querySelector("#design-direction").textContent = plan.design_direction;
  fillList("sections", plan.sections);
  fillList("features", plan.features);
  fillList("next-steps", plan.next_steps);
  results.hidden = false;
}

form.addEventListener("submit", async (event) => {
  event.preventDefault();

  const brief = briefInput.value.trim();

  if (brief.length < 30) {
    results.hidden = true;
    setStatus("Please enter at least 30 characters.", "error");
    return;
  }

  submitButton.disabled = true;
  results.hidden = true;
  setStatus("Building your project plan...");

  try {
    const response = await fetch("/api/plan", {
      method: "POST",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify({ brief })
    });

    const data = await response.json();

    if (!response.ok) {
      throw new Error(data.error || "The request failed.");
    }

    renderPlan(data.plan);
    setStatus("Project plan ready.", "success");
  } catch (error) {
    setStatus(error.message || "Something went wrong. Please try again.", "error");
  } finally {
    submitButton.disabled = false;
  }
});

This browser reference contains the final brief guard, loading state, error handling, safe rendering, and button recovery path.

That is the hardening pass complete. Your Client Brief Architect now keeps the $350 package clear while guiding users safely through both successful and failed requests.

Secret mission

Add Deadline and Scope Risk Flags

Carry an optional deadline through the browser-to-model data contract. Flag multi-page requests, advanced functionality, or tight timelines that need a custom quote beyond the $350 package.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your local app running, pause it for later, or delete it entirely. The project cost remains $0 within Gemini API Free Tier limits, while $350 remains your client-facing service price.

Resources you used:

  • A local Express server process running at http://localhost:3000.
  • The local client-brief-architect folder.
  • The GEMINI_API_KEY value stored in the current Visual Studio Code terminal session.
  • A Gemini API key managed through Google AI Studio.

Keep everything running

No action is needed. Choose this if you plan to keep testing Client Brief Architect.

  • Leave the server running at http://localhost:3000.
  • Keep the current terminal open to preserve GEMINI_API_KEY for this session.
  • Continue using fictional briefs because Free Tier content may be used to improve Google products.
  • Protect the Gemini API key from browser code or public files.

Pause - I'll come back to this later

Stop the local server to free its port while preserving your project files. The API key remains active in Google AI Studio after the terminal closes.

  • Return to the Visual Studio Code terminal that is running the server.
  • Press Ctrl+C to stop the Express process.
  • Refresh http://localhost:3000 to confirm the app is no longer being served.
  • Close the current terminal to clear the session-scoped GEMINI_API_KEY value.
  • Keep the client-brief-architect folder for your next session.
  • Leave the Gemini API key active only if you plan to return to the app.

Delete - I don't want to use this again

Deleting the local folder permanently removes your finished app. Your Visual Studio Code and Node.js installations remain available for future projects.

  • Copy any project files you want to keep to a different folder.
  • Press Ctrl+C in the current Visual Studio Code terminal to stop the Express process.
  • Close the current terminal to clear the session-scoped GEMINI_API_KEY value.
  • Return to the Google AI Studio API Keys page from earlier.
  • Delete the Gemini API key used by Client Brief Architect.
  • Confirm the key no longer appears as active.

macOS

Finder gives you a visual way to remove the exact project folder without guessing its path.

  • Close the Visual Studio Code window that contains client-brief-architect.
  • Use Finder to locate the client-brief-architect folder at the location where you created it.
  • Move only the client-brief-architect folder to Trash.
  • Open Trash.
  • Permanently remove only the client-brief-architect folder.
  • Return to its former location in Finder to confirm the folder is gone.

Windows

File Explorer lets you remove the project folder without affecting your installed development tools.

  • Close the Visual Studio Code window that contains client-brief-architect.
  • Use File Explorer to locate the client-brief-architect folder at the location where you created it.
  • Move only the client-brief-architect folder to the Recycle Bin.
  • Open the Recycle Bin.
  • Permanently remove only the client-brief-architect folder.
  • Return to its former location in File Explorer to confirm the folder is gone.

Linux

Your Linux file manager can remove the project folder while leaving Node.js available for other work.

  • Close the Visual Studio Code window that contains client-brief-architect.
  • Use your Linux file manager to locate the client-brief-architect folder at the location where you created it.
  • Move only the client-brief-architect folder to your system trash.
  • Open your system trash.
  • Permanently remove only the client-brief-architect folder.
  • Return to its former location in your file manager to confirm the folder is gone.

Nice Work!

Nice Work!

You did it! Client Brief Architect now turns fictional client briefs into scoped plans for a $350 one-page website.

What you learned:

  • Built a responsive brief-intake interface around a clearly scoped $350 starter offer.
  • Served the frontend through Express. Sent each fictional brief to the Gemini API from a server-side endpoint. Kept GEMINI_API_KEY out of browser code.
  • Turned unreliable model prose into predictable structured output with JSON Schema. Validated every plan at runtime. Added visible loading states. Added useful failure messages.
  • Secret Mission: Extended the data contract with an optional deadline. Added Scope and Delivery Risks analysis that recommends a custom quote for requests beyond the $350 package.

Ready to quiz yourself?