Build an AI Operations Platform

Build a multi-agent operations platform on a student-friendly free stack.

Introduction

30 Second Summary

Client requests rarely arrive as tidy tickets. Important details can sit inside a long message while urgent work competes with routine questions.

In this project, you will build a full-stack request router that uses Gemini to turn a synthetic client message into a structured proposal. A reviewer approves that proposal before the app creates a GitHub issue.

What You'll Build

Your finished dashboard turns one synthetic client message into a clear ticket proposal that you can review before opening it as a real GitHub issue.

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

  • A polished client request form for synthetic contact details plus a free-text message.
  • A structured AI review that shows seven validated fields for triage before ticket creation.
  • A human-in-the-loop approval flow that opens a real issue only after you accept the AI proposal. Your Gemini credential stays on the server. Your GitHub credential also stays there.
  • Secret Mission: Let the reviewer edit Gemini's issue title and body before approval.

Are there any prerequisites?

Basic JavaScript knowledge is enough for the code in this project. The project also requires a Gemini API key plus a GitHub repository with Issues enabled.

Before We Start

This first checkpoint commits you to a tool that processes synthetic client software requests. A person must review the AI-generated triage and issue content before it becomes an external team-workflow record.

Set Up the Full-Stack App

The AI analysis and GitHub approval flow need a reproducible local runtime. A visible starter page gives you a stable base before either integration adds complexity.

In this step, you will prepare Node.js 24 for a Next.js app. You will configure TypeScript before launching the first version in your browser.

In this step, get ready to:
  • Verify that Node.js and npm meet the project requirements.
  • Create the project configuration with its local dependencies.
  • Run the starter page and confirm its title in the browser.
Check Node.js and npm

The AI SDK used later requires a supported Node.js runtime. This project standardizes on major version 24 so every dependency runs against the same foundation.

  • Check your Node.js version by running the first command below:
  • Check your npm version by running the second command below:
node --version
npm --version

What do these checks show?

The first command reports your installed Node.js version. The second command confirms that npm is available for installing project dependencies.

If Node.js is missing or below major version 24, nvm can install the required runtime without changing the project files.

✔️ I see Node.js 24 or higher

Your runtime is ready. Node.js can now run the framework and packages used throughout this project.

  • Keep this terminal open for the project setup commands in the next substep.

ⓧ I see an older Node.js version

Your current Node.js version is below the project requirement. Install nvm 0.40.8 so you can add Node.js 24.

  • Install nvm by running this command:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.8/install.sh | bash

What does the installer do?

The installer downloads nvm 0.40.8 from its official repository. It configures your shell so future terminals can use the nvm command.

  • Close the terminal after the installer finishes.
  • Reopen the terminal so it loads the new shell configuration.
  • Install Node.js 24 by running this command:
nvm install 24

What does nvm install?

nvm downloads Node.js major version 24 for your user account. It also activates that runtime in the current terminal.

  • Confirm the upgraded Node.js version by running the first command below:
  • Confirm npm is available by running the second command below:
node --version
npm --version

What confirms the upgrade?

The Node.js output now begins with major version 24. The npm command also returns a version number.

ⓧ Node.js is not installed

The missing command means Node.js is unavailable in this terminal. Install nvm 0.40.8 before adding Node.js 24.

  • Install nvm by running this command:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.8/install.sh | bash

What does the installer add?

The script installs nvm 0.40.8 for your user account. A fresh terminal can then use nvm to manage Node.js versions.

  • Close the terminal after the installer finishes.
  • Reopen the terminal so it loads nvm.
  • Install Node.js 24 by running this command:
nvm install 24

What happens next?

nvm downloads Node.js major version 24 and activates it in the reopened terminal.

  • Confirm Node.js is available by running the first command below:
  • Confirm npm is available by running the second command below:
node --version
npm --version

What should the check prove?

The Node.js output begins with major version 24. The npm command returns its own version number.

Still seeing the wrong Node.js version?

Make sure you reopened the terminal after installing nvm. An older terminal session may not know that the new command exists.

Run the version checks in the same terminal where nvm install 24 completed.

Help me fix my Node.js version setup.

Create the project configuration

A manually configured project makes every dependency and compiler choice visible. The package.json file records the packages while tsconfig.json controls TypeScript.

VS Code may show a workspace trust prompt when it opens the folder. You created this folder yourself, so confirming trust lets the editor use its project features.

  • Create the ai-client-request-router directory structure on your Desktop by running these commands:
cd ~/Desktop
mkdir ai-client-request-router
cd ai-client-request-router
mkdir -p src/app
code .

What does this setup do?

  • The first command moves your terminal to the Desktop.
  • The next commands create the ai-client-request-router folder and its src/app directory.
  • The final command opens the current folder in VS Code as a workspace.
  • Confirm that you trust the folder if VS Code asks.
  • Confirm the Explorer sidebar shows ai-client-request-router as the open folder.

VS Code did not open?

The Linux installation normally adds the code command to your system path. Reopen your terminal before trying again if the command is unavailable.

Help me open my project folder in VS Code.

  • Create .nvmrc in the ai-client-request-router folder using the Explorer sidebar.
  • Set the project runtime by pasting this into .nvmrc:
24

Why add an .nvmrc file?

The file records major version 24 as the expected Node.js runtime. That keeps the runtime requirement next to the project code.

  • Save .nvmrc.
  • Create package.json in the ai-client-request-router folder using the Explorer sidebar.
  • Define the starter dependencies by pasting this into package.json:
{
  "name": "ai-client-request-router",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "16.4.0",
    "react": "19.3.0",
    "react-dom": "19.3.0"
  },
  "devDependencies": {
    "@types/node": "26.6.5",
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0",
    "typescript": "7.0.2"
  }
}

What does package.json configure?

  • Next.js 16.4.0 provides the application framework and development server.
  • React 19.3.0 renders the user interface.
  • TypeScript 7.0.2 checks the application types during development and builds.
  • The AI packages stay out of this starter configuration until the analysis step.
  • Save package.json.
  • Install the configured dependencies by running this command in the project terminal:
npm install

What does npm install do?

npm reads package.json and installs its dependencies inside the project. The packages remain local to ai-client-request-router.

  • Confirm the terminal returns to its prompt without an installation error.

Dependency installation failed?

Confirm that your terminal is inside ~/Desktop/ai-client-request-router before running the install command. Also confirm that package.json contains valid JSON with matching braces.

Help me debug my npm installation.

  • Create .gitignore in the ai-client-request-router folder using the Explorer sidebar.
  • Define the files that Git must ignore by pasting this into .gitignore:
node_modules
.next
.env*
!.env.local.example
npm-debug.log*

What stays out of source control?

Generated dependency files and Next.js build output do not belong in source control. The environment pattern also protects local configuration files while allowing the example file used later.

  • Save .gitignore.
  • Create tsconfig.json in the ai-client-request-router folder using the Explorer sidebar.
  • Configure TypeScript by pasting this into tsconfig.json:
{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": false,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [{ "name": "next" }],
    "paths": { "@/*": ["./src/*"] }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

What does this TypeScript config do?

  • Strict mode catches unsafe type assumptions during development.
  • The bundler setting matches how Next.js resolves application modules.
  • The @/* path maps imports to the src directory.
  • Save tsconfig.json.
  • Confirm the Explorer sidebar lists .nvmrc, package.json, .gitignore, and tsconfig.json in ai-client-request-router.
Build and run the starter page

The Next.js App Router uses a root layout to wrap every page. The temporary page only needs enough structure to prove the full-stack framework starts correctly.

  • Create src/app/layout.tsx using the Explorer sidebar.
  • Define the root document by pasting this into src/app/layout.tsx:
import "./globals.css";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

What does the root layout do?

  • The stylesheet import applies the global theme to every page.
  • The RootLayout component places page content inside the document body.
  • The children value represents the page that Next.js renders inside the layout.
  • Save src/app/layout.tsx.
  • Create src/app/globals.css using the Explorer sidebar.
  • Add the starter dark theme by pasting this into src/app/globals.css:
:root { color-scheme: dark; --panel: rgba(15, 28, 47, 0.9); --border: rgba(148, 163, 184, 0.22); --text: #f8fafc; --muted: #a8b5c7; --accent: #67e8f9; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: radial-gradient(circle at top left, #15314a, #07111f 45%); color: var(--text); font-family: Arial, Helvetica, sans-serif; }
.page-shell { width: min(1180px, calc(100% - 32px)); margin: auto; padding: 56px 0 80px; }
.hero { max-width: 720px; margin-bottom: 28px; }
.eyebrow, .step-label { color: var(--accent); font-size: 0.78rem; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; }
h1 { margin: 8px 0 12px; font-size: clamp(2.5rem, 6vw, 4.7rem); line-height: 1; }
.hero > p:last-child, .privacy-note { color: var(--muted); }

What does the starter theme control?

  • The root variables define the reusable text and accent colours.
  • The body rule supplies the dark background and base typography.
  • The hero rules make the product title readable across different screen widths.
  • Save src/app/globals.css.
  • Confirm the Explorer sidebar lists layout.tsx and globals.css inside src/app.
  • Create src/app/page.tsx using the Explorer sidebar.
  • Add the temporary product page by pasting this into src/app/page.tsx:
export default function Home() {
  return (
    <main className="page-shell">
      <header className="hero">
        <p className="eyebrow">Full-stack AI workflow</p>
        <h1>AI Client Request Router</h1>
        <p>Turn an unstructured message into a reviewed GitHub ticket.</p>
      </header>
    </main>
  );
}

What does the starter page render?

The Home component renders the product heading inside the styled page shell. It contains no request form or external integration yet.

  • Save src/app/page.tsx.

✔️ Awesome, I've got everything!

  • Save all seven project files before starting the development server.

ⓧ I'd like to double check the full code

  • Compare each file with the complete starter code below.
24

What does this file confirm?

The project records Node.js major version 24 as its runtime.

{
  "name": "ai-client-request-router",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "16.4.0",
    "react": "19.3.0",
    "react-dom": "19.3.0"
  },
  "devDependencies": {
    "@types/node": "26.6.5",
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0",
    "typescript": "7.0.2"
  }
}

What should package.json contain?

The starter package contains the framework and type-checking dependencies. The AI packages are absent at this stage.

node_modules
.next
.env*
!.env.local.example
npm-debug.log*

What should Git ignore?

Dependency files, build output, local environment files, and npm debug logs remain outside source control.

{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": false,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [{ "name": "next" }],
    "paths": { "@/*": ["./src/*"] }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

What should TypeScript enforce?

The compiler uses strict checking and preserves JSX for Next.js. Imports beginning with @/ resolve from src.

import "./globals.css";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

What should the layout contain?

The layout imports the global stylesheet and renders every page inside the document body.

:root { color-scheme: dark; --panel: rgba(15, 28, 47, 0.9); --border: rgba(148, 163, 184, 0.22); --text: #f8fafc; --muted: #a8b5c7; --accent: #67e8f9; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: radial-gradient(circle at top left, #15314a, #07111f 45%); color: var(--text); font-family: Arial, Helvetica, sans-serif; }
.page-shell { width: min(1180px, calc(100% - 32px)); margin: auto; padding: 56px 0 80px; }
.hero { max-width: 720px; margin-bottom: 28px; }
.eyebrow, .step-label { color: var(--accent); font-size: 0.78rem; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; }
h1 { margin: 8px 0 12px; font-size: clamp(2.5rem, 6vw, 4.7rem); line-height: 1; }
.hero > p:last-child, .privacy-note { color: var(--muted); }

What should the stylesheet provide?

The stylesheet supplies the initial dark theme and responsive product heading.

export default function Home() {
  return (
    <main className="page-shell">
      <header className="hero">
        <p className="eyebrow">Full-stack AI workflow</p>
        <h1>AI Client Request Router</h1>
        <p>Turn an unstructured message into a reviewed GitHub ticket.</p>
      </header>
    </main>
  );
}

What should the page contain?

The temporary page renders the product title and its one-line purpose. No request form is present yet.

Before you start the server, do you expect the browser to show the product title or an empty page?

  • Start the Next.js development server by running this command in the project terminal:
npm run dev

What does the development command do?

npm runs the dev script from package.json. Next.js starts a local development server that keeps this terminal occupied.

  • Leave the development server running in this terminal.
  • Open http://localhost:3000 in your browser.

You will see “AI Client Request Router” above a short description on a dark background. That confirms the Next.js app is running from your local project.

Starter page not loading?

Confirm that the terminal running the server is inside ~/Desktop/ai-client-request-router. Check that layout.tsx, globals.css, and page.tsx are all inside src/app.

Help me debug my Next.js starter page.

Your full-stack foundation is live. Next, you will turn this starter page into a controlled client request form with a visible raw preview.

Capture a Client Request

Your Next.js starter page proves the app can render in a browser. A client request still has nowhere to go.

The first useful slice is a controlled React form that captures each field. Its local preview exposes the triage problem because the submitted message remains one unstructured block.

In this step, get ready to:
  • Capture synthetic client details with controlled form fields.
  • Store each submitted request in a local preview.
  • Arrange the intake form beside a responsive review panel.
Build the controlled request form

A controlled form stores every input value in component state. The submitted snapshot updates only when the reviewer selects the preview button.

  • Select src/app/page.tsx in the VS Code Explorer sidebar.
  • Replace the entire file with this code:
"use client";

import { useState } from "react";
type ClientRequest = { name: string; email: string; message: string };
const initialRequest: ClientRequest = { name: "", email: "", message: "" };

export default function Home() {
  // Keep every input value in React state so the preview uses current data.
  const [request, setRequest] = useState<ClientRequest>(initialRequest);
  const [submittedRequest, setSubmittedRequest] = useState<ClientRequest | null>(null);

  // Store a snapshot only when the reviewer submits the form.
  function handlePreview() { setSubmittedRequest(request); }

  return (<main className="page-shell">
    <header className="hero"><p className="eyebrow">Full-stack AI workflow</p><h1>AI Client Request Router</h1><p>Turn an unstructured message into a reviewed GitHub ticket.</p></header>
    <section className="workspace">
      <form className="panel form-panel" onSubmit={(event) => { event.preventDefault(); handlePreview(); }}>
        <p className="step-label">1. Intake</p><h2>Client request</h2>
        <label>Client name<input required value={request.name} onChange={(event) => setRequest({ ...request, name: event.target.value })} placeholder="Jordan Lee" /></label>
        <label>Client email<input required type="email" value={request.email} onChange={(event) => setRequest({ ...request, email: event.target.value })} placeholder="jordan@example.com" /></label>
        <label>Request<textarea required rows={8} value={request.message} onChange={(event) => setRequest({ ...request, message: event.target.value })} placeholder="Describe a synthetic request." /></label>
        <button className="primary-button" type="submit">Preview request</button><p className="privacy-note">Use synthetic requests only.</p>
      </form>
      <section className="panel result-panel" aria-live="polite">
        <p className="step-label">2. Review</p><h2>Raw request</h2>
        {submittedRequest ? <article className="result-card"><h3>Submitted request</h3><strong>{submittedRequest.name}</strong><p>{submittedRequest.email}</p><p className="pre-wrap">{submittedRequest.message}</p></article> : <div className="empty-state">Submit a request to preview the unstructured message.</div>}
      </section></section>
  </main>);
}

What does this code do?

  • The "use client" directive lets this component hold interactive browser state.
  • The request state holds the current values from all three controlled fields.
  • The submittedRequest state holds the latest submitted snapshot.
  • The handlePreview() function moves the current form data into the result panel.
  • The event.preventDefault() call keeps the browser from reloading during submission.
  • Save src/app/page.tsx.
  • Switch back to the browser from earlier.

The development server refreshes the page. You'll see three form fields beside a raw request panel.

  • Enter Jordan Lee in the Client name field.
  • Enter jordan@example.com in the Client email field.
  • Enter Our checkout fails after card payment and customers cannot finish orders. in the Request field.
  • Before you preview the request, predict which details will appear in the result panel.
  • Select Preview request.

You'll see one raw request card containing the same synthetic details. The message remains a single unstructured paragraph.

The card contains no request type, priority, or recommended action. That is the intended triage gap for the next step.

Preview card missing?

Confirm the development server from the previous step is still running. Save src/app/page.tsx again if the browser has not refreshed.

Check that each field updates request through its own onChange handler.

Ask for help with the component state if the form still stays empty: help me debug why my controlled React form does not show the submitted request

✔️ Awesome, I've got everything!

Your controlled form now captures a local request snapshot. Make sure src/app/page.tsx is saved before continuing.

ⓧ I'd like to double check the full code

"use client";

import { useState } from "react";
type ClientRequest = { name: string; email: string; message: string };
const initialRequest: ClientRequest = { name: "", email: "", message: "" };

export default function Home() {
  // Keep every input value in React state so the preview uses current data.
  const [request, setRequest] = useState<ClientRequest>(initialRequest);
  const [submittedRequest, setSubmittedRequest] = useState<ClientRequest | null>(null);

  // Store a snapshot only when the reviewer submits the form.
  function handlePreview() { setSubmittedRequest(request); }

  return (<main className="page-shell">
    <header className="hero"><p className="eyebrow">Full-stack AI workflow</p><h1>AI Client Request Router</h1><p>Turn an unstructured message into a reviewed GitHub ticket.</p></header>
    <section className="workspace">
      <form className="panel form-panel" onSubmit={(event) => { event.preventDefault(); handlePreview(); }}>
        <p className="step-label">1. Intake</p><h2>Client request</h2>
        <label>Client name<input required value={request.name} onChange={(event) => setRequest({ ...request, name: event.target.value })} placeholder="Jordan Lee" /></label>
        <label>Client email<input required type="email" value={request.email} onChange={(event) => setRequest({ ...request, email: event.target.value })} placeholder="jordan@example.com" /></label>
        <label>Request<textarea required rows={8} value={request.message} onChange={(event) => setRequest({ ...request, message: event.target.value })} placeholder="Describe a synthetic request." /></label>
        <button className="primary-button" type="submit">Preview request</button><p className="privacy-note">Use synthetic requests only.</p>
      </form>
      <section className="panel result-panel" aria-live="polite">
        <p className="step-label">2. Review</p><h2>Raw request</h2>
        {submittedRequest ? <article className="result-card"><h3>Submitted request</h3><strong>{submittedRequest.name}</strong><p>{submittedRequest.email}</p><p className="pre-wrap">{submittedRequest.message}</p></article> : <div className="empty-state">Submit a request to preview the unstructured message.</div>}
      </section></section>
  </main>);
}
Style the review workspace

The intake form needs a clear relationship with its result. A CSS Grid workspace places both panels side by side on wide screens.

  • Select src/app/globals.css in the VS Code Explorer sidebar.
  • Replace the entire file with this stylesheet:
/* Establish the shared dark theme and readable page dimensions. */
:root { color-scheme: dark; --panel: rgba(15, 28, 47, 0.9); --border: rgba(148, 163, 184, 0.22); --text: #f8fafc; --muted: #a8b5c7; --accent: #67e8f9; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: radial-gradient(circle at top left, #15314a, #07111f 45%); color: var(--text); font-family: Arial, Helvetica, sans-serif; }
button, input, textarea { font: inherit; }
.page-shell { width: min(1180px, calc(100% - 32px)); margin: auto; padding: 56px 0 80px; }
.hero { max-width: 720px; margin-bottom: 28px; }
.eyebrow, .step-label { color: var(--accent); font-size: 0.78rem; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; }
h1 { margin: 8px 0 12px; font-size: clamp(2.5rem, 6vw, 4.7rem); line-height: 1; }
h2 { margin-top: 0; }
.hero > p:last-child, .privacy-note { color: var(--muted); }
/* Separate intake from review while keeping both panels aligned. */
.workspace { display: grid; grid-template-columns: 0.9fr 1.1fr; gap: 24px; align-items: start; }
.panel { padding: 26px; border: 1px solid var(--border); border-radius: 20px; background: var(--panel); box-shadow: 0 24px 70px #0006; }
.form-panel { display: grid; gap: 16px; }
label { display: grid; gap: 8px; color: #dbeafe; font-weight: 700; }
input, textarea { width: 100%; border: 1px solid #334155; border-radius: 11px; background: #020817bb; color: white; padding: 12px 13px; }
textarea { resize: vertical; }
.primary-button { border: 0; border-radius: 11px; background: linear-gradient(135deg, #22d3ee, #38bdf8); color: #04111d; cursor: pointer; font-weight: 900; padding: 13px 16px; }
.primary-button:disabled { cursor: not-allowed; opacity: 0.55; }
.result-panel { min-height: 490px; }
.empty-state { display: grid; min-height: 350px; place-items: center; border: 1px dashed #475569; border-radius: 14px; color: var(--muted); text-align: center; }
.result-card { border: 1px solid #94a3b826; border-radius: 13px; background: #0208177a; padding: 15px; }
.result-card h3 { color: var(--muted); font-size: 0.78rem; text-transform: uppercase; }
.result-card p { margin-bottom: 0; line-height: 1.55; }
.pre-wrap { white-space: pre-wrap; }
/* Stack the panels when the screen cannot fit two readable columns. */
@media (max-width: 840px) { .workspace { grid-template-columns: 1fr; } .result-panel { min-height: auto; } }

What does this CSS do?

  • The .workspace grid gives the form a narrower column beside a wider review column.
  • The .panel rule gives both sections the same border, background, spacing, and depth.
  • The form controls use a dark background with high-contrast text for readability.
  • The media query changes the workspace to one column when the viewport is 840px wide or narrower.
  • Save src/app/globals.css.
  • Before you inspect the page, predict where the intake form and raw preview will sit on a wide screen.
  • Switch back to the browser from earlier.

You'll see the intake form on the left. The raw request panel sits on the right.

  • Narrow the browser window until the panels stack.

You'll see the review panel move below the intake form. This keeps both sections readable on a smaller screen.

  • Return the browser window to a wide size.
  • Before you select the preview button again, predict whether the result panel will add any triage fields.
  • Select Preview request.

You'll see one raw request block with the submitted name, email address, and message. You will see no request type, priority, or recommended action.

Good work. Your intake and review workspace now makes the manual triage gap visible.

Styles not showing?

Confirm src/app/layout.tsx still imports ./globals.css.

Check that the form and result section both use the panel class. Confirm their parent uses the workspace class.

Ask for help if the page still appears unstyled: help me debug why my Next.js page is not using the styles from globals.css

✔️ Awesome, I've got everything!

Your form and raw preview now share a responsive two-column workspace. Double check that src/app/globals.css is saved.

ⓧ I'd like to double check the full code

/* Establish the shared dark theme and readable page dimensions. */
:root { color-scheme: dark; --panel: rgba(15, 28, 47, 0.9); --border: rgba(148, 163, 184, 0.22); --text: #f8fafc; --muted: #a8b5c7; --accent: #67e8f9; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: radial-gradient(circle at top left, #15314a, #07111f 45%); color: var(--text); font-family: Arial, Helvetica, sans-serif; }
button, input, textarea { font: inherit; }
.page-shell { width: min(1180px, calc(100% - 32px)); margin: auto; padding: 56px 0 80px; }
.hero { max-width: 720px; margin-bottom: 28px; }
.eyebrow, .step-label { color: var(--accent); font-size: 0.78rem; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; }
h1 { margin: 8px 0 12px; font-size: clamp(2.5rem, 6vw, 4.7rem); line-height: 1; }
h2 { margin-top: 0; }
.hero > p:last-child, .privacy-note { color: var(--muted); }
/* Separate intake from review while keeping both panels aligned. */
.workspace { display: grid; grid-template-columns: 0.9fr 1.1fr; gap: 24px; align-items: start; }
.panel { padding: 26px; border: 1px solid var(--border); border-radius: 20px; background: var(--panel); box-shadow: 0 24px 70px #0006; }
.form-panel { display: grid; gap: 16px; }
label { display: grid; gap: 8px; color: #dbeafe; font-weight: 700; }
input, textarea { width: 100%; border: 1px solid #334155; border-radius: 11px; background: #020817bb; color: white; padding: 12px 13px; }
textarea { resize: vertical; }
.primary-button { border: 0; border-radius: 11px; background: linear-gradient(135deg, #22d3ee, #38bdf8); color: #04111d; cursor: pointer; font-weight: 900; padding: 13px 16px; }
.primary-button:disabled { cursor: not-allowed; opacity: 0.55; }
.result-panel { min-height: 490px; }
.empty-state { display: grid; min-height: 350px; place-items: center; border: 1px dashed #475569; border-radius: 14px; color: var(--muted); text-align: center; }
.result-card { border: 1px solid #94a3b826; border-radius: 13px; background: #0208177a; padding: 15px; }
.result-card h3 { color: var(--muted); font-size: 0.78rem; text-transform: uppercase; }
.result-card p { margin-bottom: 0; line-height: 1.55; }
.pre-wrap { white-space: pre-wrap; }
/* Stack the panels when the screen cannot fit two readable columns. */
@media (max-width: 840px) { .workspace { grid-template-columns: 1fr; } .result-panel { min-height: auto; } }

Your browser can now capture a client request and expose its unstructured form. Next up, Gemini turns that raw message into validated fields that the app can use.

Turn Free Text into Structured AI Data

Your request form captures the client's message. The raw paragraph still cannot drive reliable labels or workflow decisions.

In this step, a server-only Gemini integration will turn that paragraph into seven validated fields. A Zod schema will check the result before your Next.js app displays it.

In this step, get ready to:
  • Install the AI packages and configure the Gemini environment variable.
  • Create shared data types and a server analysis route.
  • Replace the raw preview with a structured AI review.
Install the AI packages and configure Gemini

The AI SDK provides the structured generation interface. The Google provider connects that interface to Gemini.

Why use synthetic requests?

Gemini free-tier content may be used to improve Google products. Use invented names and requests throughout this project.

  • Return to the terminal running the development server.
  • Stop the server by pressing Ctrl+C.
  • Install AI SDK 7.0.137, the Google provider 4.0.93, and Zod 4.6.6 by running this command:
npm install ai@7.0.137 @ai-sdk/google@4.0.93 zod@4.6.6

What does this command add?

  • The ai package provides structured generation helpers.
  • The @ai-sdk/google package connects the AI SDK to Gemini.
  • The zod package validates the generated object.

The installation can take a moment while npm downloads the packages. You will see the terminal prompt return when it finishes.

Package installation failed?

Confirm the terminal is inside the ai-client-request-router folder. Confirm your active Node.js major version is 24.

Use the terminal output to diagnose the failed package installation.

An environment variable lets server code read a credential without placing it in browser code. Your existing .gitignore keeps the live local file out of source control.

  • Create .env.local.example inside the ai-client-request-router folder using the VS Code file sidebar.
  • Document the current Gemini setting and the future GitHub settings by pasting this file:
GOOGLE_GENERATIVE_AI_API_KEY=your-gemini-api-key-here
GITHUB_TOKEN=your-github-token-here
GITHUB_REPOSITORY=your-github-username/your-test-repository

Why include placeholder settings?

The example file documents the finished environment shape without storing live credentials. The GitHub values remain placeholders until the approval workflow is added.

  • Save .env.local.example.

Adding a live credential is the sensitive part. The value stays inside an ignored local file that only the server reads.

  • Create .env.local inside the ai-client-request-router folder using the VS Code file sidebar.
  • Add the Gemini setting by pasting this line:
GOOGLE_GENERATIVE_AI_API_KEY=your-gemini-api-key-here

How does the key stay private?

Next.js loads GOOGLE_GENERATIVE_AI_API_KEY into server-side code. The browser never needs the live value.

The .env* rule in .gitignore excludes this file from Git. The exception keeps only .env.local.example available for documentation.

  • Replace the placeholder in .env.local with your Gemini API key.
  • Save .env.local.
  • Restart the development server by running this command:
npm run dev

Why restart the server?

The new server process loads the value from .env.local. The analysis route can now access the key when it handles a request.

  • Switch back to the browser that remains open at http://localhost:3000.
  • Refresh the page.

You should still see the request form and raw preview workspace. The restarted server now has access to the Gemini environment variable.

Build the schema-validated server route

Shared TypeScript types give the client and server the same data shape. A Route Handler then checks the incoming form data before requesting a structured result.

  • Create src/lib/types.ts inside the ai-client-request-router folder using the VS Code file sidebar.
  • Paste the complete shared type definitions from the second tab below.

✔️ Awesome, I've got everything!

Use this tab after your file matches the complete reference. Save the file before continuing.

ⓧ I'd like to double check the full code

export type RequestType = "bug" | "feature" | "support" | "sales" | "other";
export type Priority = "low" | "medium" | "high" | "critical";

export interface ClientRequest {
  name: string;
  email: string;
  message: string;
}

export interface LeadAnalysis {
  requestType: RequestType;
  priority: Priority;
  summary: string;
  recommendedAction: string;
  draftReply: string;
  issueTitle: string;
  issueBody: string;
}

What do these types define?

The two unions restrict classification to supported request types and priorities. The interfaces describe the submitted request plus all seven fields in a successful analysis.

  • Save src/lib/types.ts.
  • Check the development server terminal.

You should see the development server compile without a TypeScript error. The shared model is now available through the existing @/* path mapping.

  • Create src/app/api/analyze/route.ts inside the ai-client-request-router folder using the VS Code file sidebar.
  • Paste the complete analysis route from the second tab below.

✔️ Awesome, I've got everything!

Use this tab after your route matches the complete reference. Save the route before checking the server terminal.

ⓧ I'd like to double check the full code

import { google } from "@ai-sdk/google";
import { generateText, Output } from "ai";
import { z } from "zod";

const analysisSchema = z.object({
  requestType: z.enum(["bug", "feature", "support", "sales", "other"]),
  priority: z.enum(["low", "medium", "high", "critical"]),
  summary: z.string().min(1),
  recommendedAction: z.string().min(1),
  draftReply: z.string().min(1),
  issueTitle: z.string().min(1),
  issueBody: z.string().min(1)
});

export async function POST(request: Request) {
  try {
    const payload = await request.json();
    if (
      typeof payload.name !== "string" ||
      typeof payload.email !== "string" ||
      typeof payload.message !== "string" ||
      !payload.name.trim() ||
      !payload.email.trim() ||
      !payload.message.trim()
    ) {
      return Response.json({ error: "Name, email, and message are required." }, { status: 400 });
    }

    const { output } = await generateText({
      model: google("gemini-3.8-flash"),
      output: Output.object({
        name: "ClientRequestAnalysis",
        description: "A validated triage result and GitHub issue proposal.",
        schema: analysisSchema
      }),
      prompt: `You triage requests for a small software team.
Classify the request, assign a realistic priority, summarize it, recommend the next action, draft a short professional reply, and prepare a GitHub issue title and Markdown body.
Use critical only for outages, security incidents, severe data loss, or failures blocking many users.
Client name: ${payload.name}
Client email: ${payload.email}
Client request: ${payload.message}`
    });

    return Response.json({ analysis: output });
  } catch (error) {
    console.error("Analysis failed", error);
    return Response.json({ error: "Analysis failed. Check the server terminal." }, { status: 500 });
  }
}

How does the route control the result?

The input check rejects missing or blank form values. The schema restricts classifications and requires every generated text field to contain content.

The model call uses Output.object with analysisSchema. The browser receives only the validated output object.

  • Save src/app/api/analyze/route.ts.
  • Check the development server terminal.

You should see a successful compilation without an import or route error. The server can now accept a request at /api/analyze.

Route compilation failed?

Confirm the file path is src/app/api/analyze/route.ts. Confirm all three installed packages appear in package.json.

Use the highlighted line to debug the analysis route compilation error.

Connect the form to the analysis route

The client now needs to replace its local preview with a server request. It will send only the controlled form values to /api/analyze.

The updated component also needs loading and error states. Each generated field will receive its own review area.

  • Return to src/app/page.tsx in VS Code.
  • Select the entire file.
  • Replace it with the complete client component from the second tab below.

✔️ Awesome, I've got everything!

Use this tab after your component matches the complete reference. Save the file before checking the browser.

ⓧ I'd like to double check the full code

"use client";

import { useState } from "react";
import type { ClientRequest, LeadAnalysis } from "@/lib/types";

const initialRequest: ClientRequest = { name: "", email: "", message: "" };

export default function Home() {
  const [request, setRequest] = useState<ClientRequest>(initialRequest);
  const [analysis, setAnalysis] = useState<LeadAnalysis | null>(null);
  const [isAnalyzing, setIsAnalyzing] = useState(false);
  const [isCreatingIssue, setIsCreatingIssue] = useState(false);
  const [error, setError] = useState("");
  const [issue, setIssue] = useState<{ url: string; number: number } | null>(null);

  async function handleAnalyze() {
    setIsAnalyzing(true);
    setError("");
    setAnalysis(null);
    setIssue(null);
    try {
      const response = await fetch("/api/analyze", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(request)
      });
      const data = await response.json();
      if (!response.ok) throw new Error(data.error ?? "Analysis failed.");
      setAnalysis(data.analysis);
    } catch (caughtError) {
      setError(caughtError instanceof Error ? caughtError.message : "Analysis failed.");
    } finally {
      setIsAnalyzing(false);
    }
  }

  return (
    <main className="page-shell">
      <header className="hero">
        <p className="eyebrow">Full-stack AI workflow</p>
        <h1>AI Client Request Router</h1>
        <p>Turn an unstructured message into a reviewed GitHub ticket.</p>
      </header>

      <section className="workspace">
        <form
          className="panel form-panel"
          onSubmit={(event) => {
            event.preventDefault();
            void handleAnalyze();
          }}
        >
          <p className="step-label">1. Intake</p>
          <h2>Client request</h2>
          <label>Client name<input required value={request.name} onChange={(event) => setRequest({ ...request, name: event.target.value })} placeholder="Jordan Lee" /></label>
          <label>Client email<input required type="email" value={request.email} onChange={(event) => setRequest({ ...request, email: event.target.value })} placeholder="jordan@example.com" /></label>
          <label>Request<textarea required rows={8} value={request.message} onChange={(event) => setRequest({ ...request, message: event.target.value })} placeholder="Describe a synthetic request." /></label>
          <button className="primary-button" disabled={isAnalyzing} type="submit">{isAnalyzing ? "Analyzing request..." : "Analyze request"}</button>
          <p className="privacy-note">Use synthetic requests only.</p>
        </form>

        <section className="panel result-panel" aria-live="polite">
          <p className="step-label">2. Review</p>
          <h2>AI proposal</h2>
          {!analysis && !isAnalyzing && <div className="empty-state">Submit a request to generate a structured review.</div>}
          {isAnalyzing && <div className="empty-state">Gemini is structuring the request.</div>}
          {analysis && (
            <div className="analysis-stack">
              <div className="badge-row"><span className="badge">{analysis.requestType}</span><span className={`badge priority-${analysis.priority}`}>{analysis.priority} priority</span></div>
              <article className="result-card"><h3>Summary</h3><p>{analysis.summary}</p></article>
              <article className="result-card"><h3>Recommended action</h3><p>{analysis.recommendedAction}</p></article>
              <article className="result-card"><h3>Draft client reply</h3><p>{analysis.draftReply}</p></article>
              <article className="result-card"><h3>Proposed GitHub issue</h3><strong>{analysis.issueTitle}</strong><p className="pre-wrap">{analysis.issueBody}</p></article>
            </div>
          )}
          {error && <p className="error-message">{error}</p>}
        </section>
      </section>
    </main>
  );
}

How does the client use the route?

The controlled form values live in request. The submit handler sends that object to the server as JSON.

A successful response fills analysis. Loading and error state keep the review panel useful during every outcome.

  • Save src/app/page.tsx.
  • Switch back to the browser.

You should see the intake form beside an empty AI proposal panel. The old raw request preview is gone.

Component failed to compile?

Confirm src/lib/types.ts is saved. Check that the import path remains @/lib/types.

Use the browser or terminal error to debug the client component.

The final stylesheet gives the generated classifications and text fields a scannable review layout. It also keeps the workspace readable on narrow screens.

  • Return to src/app/globals.css in VS Code.
  • Select the entire file.
  • Replace it with the complete stylesheet from the second tab below.

✔️ Awesome, I've got everything!

Use this tab after your stylesheet matches the complete reference. Save the file before testing the full workflow.

ⓧ I'd like to double check the full code

:root { color-scheme: dark; --panel: rgba(15, 28, 47, 0.9); --border: rgba(148, 163, 184, 0.22); --text: #f8fafc; --muted: #a8b5c7; --accent: #67e8f9; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: radial-gradient(circle at top left, #15314a, #07111f 45%); color: var(--text); font-family: Arial, Helvetica, sans-serif; }
button, input, textarea { font: inherit; }
.page-shell { width: min(1180px, calc(100% - 32px)); margin: auto; padding: 56px 0 80px; }
.hero { max-width: 720px; margin-bottom: 28px; }
.eyebrow, .step-label { color: var(--accent); font-size: 0.78rem; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; }
h1 { margin: 8px 0 12px; font-size: clamp(2.5rem, 6vw, 4.7rem); line-height: 1; }
h2 { margin-top: 0; }
.hero > p:last-child, .privacy-note { color: var(--muted); }
.workspace { display: grid; grid-template-columns: 0.9fr 1.1fr; gap: 24px; align-items: start; }
.panel { padding: 26px; border: 1px solid var(--border); border-radius: 20px; background: var(--panel); box-shadow: 0 24px 70px #0006; }
.form-panel, .analysis-stack { display: grid; gap: 16px; }
label { display: grid; gap: 8px; color: #dbeafe; font-weight: 700; }
input, textarea { width: 100%; border: 1px solid #334155; border-radius: 11px; background: #020817bb; color: white; padding: 12px 13px; }
textarea { resize: vertical; }
.primary-button { border: 0; border-radius: 11px; background: linear-gradient(135deg, #22d3ee, #38bdf8); color: #04111d; cursor: pointer; font-weight: 900; padding: 13px 16px; }
.primary-button:disabled { cursor: not-allowed; opacity: 0.55; }
.result-panel { min-height: 490px; }
.empty-state { display: grid; min-height: 350px; place-items: center; border: 1px dashed #475569; border-radius: 14px; color: var(--muted); text-align: center; }
.badge-row { display: flex; gap: 8px; }
.badge { border: 1px solid #3b82f6; border-radius: 999px; color: #bfdbfe; padding: 6px 10px; text-transform: capitalize; }
.priority-high, .priority-critical { border-color: #fb7185; color: #fecdd3; }
.result-card { border: 1px solid #94a3b826; border-radius: 13px; background: #0208177a; padding: 15px; }
.result-card h3 { color: var(--muted); font-size: 0.78rem; text-transform: uppercase; }
.result-card p { margin-bottom: 0; line-height: 1.55; }
.pre-wrap { white-space: pre-wrap; }
.success-link { border: 1px solid #34d399; border-radius: 11px; color: #a7f3d0; padding: 12px; text-align: center; text-decoration: none; }
.error-message { border: 1px solid #fb718577; border-radius: 11px; color: #fda4af; padding: 12px; }
@media (max-width: 840px) { .workspace { grid-template-columns: 1fr; } .result-panel { min-height: auto; } }

How does the review stay scannable?

Badges separate classification from the longer generated fields. Result cards give each text value its own review area.

The responsive rule changes the workspace to one column on narrow screens. The analysis stays readable without changing its content.

  • Save src/app/globals.css.
  • Switch back to the browser.
  • Refresh the page.

Before you submit the test request, which fields do you expect Gemini to separate from the checkout problem?

  • Enter Jordan Lee in the Client name field.
  • Enter jordan@example.com in the Client email field.
  • Enter Our checkout fails after card payment and customers cannot finish orders. in the Request field.
  • Select Analyze request.

The request may take a moment while Gemini generates the object. You will first see the loading state in the review panel.

You should then see request type, priority, summary, recommended action, draft client reply, issue title, and issue body. The single raw preview block has been replaced by seven structured fields.

Analysis did not appear?

Confirm .env.local contains your live Gemini key. Confirm the development server was restarted after the file was saved.

Compare every property in analysisSchema with LeadAnalysis. Each property name must match exactly.

Use the browser message and server output to debug the structured Gemini request.

That is the raw-triage problem solved. Your app now converts one unstructured request into validated data while keeping the Gemini credential on the server.

Next, you will add an approval gate that turns the reviewed proposal into a real GitHub issue.

Approve and Create a GitHub Issue

Your dashboard now turns a client message into a structured proposal. The next goal is to move an approved proposal into your team's workflow.

The proposal cannot reach GitHub yet. Sending it automatically would give the AI control over an external system. This step adds a human approval gate before the repository changes.

In this step, get ready to:
  • Create restricted GitHub access for one test repository.
  • Build a server route that creates an approved issue.
  • Approve one proposal and open the resulting issue.
Configure GitHub access

A fine-grained personal access token lets the server create issues without exposing your GitHub password. Restricting the token to one test repository limits what the app can change.

  • Return to your GitHub account from earlier.
  • Select New repository.
  • Choose your GitHub username as the Owner.
  • Enter your test repository name in the repository name field.
  • Choose a private visibility for the test repository.
  • Select Create repository.
  • Select Settings in the test repository.
  • Confirm Issues are enabled for the repository.

Why use a test repository?

A test repository keeps this workflow away from production work. Restricting the token to that repository creates a smaller permission boundary.

Creating a token can feel risky because GitHub reveals the credential for you to store. This token stays local in .env.local and receives access to one repository.

  • Open your GitHub account Settings.
  • Select Developer settings.
  • Select Personal access tokens.
  • Select Fine-grained tokens.
  • Select Generate new token.

Keep the token restricted

Repository restrictions determine where the token works. Repository permissions determine which actions it can perform there.

  • Limit the token to the test repository you just created.
  • Set Issues to read and write permission.
  • Generate the fine-grained token.
  • Copy the revealed token to your clipboard.

Environment variables keep credentials outside the client component. Values without a NEXT_PUBLIC_ prefix remain available to server code only.

  • Switch back to .env.local from earlier.
  • Set GITHUB_TOKEN to the token you copied.
  • Set GITHUB_REPOSITORY to your GitHub username/your test repository name.

You should now see GOOGLE_GENERATIVE_AI_API_KEY, GITHUB_TOKEN, and GITHUB_REPOSITORY in the local file.

  • Save .env.local.

Cannot create or restrict the token?

Check that the test repository belongs to the owner selected during token creation. Confirm that the token has read and write permission for Issues.

Make sure the token exists only in .env.local. Never paste it into .env.local.example or commit it to source control.

Help me check my GitHub token scope without sharing the token value.

Add the server-side issue route

A Next.js Route Handler keeps the token on the server. It validates the approved data before making the REST request to GitHub.

  • Create src/app/api/issues/route.ts inside the ai-client-request-router folder using the New File control in the VS Code Explorer.
  • Paste the route scaffold into src/app/api/issues/route.ts using this code:
import type { ClientRequest, LeadAnalysis } from "@/lib/types";

type CreateIssuePayload = { requester: ClientRequest; analysis: LeadAnalysis };

export async function POST(request: Request) {
  try {
    const token = process.env.GITHUB_TOKEN;
    const repository = process.env.GITHUB_REPOSITORY;
    if (!token || !repository) {
      return Response.json({ error: "GitHub environment variables are missing." }, { status: 500 });
    }

    const parts = repository.split("/");
    if (parts.length !== 2) {
      return Response.json({ error: "GITHUB_REPOSITORY must use owner/repository." }, { status: 500 });
    }

    const [owner, repo] = parts;
    const payload = (await request.json()) as CreateIssuePayload;
    if (!payload.requester?.name || !payload.requester?.email || !payload.requester?.message || !payload.analysis?.issueTitle || !payload.analysis?.issueBody) {
      return Response.json({ error: "The approved issue payload is incomplete." }, { status: 400 });
    }
  } catch (error) {
    console.error("Issue creation failed", error);
    return Response.json({ error: "The GitHub issue could not be created." }, { status: 500 });
  }
}

What does this scaffold validate?

  • The imported types define the requester details and seven-field analysis expected from the browser.
  • The environment checks stop the route when either GitHub value is missing.
  • The repository check requires the owner/repository format.
  • The payload check requires requester details plus an issue title and body.
  • Insert the GitHub request directly before the catch block using this code:
    const githubResponse = await fetch(
      `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues`,
      {
        method: "POST",
        headers: {
          Accept: "application/vnd.github+json",
          Authorization: `Bearer ${token}`,
          "Content-Type": "application/json",
          "User-Agent": "ai-client-request-router",
          "X-GitHub-Api-Version": "2026-03-10"
        },
        body: JSON.stringify({
          title: payload.analysis.issueTitle,
          body: `${payload.analysis.issueBody}\n\n---\nRequester: ${payload.requester.name} (${payload.requester.email})\nOriginal request: ${payload.requester.message}`
        })
      }
    );

    const data = (await githubResponse.json()) as { message?: string; html_url?: string; number?: number };
    if (!githubResponse.ok || !data.html_url || !data.number) {
      return Response.json({ error: data.message ?? "GitHub did not create the issue." }, { status: githubResponse.status });
    }

    return Response.json({ url: data.html_url, number: data.number }, { status: 201 });

How does the GitHub request work?

  • The request targets the Issues endpoint for the configured repository.
  • The headers authenticate the request with the server-only token.
  • The body combines the AI-generated issue content with the original requester details.
  • A successful response returns the issue URL and issue number to the browser.
  • Save src/app/api/issues/route.ts.

Seeing a route error?

Check that route.ts is inside src/app/api/issues. Confirm the import path uses @/lib/types.

If the route reports missing GitHub configuration later, check the variable names in .env.local. Restart the development server after correcting the file.

Help me debug my GitHub issue Route Handler.

✔️ I've added the issue route

Good work. Save src/app/api/issues/route.ts before connecting the browser approval action.

ⓧ I'd like to double check the full code

import type { ClientRequest, LeadAnalysis } from "@/lib/types";

type CreateIssuePayload = { requester: ClientRequest; analysis: LeadAnalysis };

export async function POST(request: Request) {
  try {
    const token = process.env.GITHUB_TOKEN;
    const repository = process.env.GITHUB_REPOSITORY;
    if (!token || !repository) {
      return Response.json({ error: "GitHub environment variables are missing." }, { status: 500 });
    }

    const parts = repository.split("/");
    if (parts.length !== 2) {
      return Response.json({ error: "GITHUB_REPOSITORY must use owner/repository." }, { status: 500 });
    }

    const [owner, repo] = parts;
    const payload = (await request.json()) as CreateIssuePayload;
    if (!payload.requester?.name || !payload.requester?.email || !payload.requester?.message || !payload.analysis?.issueTitle || !payload.analysis?.issueBody) {
      return Response.json({ error: "The approved issue payload is incomplete." }, { status: 400 });
    }

    const githubResponse = await fetch(
      `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues`,
      {
        method: "POST",
        headers: {
          Accept: "application/vnd.github+json",
          Authorization: `Bearer ${token}`,
          "Content-Type": "application/json",
          "User-Agent": "ai-client-request-router",
          "X-GitHub-Api-Version": "2026-03-10"
        },
        body: JSON.stringify({
          title: payload.analysis.issueTitle,
          body: `${payload.analysis.issueBody}\n\n---\nRequester: ${payload.requester.name} (${payload.requester.email})\nOriginal request: ${payload.requester.message}`
        })
      }
    );

    const data = (await githubResponse.json()) as { message?: string; html_url?: string; number?: number };
    if (!githubResponse.ok || !data.html_url || !data.number) {
      return Response.json({ error: data.message ?? "GitHub did not create the issue." }, { status: githubResponse.status });
    }

    return Response.json({ url: data.html_url, number: data.number }, { status: 201 });
  } catch (error) {
    console.error("Issue creation failed", error);
    return Response.json({ error: "The GitHub issue could not be created." }, { status: 500 });
  }
}
Connect approval and verify the workflow

The server route is ready to receive an approved proposal. The client now needs a separate handler so analysis and external action remain two distinct decisions.

  • In src/app/page.tsx, find the closing brace of handleAnalyze().
  • Add handleApprove() directly below handleAnalyze() using this code:
  async function handleApprove() {
    if (!analysis) return;
    setIsCreatingIssue(true);
    setError("");
    try {
      const response = await fetch("/api/issues", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ requester: request, analysis })
      });
      const data = await response.json();
      if (!response.ok) throw new Error(data.error ?? "Issue creation failed.");
      setIssue(data);
    } catch (caughtError) {
      setError(caughtError instanceof Error ? caughtError.message : "Issue creation failed.");
    } finally {
      setIsCreatingIssue(false);
    }
  }

What does the approval handler do?

  • The guard stops the request when no validated analysis exists.
  • The loading state prevents repeated approval clicks while GitHub responds.
  • The request sends the original client details with the reviewed analysis.
  • The success state stores the returned issue URL and number.
  • In the review panel of src/app/page.tsx, find this proposed issue card:
              <article className="result-card"><h3>Proposed GitHub issue</h3><strong>{analysis.issueTitle}</strong><p className="pre-wrap">{analysis.issueBody}</p></article>

Where does approval belong?

The proposed issue card is the final review point. Placing approval directly below it connects the human decision to the exact title and body being submitted.

  • Replace that card with the following card and approval controls:
              <article className="result-card"><h3>Proposed GitHub issue</h3><strong>{analysis.issueTitle}</strong><p className="pre-wrap">{analysis.issueBody}</p></article>
              <button className="primary-button" disabled={isCreatingIssue || Boolean(issue)} onClick={handleApprove} type="button">{isCreatingIssue ? "Creating issue..." : issue ? `Issue #${issue.number} created` : "Approve and create issue"}</button>
              {issue && <a className="success-link" href={issue.url} target="_blank" rel="noreferrer">Open GitHub issue #{issue.number}</a>}

How does the approval state appear?

  • The button starts the issue request only after a reviewer selects it.
  • The disabled state blocks duplicate issue creation during the request.
  • The success link uses the URL returned by GitHub.
  • Save src/app/page.tsx.

Approval controls showing an error?

Confirm that handleApprove appears inside Home() before the component's return statement.

Check that the button uses onClick={handleApprove}. Confirm the success link remains inside the block that renders when analysis exists.

Help me debug the approval controls in my React component.

✔️ I've connected approval

Great. Save src/app/page.tsx before running the production build.

ⓧ I'd like to double check the full code

"use client";

import { useState } from "react";
import type { ClientRequest, LeadAnalysis } from "@/lib/types";

const initialRequest: ClientRequest = { name: "", email: "", message: "" };

export default function Home() {
  const [request, setRequest] = useState<ClientRequest>(initialRequest);
  const [analysis, setAnalysis] = useState<LeadAnalysis | null>(null);
  const [isAnalyzing, setIsAnalyzing] = useState(false);
  const [isCreatingIssue, setIsCreatingIssue] = useState(false);
  const [error, setError] = useState("");
  const [issue, setIssue] = useState<{ url: string; number: number } | null>(null);

  async function handleAnalyze() {
    setIsAnalyzing(true);
    setError("");
    setAnalysis(null);
    setIssue(null);
    try {
      const response = await fetch("/api/analyze", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(request)
      });
      const data = await response.json();
      if (!response.ok) throw new Error(data.error ?? "Analysis failed.");
      setAnalysis(data.analysis);
    } catch (caughtError) {
      setError(caughtError instanceof Error ? caughtError.message : "Analysis failed.");
    } finally {
      setIsAnalyzing(false);
    }
  }

  async function handleApprove() {
    if (!analysis) return;
    setIsCreatingIssue(true);
    setError("");
    try {
      const response = await fetch("/api/issues", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ requester: request, analysis })
      });
      const data = await response.json();
      if (!response.ok) throw new Error(data.error ?? "Issue creation failed.");
      setIssue(data);
    } catch (caughtError) {
      setError(caughtError instanceof Error ? caughtError.message : "Issue creation failed.");
    } finally {
      setIsCreatingIssue(false);
    }
  }

  return (
    <main className="page-shell">
      <header className="hero">
        <p className="eyebrow">Full-stack AI workflow</p>
        <h1>AI Client Request Router</h1>
        <p>Turn an unstructured message into a reviewed GitHub ticket.</p>
      </header>

      <section className="workspace">
        <form
          className="panel form-panel"
          onSubmit={(event) => {
            event.preventDefault();
            void handleAnalyze();
          }}
        >
          <p className="step-label">1. Intake</p>
          <h2>Client request</h2>
          <label>Client name<input required value={request.name} onChange={(event) => setRequest({ ...request, name: event.target.value })} placeholder="Jordan Lee" /></label>
          <label>Client email<input required type="email" value={request.email} onChange={(event) => setRequest({ ...request, email: event.target.value })} placeholder="jordan@example.com" /></label>
          <label>Request<textarea required rows={8} value={request.message} onChange={(event) => setRequest({ ...request, message: event.target.value })} placeholder="Describe a synthetic request." /></label>
          <button className="primary-button" disabled={isAnalyzing} type="submit">{isAnalyzing ? "Analyzing request..." : "Analyze request"}</button>
          <p className="privacy-note">Use synthetic requests only.</p>
        </form>

        <section className="panel result-panel" aria-live="polite">
          <p className="step-label">2. Review</p>
          <h2>AI proposal</h2>
          {!analysis && !isAnalyzing && <div className="empty-state">Submit a request to generate a structured review.</div>}
          {isAnalyzing && <div className="empty-state">Gemini is structuring the request.</div>}
          {analysis && (
            <div className="analysis-stack">
              <div className="badge-row"><span className="badge">{analysis.requestType}</span><span className={`badge priority-${analysis.priority}`}>{analysis.priority} priority</span></div>
              <article className="result-card"><h3>Summary</h3><p>{analysis.summary}</p></article>
              <article className="result-card"><h3>Recommended action</h3><p>{analysis.recommendedAction}</p></article>
              <article className="result-card"><h3>Draft client reply</h3><p>{analysis.draftReply}</p></article>
              <article className="result-card"><h3>Proposed GitHub issue</h3><strong>{analysis.issueTitle}</strong><p className="pre-wrap">{analysis.issueBody}</p></article>
              <button className="primary-button" disabled={isCreatingIssue || Boolean(issue)} onClick={handleApprove} type="button">{isCreatingIssue ? "Creating issue..." : issue ? `Issue #${issue.number} created` : "Approve and create issue"}</button>
              {issue && <a className="success-link" href={issue.url} target="_blank" rel="noreferrer">Open GitHub issue #{issue.number}</a>}
            </div>
          )}
          {error && <p className="error-message">{error}</p>}
        </section>
      </section>
    </main>
  );
}
  • Return to the terminal running the development server.
  • Stop the server by pressing Ctrl+C.

Before you run the production check, do you expect the new route and approval handler to pass strict TypeScript validation?

  • Build the completed app by running:
npm run build

You should see the production build complete successfully. This confirms that the issue route and client component compile together.

Build not completing?

Check the first file path named in the build output. Compare that file with the full-code tabs above.

If the error points to src/app/page.tsx, confirm that handleApprove() closes before the component's return statement.

Help me diagnose my Next.js production build error.

  • Restart the development server by running:
npm run dev

The server reloads the values from .env.local. Keep this terminal running while you test the approval workflow.

  • Return to the browser tab from earlier.
  • Refresh http://localhost:3000.
  • Enter Jordan Lee in the client name field.
  • Enter jordan@example.com in the client email field.
  • Enter Our checkout fails after card payment and customers cannot finish orders in the request field.
  • Select Analyze request.

You should see all seven structured fields in the review panel. The proposed GitHub issue appears above the approval button.

The next click is the only action in this workflow allowed to change the repository. Before selecting it, do you expect GitHub to contain the issue before approval or only after approval?

  • Select Approve and create issue.

The button changes while the request runs. You should then see the created issue number and a link to open it.

  • Select the Open GitHub issue link.

GitHub opens the new issue. You should see the AI-generated title and body plus the synthetic requester details.

Issue not appearing in GitHub?

Confirm that GITHUB_REPOSITORY contains exactly one owner name and one repository name separated by a slash. Restart the development server after changing .env.local.

Confirm that the fine-grained token can access the configured repository. Check that Issues has read and write permission.

Help me debug why my approved GitHub issue was not created.

That completes the approval boundary. Your app can propose a ticket with Gemini, wait for a reviewer, and create one real GitHub issue without exposing either credential to the browser.

Secret mission

Make the AI Draft Editable

Your approval gate already prevents Gemini from creating issues automatically. Extend that gate so the reviewer can edit the proposed title and body before the approved draft reaches GitHub.

Clean Up Your Resources

Clean Up Your Resources

Your Next.js app runs locally. This project's Gemini API use stays within the free tier, so there is no separate infrastructure charge.

Choose whether to keep the workflow available, pause its development server, or remove every project resource.

Resources you used:

  • The running Next.js development server.
  • The ai-client-request-router directory, including .env.local and the installed project dependencies.
  • The fine-grained GitHub personal access token with Issues access.
  • The test repository identified by GITHUB_REPOSITORY, including at least one approved test issue.

Keep everything running

No action needed. Choose this if you plan to keep testing the request router or improving its approval workflow.

  • Keep the development server active while you continue testing the app.
  • Retain the ai-client-request-router directory so the source code and local configuration remain available.
  • Leave the fine-grained token active only while the app still needs permission to create issues.
  • Keep .env.local outside source control so neither credential is committed.

Pause - I'll come back to this later

Shut down the development server to free its local process. Your project files, test repository, issue, and token remain available for your return.

  • Return to the terminal panel that is running the development server.
  • Stop the server by pressing Ctrl+C.

You'll see the terminal prompt return. The app at http://localhost:3000 stops responding until you restart it.

  • Restart the development server when you return by running:
npm run dev

What does this command do?

The dev script starts the Next.js development server. You'll see the local address for the app in the terminal.

Server Won't Restart?

Confirm that the terminal is inside the ai-client-request-router directory before you retry.

Help me restart the development server.

Delete - I don't want to use this again

Deletion is permanent. Your local files and GitHub test resources can only be restored from a backup after this cleanup.

A fine-grained token can create issues while it remains active. Revoking it removes that access without deleting your GitHub account.

  • Return to GitHub.
  • Open Settings from your profile menu.
  • Select Developer settings.
  • Select Personal access tokens.
  • Select Fine-grained tokens.
  • Open the token used by the request router.
  • Revoke the token with its revocation control.

That access path is now closed. The token value stored in .env.local disappears when you delete the project directory below.

Close the test issues to record that the experiment has finished. This updates their status before you remove the repository.

  • Return to the test repository identified by GITHUB_REPOSITORY.
  • Select Issues in the repository navigation.
  • Open each issue created by the request router.
  • Close each test issue with the issue page's close control.

The test issues now show a closed state. This confirms the app's trial work is no longer active.

Repository deletion removes every test issue in one step. Your GitHub account and unrelated repositories stay untouched.

  • Open Settings in the test repository.
  • Scroll to the repository deletion controls.
  • Choose the option that permanently deletes the repository.
  • Enter the owner/repository value stored under GITHUB_REPOSITORY when GitHub requests confirmation.
  • Confirm the repository deletion.

The repository and its test issues are now removed from GitHub.

The final cleanup removes the source code and .env.local together. A backup is the only recovery path after these commands.

macOS

  • Return to the terminal panel that ran the development server.
  • Stop the server by pressing Ctrl+C.
  • Complete the local cleanup by running these commands:
cd ..
rm -rf ai-client-request-router
ls
What Do These Commands Do?
  • The cd command moves the terminal outside the directory being removed.
  • The rm command permanently removes the project directory and its local environment file.
  • The ls command lists the remaining directories. Its output should no longer include ai-client-request-router.
Still See the Project Directory?

Confirm that the development server stopped before retrying the cleanup. Make sure you started from the terminal that was inside ai-client-request-router.

Help me remove the local project safely.

Windows

  • Return to the terminal panel that ran the development server.
  • Stop the server by pressing Ctrl+C.
  • Complete the local cleanup by running these commands:
cd ..
cmd.exe /c "rmdir /s /q ai-client-request-router"
dir
What Do These Commands Do?
  • The cd command moves the terminal outside the directory being removed.
  • The cmd.exe command runs the Windows directory removal command.
  • The dir command lists the remaining directories. Its output should no longer include ai-client-request-router.
Still See the Project Directory?

Confirm that the development server stopped before retrying the cleanup. Close any process that is still using a file inside ai-client-request-router.

Help me remove the local project safely.

Nice Work!

Nice Work!

You did it! You built a full-stack AI request router that turns an unstructured client message into a reviewed GitHub issue.

You've learned how to:

  • Built a controlled React form for synthetic client requests. A polished review workspace makes every proposal easy to inspect.
  • Connected Gemini to a server-only Next.js Route Handler. Zod validates every response against the seven-field application schema before it reaches the browser.
  • Kept Gemini and GitHub credentials on the server. A human approval gate creates a real GitHub issue only after review.
  • Completed the Secret Mission by separating the generated analysis from the reviewer-edited draft. The approved GitHub issue uses the edited title and body.

Ready to quiz yourself?