Build a Gemini Ticket Triage Lab
Build a Gemini ticket triage app with structured output, evals, and guardrails.
Introduction
30 Second Summary
A busy support inbox mixes urgent account lockouts with duplicate charges and feature requests. Someone still has to read each message before deciding what needs attention first.
In this project, you will build a browser-based support ticket triage app powered by the Gemini Developer API. You will replace its brittle free-form response with Structured Outputs backed by JSON Schema.
What You'll Build
Your finished app turns a pasted synthetic customer ticket into four result cards that a support teammate can scan in seconds.
By the end of this project, you'll have:
- A working triage app that converts a synthetic ticket into a category, priority, summary, and suggested reply. The Express server keeps the Gemini API key away from browser code.
- A side-by-side reliability demo where a readable AI paragraph leaves the named result fields unavailable. Structured output then fills every card predictably.
- A repeatable evaluation suite that runs four labeled tickets from the terminal. You can see each category and priority check pass or fail before a final score.
- Secret Mission: Add a deterministic human-review rule that flags every critical ticket before a reply can be sent.
Do I need a paid AI account?
No paid AI account is required. Use only the included synthetic tickets because free-tier prompts and outputs may be used to improve Google's products.
Before We Start
Before the hands-on work begins, this is your moment to commit to building a reliable AI support-ticket workflow. You will connect that goal to the support teammates who depend on consistent ticket fields.
Prepare the Windows AI Workspace
Your support workflow needs a browser-facing app backed by an Express server. The server keeps the Gemini Developer API key away from browser code.
Pinned versions keep setup drift from hiding the reliability lesson. In this step, you will verify Node.js v24.22.0 LTS plus Visual Studio Code before storing your key locally.
In this step, get ready to:
- Verify the pinned Windows toolchain.
- Store the Gemini API key outside source control.
- Install the exact project dependencies.
Verify the Windows toolchain
Windows PowerShell gives you one place to check the tools that run this project. The version results separate a missing tool from an outdated runtime.
- Press the Windows key to open the search bar.
- Enter PowerShell in the search bar.
- Press Enter to open Windows PowerShell.
Before you run these checks, do you expect both tools to report the required versions?
- Check the installed Node.js version plus the Visual Studio Code command-line launcher by running these commands:
node --version
code --version
What do these commands check?
- The node --version command prints the installed Node.js version.
- The code --version command prints the installed Visual Studio Code version details.
✔️ I see Node.js v24.22.0
Your pinned runtime is ready. The Visual Studio Code command-line launcher is also available when its version details appear.
ⓧ I see an older Node.js version
The installed runtime cannot guarantee the dependency behavior used in this project. Upgrade Node.js before creating the workspace.
- Visit the official Node.js download page.
- Download Node.js v24.22.0 LTS for Windows.
- Run the downloaded Node.js installer.
- Restart Windows PowerShell after the installation finishes.
- Repeat the version checks by running these commands:
node --version
code --version
What should I see?
Node.js should print v24.22.0. Visual Studio Code should print its installed version details.
ⓧ A command is missing
A missing command means its tool is unavailable from this PowerShell session. Install each missing tool before continuing.
- Install Node.js v24.22.0 LTS from the official Node.js download page if the Node.js command was missing.
- Install the Visual Studio Code Windows User setup from the official Windows setup guide if the Visual Studio Code command was missing.
- Restart Windows PowerShell after completing the installation.
- Repeat the version checks by running these commands:
node --version
code --version
What should I see?
Node.js should print v24.22.0. Visual Studio Code should print its installed version details.
Still missing a command?
Confirm that you restarted PowerShell after the installation. An older terminal session may not know about the newly installed command.
If Visual Studio Code remains unavailable, repeat its Windows User setup. Help me troubleshoot a missing Windows development command.
Create the workspace and protect the API key
A named Desktop folder gives every project file one predictable location. PowerShell can create that folder before Visual Studio Code takes over.
- Create the ai-ticket-triage-lab folder on your Desktop by running these commands:
cd ~/Desktop
mkdir ai-ticket-triage-lab
cd ai-ticket-triage-lab
What do these commands do?
- The first command moves PowerShell to your Desktop.
- The second command creates the ai-ticket-triage-lab workspace folder.
- The final command moves PowerShell into the new workspace.
Your PowerShell prompt now ends inside the ai-ticket-triage-lab folder.
Workspace folder not created?
Confirm that your first command moved PowerShell to the Desktop. Check the folder name for spelling differences before repeating the commands.
If PowerShell reports that the folder already exists, enter that existing folder. Help me find my project folder in PowerShell.
- Open the current workspace in Visual Studio Code by running this command:
code .
What does this command do?
The code . command opens the current PowerShell folder in Visual Studio Code. The dot represents the folder you are currently inside.
That first workspace milestone is complete. Visual Studio Code now shows the empty ai-ticket-triage-lab folder in the Explorer sidebar.
Visual Studio Code not opening?
Confirm that code --version succeeds in the same PowerShell window. Restart PowerShell if Visual Studio Code was installed while that window was open.
Check that the prompt is inside ai-ticket-triage-lab before repeating the command. Help me open this workspace in Visual Studio Code.
A key from Google AI Studio authorizes server-side Gemini requests. You can handle this credential safely because the real value stays in a local .env file that source control ignores.
The Free Tier may use submitted content to improve Google's products. Keep every ticket in this lab synthetic.
- Visit the official Google AI Studio API Keys page in your browser.
- Sign in with your Google account if prompted.
- Click Create API key.
- Copy the generated key to your clipboard.
Source control protection comes first. The .gitignore file excludes downloaded packages plus the local credential file.
- Select the Explorer sidebar in Visual Studio Code.
- Click the New File icon in the Explorer toolbar.
- Enter .gitignore as the file name.
- Add the source control exclusions to .gitignore by pasting this configuration:
node_modules/
.env
What does this configuration do?
- The node_modules/ rule keeps downloaded dependency files out of source control.
- The .env rule keeps your local Gemini API key out of source control.
- Save .gitignore by pressing Ctrl+S.
- Confirm .gitignore appears inside ai-ticket-triage-lab in the Explorer sidebar.
Ignore rules not saved?
Check that the file is named exactly .gitignore with no extra file extension. Confirm that each rule appears on its own line.
Make sure the file sits directly inside ai-ticket-triage-lab. Help me verify my ignore file.
The shareable environment example documents the required variable name without containing a live secret. Teammates can copy this template when they set up the project.
- Click the New File icon in the Explorer toolbar.
- Enter .env.example as the file name.
- Add the safe key placeholder to .env.example by pasting this configuration:
GEMINI_API_KEY=your-api-key-here
What does this configuration do?
The GEMINI_API_KEY name matches the environment variable used by Gemini. The placeholder makes this file safe to share.
- Save .env.example by pressing Ctrl+S.
- Confirm .env.example appears in the Explorer sidebar.
Your real .env file is the only project file that receives the credential. Keep this file closed whenever you capture or share your screen.
- Click the New File icon in the Explorer toolbar.
- Enter .env as the file name.
- Add the local environment entry to .env by pasting this starter configuration:
GEMINI_API_KEY=your-api-key-here
What does this configuration do?
Node.js loads the value assigned to GEMINI_API_KEY into the server process. The application can use the credential without sending it to browser code.
- Replace your-api-key-here with the key you copied from Google AI Studio.
- Save .env by pressing Ctrl+S.
- Close the .env editor tab to hide the credential.
- Confirm .env appears in the Explorer sidebar.
Key file not ready?
Confirm that the variable name is exactly GEMINI_API_KEY. Keep the copied key on the same line after the equals sign.
Never paste the real key into a help request. Help me check the format without exposing my key.
Pin and install the project dependencies
The package manifest records the runtime plus every dependency version used in the lab. This gives every future install the same starting point.
- Click the New File icon in the Explorer toolbar.
- Enter package.json as the file name.
- Add the pinned project manifest to package.json by pasting this configuration:
{
"name": "ai-ticket-triage-lab",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node --env-file=.env server.js",
"eval": "node --env-file=.env evals.js"
},
"engines": {
"node": "24.22.0"
},
"dependencies": {
"@google/genai": "2.30.0",
"express": "5.3.0"
}
}
What does this manifest configure?
- The project metadata identifies the workspace as ai-ticket-triage-lab version 1.0.0.
- The private setting prevents accidental publication as an npm package.
- The type setting enables ES module syntax in JavaScript files.
- The scripts load .env before running the application or evaluation suite.
- The engine field pins Node.js to 24.22.0.
- The dependencies pin the Google Gen AI SDK to 2.30.0 plus Express to 5.3.0.
- Save package.json by pressing Ctrl+S.
- Confirm package.json appears inside ai-ticket-triage-lab in the Explorer sidebar.
Manifest showing a syntax problem?
Check every double quote plus comma against the reference below. JSON does not allow a comma after the final dependency.
Confirm that the file is named exactly package.json. Help me find the JSON syntax issue.
✔️ Awesome, I've got everything!
Great. Confirm every project file is saved.
Keep .env closed before taking screenshots or sharing your workspace.
ⓧ I'd like to double check the full code
Your local .env contains GEMINI_API_KEY= followed by the key you copied from Google AI Studio. The real key must remain uncommitted.
GEMINI_API_KEY=your-api-key-here
node_modules/
.env
{
"name": "ai-ticket-triage-lab",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node --env-file=.env server.js",
"eval": "node --env-file=.env evals.js"
},
"engines": {
"node": "24.22.0"
},
"dependencies": {
"@google/genai": "2.30.0",
"express": "5.3.0"
}
}
The manifest declares the dependencies. The install command downloads those packages into the workspace.
- Install the pinned project dependencies in the integrated terminal by running this command:
npm install
What does this command do?
The npm install command reads the dependencies from package.json. It downloads the pinned packages into node_modules.
- Wait for PowerShell to return to its prompt.
- Confirm node_modules appears in the Visual Studio Code Explorer sidebar.
The dependency installation is complete when PowerShell returns without a package resolution error.
Dependency installation not completing?
Confirm that the integrated terminal is inside ai-ticket-triage-lab. Check that package.json is saved before repeating the command.
A network restriction can also stop package downloads. Help me troubleshoot this npm installation.
Before the final check, do you expect PowerShell to confirm the pinned Node.js runtime plus the Visual Studio Code installation?
- Verify the finished Windows toolchain by running these commands:
node --version
code --version
What does this final check prove?
The first result confirms that the project uses the pinned Node.js runtime. The second result confirms that the Visual Studio Code command-line launcher remains available.
Node.js prints v24.22.0. Visual Studio Code prints its installed version details.
That is the setup complete. Your API key is protected locally plus both pinned dependencies are installed.
Your Windows AI workspace is ready. Next, you will turn these project files into a visible ticket-triage shell.
Build the Triage Shell
Your pinned workspace is ready for its first visible result. You now have a safe place to connect a browser interface to server-side code.
A visible mock proves the browser-to-Express path before an external model adds latency or unpredictable output. You will send JSON through a local API and render the response in four cards.
In this step, get ready to:
- Build an Express server with health and mock-analysis routes.
- Create a responsive ticket-triage interface.
- Trace one mock request from the browser to Express and back.
Create the mock Express server
The server is the boundary between browser code and future model calls. Its temporary mock response gives the interface a stable contract to render today.
- Switch back to the file list in Visual Studio Code from earlier.
- Select the ai-ticket-triage-lab folder.
- Click the new-file icon at the top of the file list.
- Enter server.js as the file name.
- Add the server foundation to server.js by pasting this code:
import express from "express";
const app = express();
const PORT = 3000;
app.use(express.json());
app.use(express.static("public"));
app.get("/api/health", (req, res) => {
res.json({ ok: true });
});
function readTicket(req) {
const ticket = req.body?.ticket;
if (typeof ticket !== "string" || ticket.trim().length < 10) {
return null;
}
return ticket.trim();
}
What does this server foundation do?
- The JSON middleware makes a submitted ticket available through req.body.
- The static middleware serves browser files from public.
- The health route returns a small success response for a quick server check.
- The readTicket() helper rejects missing or very short ticket text.
- Add the mock analysis route below readTicket() by pasting this code:
app.post("/api/analyze", (req, res) => {
const ticket = readTicket(req);
if (!ticket) {
return res.status(400).json({ error: "Enter a ticket with at least 10 characters." });
}
return res.json({
category: "billing",
priority: "high",
summary: "The customer reports a duplicate subscription charge affecting a budget review.",
suggested_reply: "Thanks for reporting the duplicate charge. We will investigate and let you know what information is needed."
});
});
app.listen(PORT, (error) => {
if (error) {
throw error;
}
console.log(`Ticket Triage Lab running at http://localhost:${PORT}`);
});
How does the mock route work?
- The POST /api/analyze route passes the request through the same validation helper.
- A valid ticket receives fixed values for category and priority.
- The mock summary and reply make every test repeatable.
- The listener starts the app on port 3000.
- Save server.js.
- Switch back to the integrated PowerShell terminal from earlier.
- Start the local server by running this command:
npm start
What does this command do?
The start script loads your local .env into Node.js. It then runs server.js while keeping the terminal attached to the server process.
You will see the local address printed in PowerShell. The running process is now ready to accept browser requests.
Before you check the health route, predict what the smallest successful response will contain.
- Click your browser icon on the Windows taskbar.
- Enter http://localhost:3000/api/health in the address bar.
- Press Enter.
You will see {"ok":true}. That response proves Express is listening and the health route is reachable.
Server not starting?
Check that PowerShell is still inside the ai-ticket-triage-lab workspace. Confirm that server.js is beside package.json.
A port error can mean another local process is already using port 3000. Help me diagnose why this Express server does not start on port 3000.
✔️ My health route works
Your server foundation is working. Keep npm start running while you build the browser interface.
ⓧ I'd like to double check the server
import express from "express";
const app = express();
const PORT = 3000;
app.use(express.json());
app.use(express.static("public"));
app.get("/api/health", (req, res) => {
res.json({ ok: true });
});
function readTicket(req) {
const ticket = req.body?.ticket;
if (typeof ticket !== "string" || ticket.trim().length < 10) {
return null;
}
return ticket.trim();
}
app.post("/api/analyze", (req, res) => {
const ticket = readTicket(req);
if (!ticket) {
return res.status(400).json({ error: "Enter a ticket with at least 10 characters." });
}
return res.json({
category: "billing",
priority: "high",
summary: "The customer reports a duplicate subscription charge affecting a budget review.",
suggested_reply: "Thanks for reporting the duplicate charge. We will investigate and let you know what information is needed."
});
});
app.listen(PORT, (error) => {
if (error) {
throw error;
}
console.log(`Ticket Triage Lab running at http://localhost:${PORT}`);
});
Build the browser interface
The browser needs a form for the ticket and named elements for each result field. Those element IDs become the contract between the page structure and the browser module.
- Select the ai-ticket-triage-lab folder in the Visual Studio Code file list.
- Click the new-folder icon at the top of the file list.
- Enter public as the folder name.
- Select the new public folder.
- Click the new-file icon at the top of the file list.
- Enter index.html as the file name.
- Add the form shell to index.html by pasting this code:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Ticket Triage Lab</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<main class="shell">
<section class="panel">
<form id="ticket-form">
<label for="ticket">Synthetic support ticket</label>
<textarea id="ticket" rows="8" required></textarea>
<div class="actions">
<button id="sample-button" type="button" class="secondary">Load sample</button>
<button type="submit">Run structured AI</button>
</div>
</form>
<p id="status" class="status" aria-live="polite"></p>
</section>
<p class="privacy">Use synthetic tickets only. Free-tier content may be used to improve Google's products.</p>
</main>
<script type="module" src="/app.js"></script>
</body>
</html>
What does this page foundation do?
- The form collects a synthetic support ticket in #ticket.
- The sample button gives the learner a repeatable ticket to test.
- The submit button starts the analysis flow.
- The status paragraph announces loading and error states without replacing the form.
- Select the public folder again.
- Click the new-file icon at the top of the file list.
- Enter styles.css as the file name.
- Add the page foundation to styles.css by pasting this code:
:root {
color-scheme: dark;
font-family: Inter, system-ui, sans-serif;
background: #07111f;
color: #e8eef7;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(circle at top, #17345c, #07111f 55%);
}
.shell {
width: min(960px, 92vw);
margin: 0 auto;
padding: 48px 0;
}
header {
margin-bottom: 24px;
}
What does this styling establish?
- The root rules establish the page colors and font stack.
- The universal selector keeps element dimensions predictable.
- The body gradient creates the dark background.
- The shell limits the content width while leaving room on smaller screens.
- Save index.html.
- Save styles.css.
- Return to your browser tab.
- Enter http://localhost:3000 in the address bar.
- Press Enter.
You will see the synthetic-ticket form on a dark background. Express is now serving files from the public folder.
Page not loading?
Confirm that index.html is inside public. Check that PowerShell still shows the running server process.
A blank unstyled page can mean styles.css was saved outside public. Help me find why Express is not serving my public files.
The remaining page regions separate readable raw output from application-ready fields. Keeping both regions in the shell prepares the interface for a direct comparison later.
- In index.html place this header directly below <main class="shell">:
<header>
<p class="eyebrow">Portfolio AI reliability demo</p>
<h1>AI Support Ticket Triage Lab</h1>
<p>Compare a readable AI paragraph with a schema-constrained application response.</p>
</header>
What does the header communicate?
The heading names the project while the supporting line states its reliability goal. This gives the interface a clear demo story before a ticket is submitted.
- In index.html place these output regions below the form panel:
<section id="raw-panel" class="panel" hidden>
<h2>Free-form result</h2>
<p>This is readable, but the frontend cannot depend on named fields.</p>
<pre id="raw-output"></pre>
</section>
<section id="cards" class="results" hidden>
<article class="card">
<h2>Category</h2>
<p id="category"></p>
</article>
<article class="card">
<h2>Priority</h2>
<p id="priority"></p>
</article>
<article class="card wide">
<h2>Summary</h2>
<p id="summary"></p>
</article>
<article class="card wide">
<h2>Suggested reply</h2>
<p id="suggested-reply"></p>
</article>
</section>
How are the results organized?
- The raw panel has its own output element for readable model text.
- The result region contains one card for each named field.
- The hidden attributes keep empty output regions out of view.
- The browser module can target each card through its element ID.
- Save index.html.
- Refresh http://localhost:3000 in your browser.
You will see the project heading above the ticket form. The empty output regions stay hidden until the browser receives data.
- Add the heading and panel styles below the existing rules in styles.css by pasting this code:
h1 {
margin: 6px 0 10px;
font-size: clamp(2rem, 6vw, 4rem);
}
.eyebrow {
color: #7dd3fc;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.panel,
.card {
border: 1px solid #28476d;
border-radius: 18px;
background: rgba(10, 25, 45, 0.9);
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.25);
}
.panel {
padding: 22px;
margin-bottom: 20px;
}
What changes in the layout?
- The title scales with the available screen width.
- The eyebrow distinguishes the project as a reliability demo.
- Panels and cards share the same border and background treatment.
- Panel padding gives the form room to breathe.
- Save styles.css.
- Refresh the browser page.
You will see a larger title and a bordered form panel. The page now has a clear visual hierarchy.
Panel still looks plain?
Confirm that the new rules sit below the closing brace for header. Check that every selector has a matching closing brace.
Refresh the browser after saving the file. Help me find why my panel and heading styles are not applying.
- Add the form-control styles below the panel rules by pasting this code:
label {
display: block;
margin-bottom: 8px;
font-weight: 700;
}
textarea {
width: 100%;
resize: vertical;
border: 1px solid #3d628e;
border-radius: 12px;
padding: 14px;
background: #06101d;
color: inherit;
font: inherit;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 14px;
}
What changes in the form?
- The label sits on its own line above the ticket field.
- The text area fills the panel width.
- The action container can wrap its controls on narrow screens.
- Save styles.css.
- Refresh the browser page.
You will see a full-width ticket field with a dark input surface. The action controls now have consistent spacing.
Ticket field overflowing?
Confirm that the universal box-sizing rule remains near the top of styles.css. Check that the text-area width is 100%.
Help me diagnose why the ticket text area extends outside its panel.
- Add the button and status styles below the action rules by pasting this code:
button {
border: 0;
border-radius: 999px;
padding: 11px 17px;
background: #38bdf8;
color: #03111f;
font-weight: 800;
cursor: pointer;
}
button.secondary {
background: #dbeafe;
}
button:disabled {
cursor: wait;
opacity: 0.55;
}
.status {
min-height: 1.5em;
margin-bottom: 0;
color: #bae6fd;
}
What do these controls communicate?
- The rounded buttons separate primary and secondary actions through color.
- The disabled state reduces opacity while a request is running.
- The status area keeps enough height to prevent the panel from jumping when text appears.
- Save styles.css.
- Refresh the browser page.
You will see two rounded controls below the ticket field. A reserved status line remains beneath the form.
Buttons not changing?
Check that button.secondary matches the secondary class on the sample button. Confirm that the rules were added outside the previous selector.
Help me find why the button styles in public/styles.css are not applying.
- Add the privacy and mobile rules below the status styles by pasting this code:
.privacy {
margin-top: 24px;
color: #a7b5c8;
font-size: 0.9rem;
}
@media (max-width: 640px) {
.results {
grid-template-columns: 1fr;
}
.card.wide {
grid-column: auto;
}
}
How does the page adapt?
- The privacy note uses quieter text below the main interface.
- The media rule turns the future result grid into one column on small screens.
- Wide cards return to the normal column flow in the mobile layout.
- Save styles.css.
- Refresh the browser page.
- Narrow the browser window to roughly phone width.
The form remains inside the viewport while the action controls wrap when needed. The privacy note stays readable beneath the panel.
✔️ My page structure is ready
Your form and hidden output regions are ready for browser behavior. Double-check that index.html is saved.
ⓧ I'd like to double check the HTML
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Ticket Triage Lab</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<main class="shell">
<header>
<p class="eyebrow">Portfolio AI reliability demo</p>
<h1>AI Support Ticket Triage Lab</h1>
<p>Compare a readable AI paragraph with a schema-constrained application response.</p>
</header>
<section class="panel">
<form id="ticket-form">
<label for="ticket">Synthetic support ticket</label>
<textarea id="ticket" rows="8" required></textarea>
<div class="actions">
<button id="sample-button" type="button" class="secondary">Load sample</button>
<button type="submit">Run structured AI</button>
</div>
</form>
<p id="status" class="status" aria-live="polite"></p>
</section>
<section id="raw-panel" class="panel" hidden>
<h2>Free-form result</h2>
<p>This is readable, but the frontend cannot depend on named fields.</p>
<pre id="raw-output"></pre>
</section>
<section id="cards" class="results" hidden>
<article class="card">
<h2>Category</h2>
<p id="category"></p>
</article>
<article class="card">
<h2>Priority</h2>
<p id="priority"></p>
</article>
<article class="card wide">
<h2>Summary</h2>
<p id="summary"></p>
</article>
<article class="card wide">
<h2>Suggested reply</h2>
<p id="suggested-reply"></p>
</article>
</section>
<p class="privacy">Use synthetic tickets only. Free-tier content may be used to improve Google's products.</p>
</main>
<script type="module" src="/app.js"></script>
</body>
</html>
Connect the request to the result cards
The page structure can now display a result, but it needs browser logic to move data through the full path. The module will load a sample ticket and send it to the mock route.
- Select the public folder in the Visual Studio Code file list.
- Click the new-file icon at the top of the file list.
- Enter app.js as the file name.
- Add the page references and event handlers to app.js by pasting this code:
const form = document.querySelector("#ticket-form");
const ticket = document.querySelector("#ticket");
const sampleButton = document.querySelector("#sample-button");
const status = document.querySelector("#status");
const rawPanel = document.querySelector("#raw-panel");
const rawOutput = document.querySelector("#raw-output");
const cards = document.querySelector("#cards");
const sampleTicket = `Hi support, our company card was charged twice for the same monthly subscription. The duplicate charge is affecting this week's budget review. Please investigate and tell me what information you need.`;
sampleButton.addEventListener("click", () => {
ticket.value = sampleTicket;
ticket.focus();
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
await runAnalysis();
});
What do these handlers do?
- The constants keep references to the form and output elements.
- The sample handler places the same synthetic billing ticket into the text area every time.
- The submit handler stops the browser from reloading the page.
- The form then hands control to runAnalysis().
- Add the rendering and busy-state helpers below the event handlers by pasting this code:
function renderStructured(data) {
rawPanel.hidden = true;
cards.hidden = false;
document.querySelector("#category").textContent = data.category;
document.querySelector("#priority").textContent = data.priority;
document.querySelector("#summary").textContent = data.summary;
document.querySelector("#suggested-reply").textContent = data.suggested_reply;
status.textContent = "Mock response received and rendered.";
}
function setBusy(isBusy, message = "") {
status.textContent = message;
sampleButton.disabled = isBusy;
form.querySelector("button[type='submit']").disabled = isBusy;
}
How do the helpers control the page?
- The render helper reveals the result grid.
- Each response field is placed into its matching card.
- The busy helper updates the status line.
- The busy helper also disables both controls during a request.
- Save app.js.
- Refresh the browser page.
- Click Load sample.
You will see the synthetic duplicate-charge ticket fill the text area. The sample control is now connected to the browser module.
Sample ticket not loading?
Confirm that app.js is inside public. Check that the script reference in index.html uses /app.js.
Check that #sample-button matches the button ID in the HTML. Help me troubleshoot why the sample ticket button does not fill the text area.
- Add the result-card styles below the existing rules in styles.css by pasting this code:
.results {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.card {
padding: 20px;
}
.card h2 {
margin-top: 0;
color: #7dd3fc;
font-size: 1rem;
}
.card.wide {
grid-column: 1 / -1;
}
pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
color: #fde68a;
}
How will the results appear?
- The result region uses two equal columns on wider screens.
- Summary and suggested-reply cards span both columns.
- Card headings reuse the page accent color.
- Long raw text can wrap without overflowing its panel.
- Add the request function below the event handlers in app.js by pasting this code:
async function runAnalysis() {
const value = ticket.value.trim();
if (value.length < 10) {
status.textContent = "Enter a longer synthetic ticket first.";
return;
}
setBusy(true, "Requesting mock output...");
try {
const response = await fetch("/api/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ticket: value })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "The request failed.");
}
renderStructured(data);
} catch (error) {
status.textContent = error.message;
} finally {
setBusy(false);
}
}
How does the request travel?
- The function rejects ticket text shorter than ten characters.
- The browser sends the ticket to /api/analyze as JSON.
- The response body is converted back into a JavaScript object.
- Successful data reaches the render helper while errors appear in the status line.
- Save styles.css.
- Save app.js.
- Refresh the browser page.
- Click Load sample.
Before you submit, predict which four values the deterministic route will place into the cards.
- Click Run structured AI.
You will see billing and high in the first two cards. The summary and suggested reply appear in the wide cards below them.
That is the full loop working. Your ticket now travels from the form to Express and returns as named fields the interface can render.
Cards not appearing?
Confirm that PowerShell still shows the running server process. Check that the browser status does not mention a short ticket.
Compare the card IDs in index.html with the selectors in app.js. Help me trace why my mock analysis response does not render in the four cards.
✔️ My mock triage flow works
Your browser-to-Express path is complete. Keep the local server running while you compare your client files.
ⓧ I'd like to double check the client code
const form = document.querySelector("#ticket-form");
const ticket = document.querySelector("#ticket");
const sampleButton = document.querySelector("#sample-button");
const status = document.querySelector("#status");
const rawPanel = document.querySelector("#raw-panel");
const rawOutput = document.querySelector("#raw-output");
const cards = document.querySelector("#cards");
const sampleTicket = `Hi support, our company card was charged twice for the same monthly subscription. The duplicate charge is affecting this week's budget review. Please investigate and tell me what information you need.`;
sampleButton.addEventListener("click", () => {
ticket.value = sampleTicket;
ticket.focus();
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
await runAnalysis();
});
async function runAnalysis() {
const value = ticket.value.trim();
if (value.length < 10) {
status.textContent = "Enter a longer synthetic ticket first.";
return;
}
setBusy(true, "Requesting mock output...");
try {
const response = await fetch("/api/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ticket: value })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "The request failed.");
}
renderStructured(data);
} catch (error) {
status.textContent = error.message;
} finally {
setBusy(false);
}
}
function renderStructured(data) {
rawPanel.hidden = true;
cards.hidden = false;
document.querySelector("#category").textContent = data.category;
document.querySelector("#priority").textContent = data.priority;
document.querySelector("#summary").textContent = data.summary;
document.querySelector("#suggested-reply").textContent = data.suggested_reply;
status.textContent = "Mock response received and rendered.";
}
function setBusy(isBusy, message = "") {
status.textContent = message;
sampleButton.disabled = isBusy;
form.querySelector("button[type='submit']").disabled = isBusy;
}
:root {
color-scheme: dark;
font-family: Inter, system-ui, sans-serif;
background: #07111f;
color: #e8eef7;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(circle at top, #17345c, #07111f 55%);
}
.shell {
width: min(960px, 92vw);
margin: 0 auto;
padding: 48px 0;
}
header {
margin-bottom: 24px;
}
h1 {
margin: 6px 0 10px;
font-size: clamp(2rem, 6vw, 4rem);
}
.eyebrow {
color: #7dd3fc;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.panel,
.card {
border: 1px solid #28476d;
border-radius: 18px;
background: rgba(10, 25, 45, 0.9);
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.25);
}
.panel {
padding: 22px;
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 700;
}
textarea {
width: 100%;
resize: vertical;
border: 1px solid #3d628e;
border-radius: 12px;
padding: 14px;
background: #06101d;
color: inherit;
font: inherit;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 14px;
}
button {
border: 0;
border-radius: 999px;
padding: 11px 17px;
background: #38bdf8;
color: #03111f;
font-weight: 800;
cursor: pointer;
}
button.secondary {
background: #dbeafe;
}
button:disabled {
cursor: wait;
opacity: 0.55;
}
.status {
min-height: 1.5em;
margin-bottom: 0;
color: #bae6fd;
}
.results {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.card {
padding: 20px;
}
.card h2 {
margin-top: 0;
color: #7dd3fc;
font-size: 1rem;
}
.card.wide {
grid-column: 1 / -1;
}
pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
color: #fde68a;
}
.privacy {
margin-top: 24px;
color: #a7b5c8;
font-size: 0.9rem;
}
@media (max-width: 640px) {
.results {
grid-template-columns: 1fr;
}
.card.wide {
grid-column: auto;
}
}
Your deterministic triage shell now proves the full-stack request path. Next, you will replace the mock with a readable AI response and expose why free-form text is a brittle application contract.
Expose the Free-Form Failure
Your mock request proved that the browser can send a ticket to Express and render the response. Those result cards worked because your server always returned four predictable fields.
The Gemini Developer API can return a useful paragraph without providing a dependable application contract. You will expose that gap before adding a schema in the next step.
In this step, get ready to:
- Create a server-side Gemini analyzer for free-form ticket responses.
- Expose the analyzer through a dedicated Express route.
- Watch the named result cards fail to consume an unstructured paragraph.
Create the server-side analyzer
The browser must never receive your API key. A server-side Google Gen AI SDK client reads the key from process.env.GEMINI_API_KEY only when an analysis request arrives.
- In the VS Code Explorer sidebar from earlier, click the New Folder icon.
- Enter lib as the folder name.
- Select the new lib folder.
- Click the New File icon in the Explorer toolbar.
- Enter analyzer.js as the file name.
- Add the server-only client setup and shared ticket prompt to lib/analyzer.js by pasting this code:
import { GoogleGenAI } from "@google/genai";
const MODEL = "gemini-3.8-flash";
function getClient() {
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
throw new Error("GEMINI_API_KEY is missing.");
}
return new GoogleGenAI({ apiKey });
}
function buildPrompt(ticket) {
return `You triage support tickets for a software company.
Classify the ticket, judge operational urgency, summarize the issue, and draft a safe reply.
Do not invent customer data, refunds, fixes, or completion times.
Ticket:
${ticket}`;
}
What does this code do?
- The MODEL constant keeps every request on gemini-3.8-flash.
- The getClient() function reads the API key inside the Express process. It stops the request if the key is unavailable.
- The buildPrompt() function gives the model a support-triage role. It also tells the model to avoid invented customer details.
- Save lib/analyzer.js.
- Confirm analyzer.js appears inside the lib folder in the Explorer sidebar.
Can't find the analyzer file?
Check that analyzer.js sits inside ai-ticket-triage-lab/lib. The lib folder should be beside public.
Make sure the file ends with .js. Help me check the location of lib/analyzer.js in my Express project.
The naive analyzer requests one helpful paragraph. Its return value has one raw property because the model has not been given a schema.
- Add the naive analyzer and temporary structured placeholder below buildPrompt() by pasting this code:
export async function analyzeNaive(ticket) {
const interaction = await getClient().interactions.create({
model: MODEL,
input: `${buildPrompt(ticket)}\n\nRespond as one helpful paragraph.`
});
return { raw: interaction.output_text };
}
export async function analyzeStructured(ticket) {
}
How does the naive request work?
- The interactions.create() call sends the support prompt and synthetic ticket to the model.
- The final prompt sentence asks for a paragraph. It does not define named response fields.
- The interaction.output_text value becomes the raw response returned to Express.
- The empty analyzeStructured() export keeps the temporary structured route import available for the next step.
- Save lib/analyzer.js.
- Confirm the file contains the analyzeNaive() export below buildPrompt().
✔️ Awesome, I've got everything!
Your server-side analyzer now creates free-form Gemini requests without exposing the API key to browser code.
ⓧ I'd like to double check the full code
import { GoogleGenAI } from "@google/genai";
const MODEL = "gemini-3.8-flash";
function getClient() {
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
throw new Error("GEMINI_API_KEY is missing.");
}
return new GoogleGenAI({ apiKey });
}
function buildPrompt(ticket) {
return `You triage support tickets for a software company.
Classify the ticket, judge operational urgency, summarize the issue, and draft a safe reply.
Do not invent customer data, refunds, fixes, or completion times.
Ticket:
${ticket}`;
}
export async function analyzeNaive(ticket) {
const interaction = await getClient().interactions.create({
model: MODEL,
input: `${buildPrompt(ticket)}\n\nRespond as one helpful paragraph.`
});
return { raw: interaction.output_text };
}
export async function analyzeStructured(ticket) {
}
Expose the naive API route
An API route gives the browser a safe boundary around the model call. Express validates the ticket before passing it to analyzeNaive().
- In server.js, find the current Express import:
import express from "express";
What are you changing?
This import currently gives the server its web framework. The analyzer import belongs directly below it so both dependencies stay at the top of the module.
- Replace the current import section with these two imports:
import express from "express";
import { analyzeNaive, analyzeStructured } from "./lib/analyzer.js";
What do these imports provide?
The analyzer module keeps model access outside the route definitions. The route can now call analyzeNaive() without handling the API key itself.
- In server.js, add the naive route below readTicket() by pasting this code:
app.post("/api/analyze-naive", async (req, res) => {
const ticket = readTicket(req);
if (!ticket) {
return res.status(400).json({ error: "Enter a ticket with at least 10 characters." });
}
try {
const result = await analyzeNaive(ticket);
return res.json(result);
} catch (error) {
console.error(error);
return res.status(500).json({ error: "Naive analysis failed. Check the API key and free-tier quota." });
}
});
What does this route do?
- The readTicket() check rejects missing or very short ticket text.
- The route waits for analyzeNaive() to return its raw paragraph.
- The success path sends the result to the browser as JSON.
- The error path keeps provider details inside the server log. The browser receives a concise failure message.
- Save server.js.
- Switch back to the integrated terminal running the app.
- Stop the existing process by pressing Ctrl+C.
- Restart the Express server with the updated route by running this command:
npm start
What does this command start?
The start script loads your local .env into the Node.js process before running server.js. Your terminal should confirm that the Ticket Triage Lab is running at http://localhost:3000.
Server not restarting?
Check that both analyzer imports use the exact names exported from lib/analyzer.js. Confirm that the relative path starts with ./lib/.
If the terminal reports an API key problem after a request, confirm that your local .env still contains the key from the setup step. Help me troubleshoot why my Express server cannot load the Gemini analyzer.
The route is ready, but the page still needs a control that selects it. The new button keeps the free-form path beside the existing structured mock for a direct comparison.
- In public/index.html, find these two buttons inside the actions container:
<button id="sample-button" type="button" class="secondary">Load sample</button>
<button type="submit">Run structured AI</button>
What is here now?
The first button loads the synthetic ticket. The submit button sends the form through the existing mock path.
- Replace those two lines with this three-button group:
<button id="sample-button" type="button" class="secondary">Load sample</button>
<button id="naive-button" type="button" class="secondary">Run naive AI</button>
<button type="submit">Run structured AI</button>
What changes in the form?
The naive-button identifier gives JavaScript a stable reference to the new control. Its button type prevents it from submitting the structured form.
- Save public/index.html.
- Refresh http://localhost:3000 in your browser.
You should now see Run naive AI between Load sample and Run structured AI.
Don't see the naive button?
Confirm that you saved public/index.html. Check that the new button remains inside the actions container.
Refresh the browser after the server restart. Help me find why the Run naive AI button is missing from my page.
✔️ Awesome, I've got everything!
The page now shows a separate control for the free-form model path.
ⓧ 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>AI Ticket Triage Lab</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<main class="shell">
<header>
<p class="eyebrow">Portfolio AI reliability demo</p>
<h1>AI Support Ticket Triage Lab</h1>
<p>Compare a readable AI paragraph with a schema-constrained application response.</p>
</header>
<section class="panel">
<form id="ticket-form">
<label for="ticket">Synthetic support ticket</label>
<textarea id="ticket" rows="8" required></textarea>
<div class="actions">
<button id="sample-button" type="button" class="secondary">Load sample</button>
<button id="naive-button" type="button" class="secondary">Run naive AI</button>
<button type="submit">Run structured AI</button>
</div>
</form>
<p id="status" class="status" aria-live="polite"></p>
</section>
<section id="raw-panel" class="panel" hidden>
<h2>Free-form result</h2>
<p>This is readable, but the frontend cannot depend on named fields.</p>
<pre id="raw-output"></pre>
</section>
<section id="cards" class="results" hidden>
<article class="card">
<h2>Category</h2>
<p id="category"></p>
</article>
<article class="card">
<h2>Priority</h2>
<p id="priority"></p>
</article>
<article class="card wide">
<h2>Summary</h2>
<p id="summary"></p>
</article>
<article class="card wide">
<h2>Suggested reply</h2>
<p id="suggested-reply"></p>
</article>
</section>
<p class="privacy">Use synthetic tickets only. Free-tier content may be used to improve Google's products.</p>
</main>
<script type="module" src="/app.js"></script>
</body>
</html>
Make the browser reveal the contract gap
The browser currently assumes every successful response contains named fields. Sending a free-form result through that renderer exposes why readable text alone is brittle application data.
- In public/app.js, replace the opening references and sample-button listener with this browser wiring:
const form = document.querySelector("#ticket-form");
const ticket = document.querySelector("#ticket");
const sampleButton = document.querySelector("#sample-button");
const naiveButton = document.querySelector("#naive-button");
const status = document.querySelector("#status");
const rawPanel = document.querySelector("#raw-panel");
const rawOutput = document.querySelector("#raw-output");
const cards = document.querySelector("#cards");
const sampleTicket = `Hi support, our company card was charged twice for the same monthly subscription. The duplicate charge is affecting this week's budget review. Please investigate and tell me what information you need.`;
sampleButton.addEventListener("click", () => {
ticket.value = sampleTicket;
ticket.focus();
});
naiveButton.addEventListener("click", async () => {
await runAnalysis("/api/analyze-naive", "naive");
});
What does this wiring add?
- The naiveButton reference points to the control you added in the page.
- Its click listener selects /api/analyze-naive as the endpoint.
- The naive mode tells the shared request helper which response shape it is handling.
- Replace the structured submit listener and the start of runAnalysis() with this endpoint-aware version:
form.addEventListener("submit", async (event) => {
event.preventDefault();
await runAnalysis("/api/analyze", "structured");
});
async function runAnalysis(endpoint, mode) {
const value = ticket.value.trim();
if (value.length < 10) {
status.textContent = "Enter a longer synthetic ticket first.";
return;
}
setBusy(true, mode === "naive" ? "Requesting free-form output..." : "Requesting structured output...");
try {
const response = await fetch(endpoint, {
Why share one request helper?
Both controls send the same ticket body. The endpoint and mode parameters let one helper support different server contracts without duplicating the request flow.
- Save public/app.js.
- Refresh the browser.
- Click Load sample.
Before you test the new route, predict whether a helpful paragraph can fill four fields that do not exist in its response.
- Click Run naive AI.
The request returns readable text, but the category, priority, summary, and reply cards have no matching properties to display. That shortfall is intentional.
Why did the cards fall short?
The server returned an object with one raw property. The card renderer looked for category, priority, summary, and suggested_reply.
A person can understand the paragraph. The frontend cannot safely infer a stable data contract from its wording.
The comparison needs to preserve the paragraph as raw output. The UI can then show the model's response without pretending it contains dependable fields.
- In runAnalysis(), find the direct structured-rendering call:
renderStructured(data);
What is the current assumption?
This call sends every successful response to the named-card renderer. The naive response needs a separate rendering path.
- Replace that direct call with this mode check:
if (mode === "naive") {
renderNaive(data);
} else {
renderStructured(data);
}
What does the mode check change?
The naive path can display data.raw without parsing it. The structured path keeps using the four existing cards.
- Add the raw-response renderer directly above renderStructured() by pasting this function:
function renderNaive(data) {
cards.hidden = true;
rawPanel.hidden = false;
rawOutput.textContent = data.raw;
status.textContent = "Naive response received. Notice the missing application fields.";
}
What does the raw renderer show?
- The named cards are hidden because the response does not provide their fields.
- The raw panel becomes visible so the complete model paragraph remains readable.
- The status message names the contract problem that the next step fixes.
- Save public/app.js.
- Refresh the browser.
- Click Load sample.
- Click Run naive AI.
The raw-output panel now shows the model's paragraph. The four named cards stay hidden because the response has no stable fields.
- In setBusy(), add this line above the existing structured-submit button state:
naiveButton.disabled = isBusy;
Why disable the naive button?
Disabling the control prevents duplicate model requests while one response is pending. The button becomes available again when the request finishes.
- Save public/app.js.
Before the final check, predict which panel will remain visible after another naive request.
- Refresh the browser.
- Click Load sample.
- Click Run naive AI.
- Repeat the naive request twice with the same synthetic ticket.
Each request produces a readable paragraph in the raw-output panel. Its wording may vary, but it still lacks dependable named fields for the four cards.
Naive request not returning?
Check the integrated terminal for the server-side failure. Confirm that npm start is still running with the local .env available.
Confirm that the browser calls /api/analyze-naive and that the server route uses analyzeNaive(). Help me debug my free-form Gemini request.
✔️ Awesome, I've got everything!
You have exposed the exact contract failure: useful prose for a person with no dependable fields for the application.
ⓧ I'd like to double check the full code
const form = document.querySelector("#ticket-form");
const ticket = document.querySelector("#ticket");
const sampleButton = document.querySelector("#sample-button");
const naiveButton = document.querySelector("#naive-button");
const status = document.querySelector("#status");
const rawPanel = document.querySelector("#raw-panel");
const rawOutput = document.querySelector("#raw-output");
const cards = document.querySelector("#cards");
const sampleTicket = `Hi support, our company card was charged twice for the same monthly subscription. The duplicate charge is affecting this week's budget review. Please investigate and tell me what information you need.`;
sampleButton.addEventListener("click", () => {
ticket.value = sampleTicket;
ticket.focus();
});
naiveButton.addEventListener("click", async () => {
await runAnalysis("/api/analyze-naive", "naive");
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
await runAnalysis("/api/analyze", "structured");
});
async function runAnalysis(endpoint, mode) {
const value = ticket.value.trim();
if (value.length < 10) {
status.textContent = "Enter a longer synthetic ticket first.";
return;
}
setBusy(true, mode === "naive" ? "Requesting free-form output..." : "Requesting structured output...");
try {
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ticket: value })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "The request failed.");
}
if (mode === "naive") {
renderNaive(data);
} else {
renderStructured(data);
}
} catch (error) {
status.textContent = error.message;
} finally {
setBusy(false);
}
}
function renderNaive(data) {
cards.hidden = true;
rawPanel.hidden = false;
rawOutput.textContent = data.raw;
status.textContent = "Naive response received. Notice the missing application fields.";
}
function renderStructured(data) {
rawPanel.hidden = true;
cards.hidden = false;
document.querySelector("#category").textContent = data.category;
document.querySelector("#priority").textContent = data.priority;
document.querySelector("#summary").textContent = data.summary;
document.querySelector("#suggested-reply").textContent = data.suggested_reply;
status.textContent = "Structured response received and rendered.";
}
function setBusy(isBusy, message = "") {
status.textContent = message;
naiveButton.disabled = isBusy;
form.querySelector("button[type='submit']").disabled = isBusy;
}
You have seen why polished model prose can still break a frontend contract. Next, you will constrain the response with a schema so the cards receive reliable fields.
Enforce a Structured Contract
Your free-form route now produces a readable Gemini response. That result exposed the missing contract between the model and your browser.
The cards need dependable fields with controlled values. Gemini Structured Outputs applies a JSON Schema to the response so your application receives a predictable shape.
In this step, get ready to:
- Define the allowed ticket fields and values.
- Connect the structured analyzer to the Express route.
- Render the parsed response in predictable result cards.
Define the ticket schema
A schema describes the exact response shape that the model must produce. Enums also limit classification fields to values your application understands.
- Select lib/analyzer.js in the VS Code Explorer sidebar.
- Find the const MODEL = "gemini-3.8-flash"; line.
- Add the ticket contract directly below that line by pasting this code:
export const ticketSchema = {
type: "object",
properties: {
category: {
type: "string",
enum: ["billing", "bug", "account", "feature", "other"],
description: "The single best routing category for the ticket."
},
priority: {
type: "string",
enum: ["low", "medium", "high", "critical"],
description: "The operational urgency of the ticket."
},
summary: {
type: "string",
description: "A concise factual summary of the customer's problem."
},
suggested_reply: {
type: "string",
description: "A helpful reply that does not invent account details or promise an unsupported resolution."
}
},
required: ["category", "priority", "summary", "suggested_reply"],
additionalProperties: false
};
What does this schema enforce?
- The category enum limits routing to five supported categories.
- The priority enum limits urgency to four supported levels.
- The required array ensures every response includes all four fields.
- The additionalProperties setting prevents unexpected fields from entering the contract.
- Save lib/analyzer.js.
- Confirm the schema ends with additionalProperties: false.
Seeing a syntax problem in the schema?
Check that every property block closes before the next property begins. Confirm that the final schema closes with };.
Compare the enum quotes with the code above. Help me find the syntax problem in my ticketSchema object.
The schema defines the contract. The structured analyzer now needs to send that contract with the model request and parse the returned JSON string.
- Select the placeholder analyzeStructured function in lib/analyzer.js.
- Replace the selected function with this implementation:
export async function analyzeStructured(ticket) {
const interaction = await getClient().interactions.create({
model: MODEL,
input: buildPrompt(ticket),
response_format: {
type: "text",
mime_type: "application/json",
schema: ticketSchema
}
});
return JSON.parse(interaction.output_text);
}
What does the structured analyzer do?
- The request reuses MODEL and buildPrompt(ticket) from the naive analyzer.
- The response_format object requests JSON that follows ticketSchema.
- The mime_type value identifies the response as application/json.
- JSON.parse(interaction.output_text) converts the returned string into an object with named fields.
- Save lib/analyzer.js.
Before you restart the app, do you expect the server to accept the new analyzer without a JavaScript syntax failure?
- Stop the running server by pressing Ctrl+C in the integrated terminal.
- Restart the Express application by running this command:
npm start
What does this restart prove?
The start script runs node --env-file=.env server.js. A successful startup confirms that Node can load the updated analyzer module.
Good progress. You will see Ticket Triage Lab running at http://localhost:3000 in the terminal when the updated module loads successfully.
Server not restarting?
Confirm that the previous Node process stopped before starting another one. Check the braces around ticketSchema and analyzeStructured.
Keep the API key inside .env while debugging. Help me diagnose why the structured analyzer prevents my Express server from starting.
✔️ Awesome, I've got everything!
Your analyzer now contains the free-form path and the schema-constrained path. Double-check that lib/analyzer.js is saved.
ⓧ I'd like to double check the full code
import { GoogleGenAI } from "@google/genai";
const MODEL = "gemini-3.8-flash";
export const ticketSchema = {
type: "object",
properties: {
category: {
type: "string",
enum: ["billing", "bug", "account", "feature", "other"],
description: "The single best routing category for the ticket."
},
priority: {
type: "string",
enum: ["low", "medium", "high", "critical"],
description: "The operational urgency of the ticket."
},
summary: {
type: "string",
description: "A concise factual summary of the customer's problem."
},
suggested_reply: {
type: "string",
description: "A helpful reply that does not invent account details or promise an unsupported resolution."
}
},
required: ["category", "priority", "summary", "suggested_reply"],
additionalProperties: false
};
function getClient() {
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
throw new Error("GEMINI_API_KEY is missing.");
}
return new GoogleGenAI({ apiKey });
}
function buildPrompt(ticket) {
return `You triage support tickets for a software company.
Classify the ticket, judge operational urgency, summarize the issue, and draft a safe reply.
Do not invent customer data, refunds, fixes, or completion times.
Ticket:
${ticket}`;
}
export async function analyzeNaive(ticket) {
const interaction = await getClient().interactions.create({
model: MODEL,
input: `${buildPrompt(ticket)}\n\nRespond as one helpful paragraph.`
});
return { raw: interaction.output_text };
}
export async function analyzeStructured(ticket) {
const interaction = await getClient().interactions.create({
model: MODEL,
input: buildPrompt(ticket),
response_format: {
type: "text",
mime_type: "application/json",
schema: ticketSchema
}
});
return JSON.parse(interaction.output_text);
}
Connect the structured analyzer
The browser already sends structured requests to /api/analyze. The Express route must now replace its temporary mock with the parsed model response.
- Select server.js in the VS Code Explorer sidebar.
- Find the temporary app.post("/api/analyze" route.
- Replace the complete temporary route with this implementation:
app.post("/api/analyze", async (req, res) => {
const ticket = readTicket(req);
if (!ticket) {
return res.status(400).json({ error: "Enter a ticket with at least 10 characters." });
}
try {
const result = await analyzeStructured(ticket);
return res.json(result);
} catch (error) {
console.error(error);
return res.status(500).json({ error: "Structured analysis failed. Check the API key and free-tier quota." });
}
});
How does the route complete the contract?
- The route reuses readTicket(req) to reject missing or short ticket text.
- The route awaits analyzeStructured(ticket) to receive the parsed object.
- The res.json(result) call returns the named fields to the browser.
- The catch block returns a route-specific error if the model request fails.
- Save server.js.
- Stop the current server by pressing Ctrl+C in the integrated terminal.
- Start the updated server by running this command:
npm start
Why restart the server?
The running Node process keeps the previous route implementation in memory. Restarting loads the structured route from the saved server.js file.
You will see Ticket Triage Lab running at http://localhost:3000 again. The structured route is now ready to receive browser requests.
Updated route not loading?
Confirm that only one app.post("/api/analyze" route remains in server.js. Check that the route calls analyzeStructured(ticket).
Restart the server after saving the file. Help me check why my Express structured route still returns the temporary mock response.
✔️ Awesome, I've got everything!
Your Express server now exposes both the naive route and the structured route. Double-check that server.js is saved.
ⓧ I'd like to double check the full code
import express from "express";
import { analyzeNaive, analyzeStructured } from "./lib/analyzer.js";
const app = express();
const PORT = 3000;
app.use(express.json());
app.use(express.static("public"));
app.get("/api/health", (req, res) => {
res.json({ ok: true });
});
function readTicket(req) {
const ticket = req.body?.ticket;
if (typeof ticket !== "string" || ticket.trim().length < 10) {
return null;
}
return ticket.trim();
}
app.post("/api/analyze-naive", async (req, res) => {
const ticket = readTicket(req);
if (!ticket) {
return res.status(400).json({ error: "Enter a ticket with at least 10 characters." });
}
try {
const result = await analyzeNaive(ticket);
return res.json(result);
} catch (error) {
console.error(error);
return res.status(500).json({ error: "Naive analysis failed. Check the API key and free-tier quota." });
}
});
app.post("/api/analyze", async (req, res) => {
const ticket = readTicket(req);
if (!ticket) {
return res.status(400).json({ error: "Enter a ticket with at least 10 characters." });
}
try {
const result = await analyzeStructured(ticket);
return res.json(result);
} catch (error) {
console.error(error);
return res.status(500).json({ error: "Structured analysis failed. Check the API key and free-tier quota." });
}
});
app.listen(PORT, (error) => {
if (error) {
throw error;
}
console.log(`Ticket Triage Lab running at http://localhost:${PORT}`);
});
Compare both response paths
Both buttons now ask the same model to triage the same ticket. Their response contracts determine what the browser can render.
- Switch back to the browser tab at http://localhost:3000 from earlier.
- Refresh the page.
- Click Load sample.
- Click Run naive AI.
The raw-output panel shows a readable paragraph. The four named result cards remain hidden because the response only contains raw.
Before you test the structured path, which response do you expect the cards to render more reliably?
- Click Run structured AI.
The Category card shows billing. The Priority card shows one allowed priority value.
The Summary card contains a concise ticket summary. The Suggested reply card contains a customer-facing response.
That is the contract working. Your frontend renders named fields without parsing a free-form paragraph.
Cards not filling with structured data?
Confirm that the terminal still shows the running server. Check that renderStructured(data) reads data.category and data.priority.
If the status area reports a request failure, confirm that .env remains available to the Express process. Help me diagnose why my structured ticket fields are not reaching the browser cards.
Your application now has a stable model-to-browser contract while preserving the naive comparison. Next, you will measure whether valid fields also contain the classifications you intended.
Run Behavioral Evaluations
Your JSON Schema now gives the browser predictable fields from Gemini. A dependable shape still cannot prove that each ticket receives the intended category or urgency.
A behavioral evaluation runs labeled synthetic tickets through the same structured analyzer. Each comparison turns model behavior into evidence for iteration.
In this step, get ready to:
- Create four labeled ticket fixtures.
- Run category and priority checks against the structured analyzer.
- Document the architecture and reliability story.
Create the labeled fixtures
Each fixture pairs a synthetic support ticket with its expected category. An allowed priority range gives the model room for reasonable urgency judgments.
- In the Visual Studio Code file sidebar, select the ai-ticket-triage-lab folder from earlier.
- Use the file sidebar's new-file control.
- Name the file evals.js.
- Build the labeled fixture set in evals.js by pasting this code:
import { analyzeStructured } from "./lib/analyzer.js";
const fixtures = [
{
name: "duplicate charge",
ticket: "Our company card was charged twice for the same monthly subscription and finance needs an answer today.",
expectedCategory: "billing",
allowedPriorities: ["high", "critical"]
},
{
name: "broken export",
ticket: "The Download PDF button spins forever for every report, but the rest of the dashboard still works.",
expectedCategory: "bug",
allowedPriorities: ["medium", "high"]
},
{
name: "keyboard shortcut request",
ticket: "It would be useful to have keyboard shortcuts for moving between dashboard tabs someday.",
expectedCategory: "feature",
allowedPriorities: ["low", "medium"]
},
{
name: "payroll lockout",
ticket: "I cannot sign in after resetting my password, and payroll closes in one hour.",
expectedCategory: "account",
allowedPriorities: ["high", "critical"]
}
];
What do these fixtures measure?
- The duplicate charge fixture checks whether a finance problem reaches the billing category.
- The broken export fixture checks whether failed product behavior reaches the bug category.
- The keyboard shortcut request fixture checks whether a product suggestion reaches the feature category.
- The payroll lockout fixture checks whether an urgent sign-in problem reaches the account category.
- Save evals.js.
- Confirm evals.js appears beside server.js in the file sidebar.
Can't find the fixture file?
Check that evals.js sits inside ai-ticket-triage-lab. It should be beside server.js.
Make sure the file name ends with .js. Help me verify where evals.js belongs in my project.
The runner calls analyzeStructured() once per fixture. Separate checks reveal whether the category or priority caused a mismatch.
- In evals.js, find the closing ]; below the fixture array.
- Add the evaluation loop below that line by pasting this code:
let passed = 0;
let total = 0;
for (const fixture of fixtures) {
try {
const result = await analyzeStructured(fixture.ticket);
const categoryPass = result.category === fixture.expectedCategory;
const priorityPass = fixture.allowedPriorities.includes(result.priority);
total += 2;
passed += Number(categoryPass) + Number(priorityPass);
console.log(`\n${fixture.name}`);
console.log(` category: ${result.category} ${categoryPass ? "PASS" : "FAIL"}`);
console.log(` priority: ${result.priority} ${priorityPass ? "PASS" : "FAIL"}`);
} catch (error) {
total += 2;
console.log(`\n${fixture.name}`);
console.log(` ERROR: ${error.message}`);
}
}
console.log(`\nScore: ${passed}/${total} checks passed`);
process.exitCode = passed === total ? 0 : 1;
What does the runner check?
- The loop sends every fixture through the same structured path used by the browser.
- The category check requires one exact expected label.
- The priority check accepts either value in the fixture's allowed range.
- The final exit code reports success only when all eight checks pass.
- Save evals.js.
- Confirm the final line sets process.exitCode from the completed score.
Seeing a syntax problem in the runner?
Check that the loop begins below the fixture array's closing ];. Code pasted inside the array cannot execute as the runner.
Compare the template-literal backticks around each log message. Help me find the syntax problem in evals.js.
✔️ Awesome, I've got everything!
Your evaluation file now contains four fixtures and eight behavioral checks. Double-check that you saved evals.js.
ⓧ I'd like to double check the full code
import { analyzeStructured } from "./lib/analyzer.js";
const fixtures = [
{
name: "duplicate charge",
ticket: "Our company card was charged twice for the same monthly subscription and finance needs an answer today.",
expectedCategory: "billing",
allowedPriorities: ["high", "critical"]
},
{
name: "broken export",
ticket: "The Download PDF button spins forever for every report, but the rest of the dashboard still works.",
expectedCategory: "bug",
allowedPriorities: ["medium", "high"]
},
{
name: "keyboard shortcut request",
ticket: "It would be useful to have keyboard shortcuts for moving between dashboard tabs someday.",
expectedCategory: "feature",
allowedPriorities: ["low", "medium"]
},
{
name: "payroll lockout",
ticket: "I cannot sign in after resetting my password, and payroll closes in one hour.",
expectedCategory: "account",
allowedPriorities: ["high", "critical"]
}
];
let passed = 0;
let total = 0;
for (const fixture of fixtures) {
try {
const result = await analyzeStructured(fixture.ticket);
const categoryPass = result.category === fixture.expectedCategory;
const priorityPass = fixture.allowedPriorities.includes(result.priority);
total += 2;
passed += Number(categoryPass) + Number(priorityPass);
console.log(`\n${fixture.name}`);
console.log(` category: ${result.category} ${categoryPass ? "PASS" : "FAIL"}`);
console.log(` priority: ${result.priority} ${priorityPass ? "PASS" : "FAIL"}`);
} catch (error) {
total += 2;
console.log(`\n${fixture.name}`);
console.log(` ERROR: ${error.message}`);
}
}
console.log(`\nScore: ${passed}/${total} checks passed`);
process.exitCode = passed === total ? 0 : 1;
Run the behavioral suite
Every fixture makes a live model request through your server-side client. The terminal may pause between results while each response completes.
These tickets are synthetic because free-tier content may be used to improve Google's products. Your API key remains protected in the server-side .env file.
- Leave the existing terminal running the browser app.
- Start another PowerShell session in the terminal panel.
Before you run the suite, predict whether all eight checks will pass.
- Execute the behavioral suite in the second PowerShell session by running:
npm run eval
You'll see all four fixture names. Each fixture reports a category result plus a priority result.
The final line shows a score out of eight checks. A lower score exposes behavior that needs another prompt or fixture iteration.
How should I read the score?
A PASS means the model matched the behavior encoded by that fixture. A FAIL identifies a disagreement between the response and your expectation.
Schema validity proves that the response has the required fields. Behavioral checks test whether the values inside those fields fit your intended workflow.
Evaluation command not completing?
Confirm that the second PowerShell session is inside ai-ticket-triage-lab. Confirm that evals.js is saved.
If every fixture reports an API error, check that your local key remains available in .env. Help me troubleshoot my evaluation run without exposing my API key.
Document the reliability story
A concise README turns the working application into an explainable project. It connects the free-form failure to the structured fix.
- In the file sidebar, select the ai-ticket-triage-lab folder.
- Use the file sidebar's new-file control.
- Name the file README.md.
- Add the project summary and local run guide by pasting this content:
# AI Support Ticket Triage Lab
A JavaScript full-stack demo that compares free-form model text with schema-constrained AI output.
## What it demonstrates
- A Gemini API key kept on an Express server instead of in browser code
- A deliberate free-form output failure
- JSON Schema as a model-to-application contract
- Labeled behavioral fixtures for category and priority checks
- Honest limitations and a human-review extension
## Run locally
1. Copy `.env.example` to `.env` and replace the placeholder with a free-tier Gemini API key.
2. Run `npm install`.
3. Run `npm start`.
4. Open `http://localhost:3000`.
What does this section establish?
- The opening sentence frames the project as a full-stack reliability demonstration.
- The capability list highlights server-side secret handling through Express.
- The local guide gives another developer a repeatable path to the running browser app.
- Save README.md.
- Confirm the editor shows the project title and the Run locally heading.
README formatting looks flat?
Check that the file name is exactly README.md. The .md extension identifies the file as Markdown.
Confirm that the heading lines begin with the same number of hash characters shown above. Help me check the Markdown structure in README.md.
The remaining sections explain how to evaluate the app. They also capture the privacy boundary and the interview lessons behind the implementation.
- Place the cursor after the local startup instructions in README.md.
- Complete the documentation by pasting this content below them:
## Run evaluations
Run `npm run eval` to compare four synthetic tickets with expected categories and allowed priority ranges.
## Architecture
The browser sends synthetic ticket text to Express. Express calls Gemini 3.8 Flash through the Google Gen AI SDK. The structured route supplies a JSON Schema, parses `interaction.output_text`, and returns named fields to the browser.
## Privacy
Use synthetic tickets only. The Gemini Developer API Free Tier may use submitted content to improve Google's products. Never commit `.env`.
## Interview talking points
- Why readable text is not a dependable application contract
- Why schema validity and semantic correctness are separate problems
- Why API secrets belong on the server
- Why labeled fixtures reveal regressions that a polished demo can hide
- Where deterministic guardrails should sit after model inference
Why include these sections?
- The evaluation section gives reviewers a repeatable command for measuring behavior.
- The architecture section traces synthetic ticket data from the browser to Gemini.
- The privacy section records the free-tier limitation beside the project instructions.
- The talking points turn implementation choices into an interview-ready explanation.
- Save README.md.
- Confirm the final section ends with the deterministic guardrail talking point.
Missing part of the project story?
Check that README.md contains the evaluation, architecture, privacy, and interview sections. Each section should appear below the local run guide.
Confirm that the API key value does not appear anywhere in the document. Help me review README.md for missing sections or exposed secrets.
✔️ Awesome, I've got everything!
Your README now explains how the application works and how its behavior is measured. Double-check that you saved README.md.
ⓧ I'd like to double check the full code
# AI Support Ticket Triage Lab
A JavaScript full-stack demo that compares free-form model text with schema-constrained AI output.
## What it demonstrates
- A Gemini API key kept on an Express server instead of in browser code
- A deliberate free-form output failure
- JSON Schema as a model-to-application contract
- Labeled behavioral fixtures for category and priority checks
- Honest limitations and a human-review extension
## Run locally
1. Copy `.env.example` to `.env` and replace the placeholder with a free-tier Gemini API key.
2. Run `npm install`.
3. Run `npm start`.
4. Open `http://localhost:3000`.
## Run evaluations
Run `npm run eval` to compare four synthetic tickets with expected categories and allowed priority ranges.
## Architecture
The browser sends synthetic ticket text to Express. Express calls Gemini 3.8 Flash through the Google Gen AI SDK. The structured route supplies a JSON Schema, parses `interaction.output_text`, and returns named fields to the browser.
## Privacy
Use synthetic tickets only. The Gemini Developer API Free Tier may use submitted content to improve Google's products. Never commit `.env`.
## Interview talking points
- Why readable text is not a dependable application contract
- Why schema validity and semantic correctness are separate problems
- Why API secrets belong on the server
- Why labeled fixtures reveal regressions that a polished demo can hide
- Where deterministic guardrails should sit after model inference
Before the final run, predict whether the score must match your earlier result exactly.
- Return to the second PowerShell session from earlier.
- Verify the completed project by running the evaluation suite again:
npm run eval
You'll see category and priority results for all four fixtures. The final score is reported out of eight checks.
You have closed the reliability loop. Your app now has a structured contract, observable behavior checks, and documentation that explains both.
Secret mission
Add a Human-Review Guardrail
Add a deterministic rule that flags every critical ticket for human review after model inference. You will display the warning in the browser and measure the rule in your evaluation suite.
Clean Up Your Resources
Clean Up Your Resources
This project has no paid cloud infrastructure. Choose whether to keep your resources available, pause the local process, or delete them entirely.
Resources you used:
- A local Express application stored in the ai-ticket-triage-lab folder.
- A local Node.js process that runs the browser app when active.
- A free-tier Gemini Developer API key managed through Google AI Studio.
Keep everything running
No cleanup is needed while you are actively building. Your protected local key lets the server make Gemini requests when you start the app.
- Keep the ai-ticket-triage-lab folder in its current location.
- Leave .env listed in .gitignore.
- Keep the project API key active in Google AI Studio.
Pause - I'll come back to this later
The browser app cannot send API requests while its local server is stopped. Your files remain ready for your return.
- Switch back to the PowerShell window from earlier if the app is running.
- Press Ctrl+C to stop the Node.js process.
- Leave the ai-ticket-triage-lab folder in its current location.
Your workspace is safely paused. The local server makes no requests while it is stopped.
Delete - I don't want to use this again
Permanent cleanup can feel drastic. These steps remove the local ai-ticket-triage-lab folder plus the project API key.
Delete the API key:
- Return to the Google AI Studio API Keys page from earlier.
- Find the free-tier key you used for this project.
- Delete or revoke the key.
That credential is now retired. The server can no longer use it for Gemini requests.
Delete the local workspace:
- Switch back to the PowerShell window from earlier if the app is running.
- Press Ctrl+C to stop the Node.js process.
- Close Visual Studio Code.
- Use File Explorer to locate ai-ticket-triage-lab.
- Select the ai-ticket-triage-lab folder.
- Press Shift+Delete to remove it permanently.
- Confirm the deletion.
The entire local workspace is now removed.
Nice Work!
Nice Work!
You did it! Your full-stack support-ticket triage lab now turns synthetic tickets into dependable application fields through a server-side Gemini integration.
You've learned how to:
- Keep the Gemini API key on the Express server while the browser renders model results without exposing the credential.
- Demonstrate the free-form failure before replacing it with a JSON Schema contract that fills predictable result cards.
- Measure model behavior with four labeled fixtures that report category checks. They also report priority checks case by case.
- Secret Mission: Enforce a deterministic human-review guardrail whenever the model returns a critical priority.
Ready to quiz yourself?