Election Assistant with Gemini AI

Build and deploy an AI election process chatbot to Google Cloud Run.

Introduction

30 Second Summary

Elections involve a lot of moving parts. Between voter registration, primaries, the Electoral College, and mail-in ballots, it can be hard to find clear answers to straightforward questions.

In this project, you will build an AI-powered election process assistant that answers questions about how elections work. You will create a full-stack chat application with Node.js and Express, connect it to Google's Gemini AI, and deploy it to Google Cloud Run so anyone can use it.

What You'll Build

A conversational AI assistant with a clean chat interface, real-time streaming responses, and a live public URL on Google Cloud Run.

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

  • A Node.js/Express backend with a chat API endpoint that integrates with Google's Gemini AI using the official @google/genai SDK.
  • A responsive chat frontend with message bubbles, multi-turn conversation history, and real-time streaming responses using Server-Sent Events.
  • A live deployment on Google Cloud Run with a public URL you can share with anyone.
  • Secret Mission: Add clickable suggested question buttons that help users discover what they can ask the assistant.

Are there any prerequisites?

This project assumes basic familiarity with Node.js, Express, HTML/CSS/JavaScript, and terminal commands. You will also need a Google Cloud account with credits and a Gemini API key. Step 1 walks you through the full setup.

Before We Start

Before We Start...

Set Up the Project

Before you can build an AI-powered assistant, you need a project foundation that connects your backend to Google's Gemini API. That means a Node.js project with the right packages installed and a server ready to handle requests.

In this step, you'll create the project directory, install three dependencies, set up your files, and confirm everything works with a test server.

In this step, get ready to:
  • Create and initialize a Node.js project with the required dependencies.
  • Set up the file structure for the backend and frontend.
  • Verify the setup with a Hello World Express server.
Create and initialize the project

Your project needs a dedicated folder and a few packages to talk to the Gemini API and serve a web interface.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
  • Move to your Desktop and create the project folder by running these commands:
cd ~/Desktop
mkdir election-assistant
cd election-assistant
  • Confirm you're inside the new folder by running:
ls

You should see an empty output since the folder is brand new.

  • Initialize a new Node.js project by running:
npm init -y

You should see a message that says Wrote to .../package.json followed by the generated JSON.

  • Install the three dependencies your project needs by running:
npm install express @google/genai dotenv

You should see output ending with added followed by a number of packages.

What did you just install?

  • express is a web framework for Node.js. It handles incoming HTTP requests and sends responses back to the browser.
  • @google/genai is Google's official SDK for the Gemini API. It lets your server send prompts to Gemini and receive AI-generated responses.
  • dotenv loads environment variables from a .env file so you can keep your API key out of your source code.

Seeing an npm error?

  • If you see npm: command not found, Node.js is not installed or not on your system path. Verify your installation by running node --version. You need version 20 or later.
  • If you see permission errors, try running the command with sudo on macOS/Linux, or open Command Prompt as Administrator on Windows.

Why is npm install failing for my election assistant project?

Set up the project files

Your project needs a few files before you can write any code. You'll configure package.json to use modern JavaScript modules, create an environment file for your API key, and set up folders for the frontend and backend.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code (or Antigravity if you use Google Antigravity) and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the election-assistant folder, and click Open.

You should see package.json and a node_modules folder in the Explorer sidebar on the left.

The npm init -y command created a basic package.json, but it needs a few updates to work with modern JavaScript imports and Cloud Run deployment later.

  • Open package.json in the Explorer sidebar by clicking on it.
  • Select all the text with Cmd+A (macOS) or Ctrl+A (Windows) and delete it.
  • Replace the entire contents with this:
{
  "name": "election-assistant",
  "version": "1.0.0",
  "description": "AI-powered election process assistant using Gemini",
  "main": "server.js",
  "type": "module",
  "scripts": {
    "start": "node server.js"
  },
  "engines": {
    "node": ">=20.0.0"
  },
  "dependencies": {
    "@google/genai": "^1.50.0",
    "dotenv": "^16.4.7",
    "express": "^5.1.0"
  }
}

What do these fields do?

  • "type": "module" enables modern import/export syntax instead of the older require() style.
  • "main": "server.js" tells Node.js which file to run when the project starts.
  • "engines" specifies that this project requires Node.js version 20 or later. Cloud Run uses this to pick the right runtime.
  • The "start" script lets you (and Cloud Run) launch the server with npm start.
  • Save package.json with Cmd+S (macOS) or Ctrl+S (Windows).

Now create the three remaining files your project needs. Your Gemini API key goes in a .env file to keep it separate from your code.

  • Create a new file by clicking the new file icon at the top of the Explorer sidebar (or press Cmd+N / Ctrl+N).
  • Name the file .env.
  • Add this line to the file, replacing your-api-key-here with your actual Gemini API key:
GEMINI_API_KEY=your-api-key-here

Where do I get a Gemini API key?

Visit Google AI Studio and click Create API key. The free tier includes generous token limits for Gemini 2.5 Flash, which is the model you'll use in this project.

  • Save .env.
  • Create another new file and name it server.js. Leave it empty for now.
  • Create a new folder by clicking the new folder icon at the top of the Explorer sidebar.
  • Name the folder public.

Your Explorer sidebar should now show .env, node_modules, package.json, package-lock.json, a public folder, and server.js.

Missing a file or folder?

  • If you don't see .env, it may be hidden. Click the three dots (...) at the top of the Explorer sidebar and make sure Show Hidden Files is enabled.
  • If you don't see node_modules, the npm install command may not have completed successfully. Go back to your terminal and run it again.

Help me check my election-assistant project file structure.

Verify the setup with a test server

The best way to confirm everything is wired up correctly is to run a simple server. Express makes this easy with just a few lines of code.

  • Open server.js in your editor and add the following code:
// Load environment variables from .env
import 'dotenv/config';
import express from 'express';

// Create the Express app and set the port
const app = express();
const port = parseInt(process.env.PORT) || 8080;

// Parse incoming JSON requests
app.use(express.json());
// Serve frontend files from the public/ folder
app.use(express.static('public'));

// Temporary test route to verify the server works
app.get('/', (req, res) => {
  res.send('Hello World');
});

// Start the server
app.listen(port, () => {
  console.log(`Election Assistant server running on port ${port}`);
});

What does this code do?

  • import 'dotenv/config' automatically loads the GEMINI_API_KEY from your .env file into process.env.
  • port reads from the PORT environment variable (which Cloud Run sets automatically) and falls back to 8080 for local development.
  • express.json() lets the server understand JSON data in incoming requests. You'll need this when the frontend sends chat messages.
  • express.static('public') serves HTML, CSS, and JavaScript files from the public/ folder directly to the browser.
  • The app.get('/') route is a temporary placeholder that returns "Hello World" so you can verify the server is running.
  • Save server.js.
  • Open the integrated terminal in your editor by pressing Ctrl+` (backtick key, same on macOS and Windows).
  • Start the server by running:
node server.js

You should see Election Assistant server running on port 8080 in the terminal.

  • Open your browser and go to http://localhost:8080.

You should see "Hello World" displayed on the page.

  • Go back to your terminal and press Ctrl+C to stop the server.

Not seeing Hello World?

  • If you see SyntaxError: Cannot use import statement outside a module, your package.json is missing "type": "module". Open package.json and make sure that line is present.
  • If you see Error: Cannot find module, your dependencies may not be installed. Run npm install again from the election-assistant folder.
  • If the browser shows "This site can't be reached", make sure the terminal says the server is running on port 8080 and that you're visiting http://localhost:8080 (not https).

Why isn't my Express server working on localhost?

✔️ Awesome, I've got everything!

Great. Double check you've saved both package.json and server.js before moving on.

ⓧ I'd like to double check the full code

Here is the full content for each file at this point. Compare yours line by line.

{
  "name": "election-assistant",
  "version": "1.0.0",
  "description": "AI-powered election process assistant using Gemini",
  "main": "server.js",
  "type": "module",
  "scripts": {
    "start": "node server.js"
  },
  "engines": {
    "node": ">=20.0.0"
  },
  "dependencies": {
    "@google/genai": "^1.50.0",
    "dotenv": "^16.4.7",
    "express": "^5.1.0"
  }
}
GEMINI_API_KEY=your-api-key-here
import 'dotenv/config';
import express from 'express';

const app = express();
const port = parseInt(process.env.PORT) || 8080;

app.use(express.json());
app.use(express.static('public'));

app.get('/', (req, res) => {
  res.send('Hello World');
});

app.listen(port, () => {
  console.log(`Election Assistant server running on port ${port}`);
});

Your project skeleton is ready and the server runs. Next up, you'll connect it to the Gemini API and build the chat backend that powers your election assistant.

Build the Gemini Chat Backend

Your Express server is running, but it only says "Hello World." The whole point of this project is to connect to Gemini AI so users can ask questions about the election process and get helpful answers back.

In this step, you'll wire up the @google/genai SDK to your server, define a system prompt that makes Gemini an election expert, and create an API endpoint that accepts user messages and returns AI-generated responses.

In this step, get ready to:
  • Import and configure the Gemini SDK with your API key.
  • Write a system prompt that turns Gemini into a non-partisan election process guide.
  • Create a chat endpoint and test it with curl.
Import the Gemini SDK and define the system prompt

A system prompt is a set of instructions you give to an LLM before the user ever speaks. It shapes the model's personality, topic coverage, and response style for the entire conversation.

  • In server.js, find the two import lines at the top of the file:
import 'dotenv/config';
import express from 'express';
  • Add this new import line directly below them:
import { GoogleGenAI } from '@google/genai';
  • Now scroll down past the two app.use() middleware lines. Add the following code block below them to create the AI client and define the election expert system prompt:
// Create a Gemini AI client using the API key from your .env file
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });

// Define the system prompt that shapes Gemini into an election process expert
const SYSTEM_PROMPT = `You are an Election Process Assistant, a helpful and non-partisan guide that helps users understand how elections work in the United States. Your role is to explain the election process clearly and accurately.

You cover the following topics:
- Voter registration: eligibility requirements, how to register, deadlines, and checking registration status
- Types of elections: primary elections, caucuses, general elections, midterm elections, and local elections
- The Electoral College: how it works, how electors are chosen, the 270 threshold, and winner-take-all vs proportional systems
- Voting methods: in-person voting, mail-in/absentee voting, early voting, and provisional ballots
- Election day procedures: what to bring, polling place locations, voting hours, and what to expect
- Election timeline: key dates, certification process, inauguration, and transition of power
- Campaign process: primaries, conventions, debates, and the general election campaign

Guidelines for your responses:
- Be non-partisan and factual. Do not express opinions about candidates or parties.
- Use clear, simple language. Break down complex processes into numbered steps when appropriate.
- If a question is outside your scope (not related to the election process), politely redirect the user.
- When discussing dates or deadlines, remind users that these can vary by state and they should check their local election office.
- Use bullet points and short paragraphs for readability.
- If you are unsure about a specific detail, say so rather than guessing.`;

What does this code do?

new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY }) creates a client that connects to Google's Gemini API. It reads the API key you saved in .env during the previous step.

SYSTEM_PROMPT tells Gemini exactly what role to play. It lists the topics the assistant should cover, enforces a non-partisan tone, and sets formatting guidelines. Every conversation with the model will follow these rules.

  • Save server.js.

You won't see a change yet. The next chunk adds the endpoint that actually uses the AI client and system prompt.

Create the POST /api/chat endpoint

The frontend will send the user's message to this endpoint. The server creates a chat session with Gemini, passes along the conversation history for context, and returns the AI's reply.

  • In server.js, find the Hello World route:
app.get('/', (req, res) => {
  res.send('Hello World');
});
  • Delete that entire route and replace it with the following chat endpoint:
// Handle incoming chat messages and return AI responses
app.post('/api/chat', async (req, res) => {
  try {
    const { message, history } = req.body;

    // Reject empty messages
    if (!message) {
      return res.status(400).json({ error: 'Message is required' });
    }

    // Create a chat session with conversation history for context
    const chat = ai.chats.create({
      model: 'gemini-2.5-flash',
      config: { systemInstruction: SYSTEM_PROMPT },
      history: history || [],
    });

    // Send the user's message and get the response
    const response = await chat.sendMessage({ message });

    // Return the AI's reply as JSON
    res.json({ reply: response.text });
  } catch (error) {
    console.error('Chat error:', error);
    res.status(500).json({ error: 'Failed to get response from assistant' });
  }
});

What does this code do?

  • app.post('/api/chat', ...) creates a POST endpoint. The frontend will send messages here as JSON.
  • ai.chats.create() starts a new chat session. It passes the system prompt so Gemini knows its role, and includes any previous conversation history so the model understands the context of follow-up questions.
  • chat.sendMessage({ message }) sends the user's question to Gemini and waits for a complete response.
  • The try/catch block catches any errors (network issues, invalid API key) and returns a clean error message instead of crashing the server.
  • Save server.js.

Seeing a syntax error when you save?

Make sure the backtick (`) at the end of the SYSTEM_PROMPT string is closed properly. Template literals in JavaScript use backticks, not regular quotes.

Also double check that you fully deleted the old app.get('/', ...) route, including its closing });.

Help me fix a syntax error in my server.js file for the election assistant project

✔️ Awesome, I've got everything!

Great. Double check you've saved server.js before moving on.

ⓧ I'd like to double check the full code

import 'dotenv/config';
import express from 'express';
import { GoogleGenAI } from '@google/genai';

const app = express();
const port = parseInt(process.env.PORT) || 8080;

app.use(express.json());
app.use(express.static('public'));

const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });

const SYSTEM_PROMPT = `You are an Election Process Assistant, a helpful and non-partisan guide that helps users understand how elections work in the United States. Your role is to explain the election process clearly and accurately.

You cover the following topics:
- Voter registration: eligibility requirements, how to register, deadlines, and checking registration status
- Types of elections: primary elections, caucuses, general elections, midterm elections, and local elections
- The Electoral College: how it works, how electors are chosen, the 270 threshold, and winner-take-all vs proportional systems
- Voting methods: in-person voting, mail-in/absentee voting, early voting, and provisional ballots
- Election day procedures: what to bring, polling place locations, voting hours, and what to expect
- Election timeline: key dates, certification process, inauguration, and transition of power
- Campaign process: primaries, conventions, debates, and the general election campaign

Guidelines for your responses:
- Be non-partisan and factual. Do not express opinions about candidates or parties.
- Use clear, simple language. Break down complex processes into numbered steps when appropriate.
- If a question is outside your scope (not related to the election process), politely redirect the user.
- When discussing dates or deadlines, remind users that these can vary by state and they should check their local election office.
- Use bullet points and short paragraphs for readability.
- If you are unsure about a specific detail, say so rather than guessing.`;

app.post('/api/chat', async (req, res) => {
  try {
    const { message, history } = req.body;

    if (!message) {
      return res.status(400).json({ error: 'Message is required' });
    }

    const chat = ai.chats.create({
      model: 'gemini-2.5-flash',
      config: { systemInstruction: SYSTEM_PROMPT },
      history: history || [],
    });

    const response = await chat.sendMessage({ message });

    res.json({ reply: response.text });
  } catch (error) {
    console.error('Chat error:', error);
    res.status(500).json({ error: 'Failed to get response from assistant' });
  }
});

app.listen(port, () => {
  console.log(`Election Assistant server running on port ${port}`);
});
Test the endpoint with curl

Before building the frontend, it's worth confirming the backend actually works. You can send a test request from the terminal using curl.

  • If your server is still running from the previous step, stop it by pressing Ctrl+C in the terminal.
  • Restart it with the updated code by running:
node server.js
  • You should see Election Assistant server running on port 8080 in your terminal.
  • Open a second terminal window. Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) in your editor, then type Terminal: Create New Terminal and press Enter.
  • Send a test message to your chat endpoint by running this command:
curl -X POST http://localhost:8080/api/chat \
  -H "Content-Type: application/json" \
  -d '{"message": "How do I register to vote?"}'

What does this command do?

  • curl -X POST sends an HTTP POST request to your local server.
  • -H "Content-Type: application/json" tells the server you're sending JSON data.
  • -d '{...}' is the JSON body containing the user's message. This mimics exactly what the frontend will send later.

After a few seconds, you should see a JSON response in your terminal containing a reply field with a detailed answer about voter registration. The response will cover eligibility, registration methods, and a reminder to check your local election office for state-specific details.

Getting an error instead of a reply?

  • If you see Failed to get response from assistant, check your terminal running the server for the full error. The most common cause is an invalid API key. Open .env and make sure GEMINI_API_KEY contains your actual key, not the placeholder text.
  • If you see Connection refused, your server is not running. Go back to the first terminal and run node server.js again.
  • If you see a syntax error about unexpected tokens, double check you saved server.js before restarting the server.

Help me debug my /api/chat endpoint

Your backend is live and responding to questions about the election process. Next up, you'll build the chat frontend so users can interact with the assistant through a real interface instead of curl commands.

Build the Chat Frontend

Your backend can now receive a question and return an AI-generated answer. The problem is, nobody wants to talk to an API endpoint using curl commands.

In this step, you'll build the chat interface that users actually interact with. You'll create three files inside your public/ folder: an HTML page for the structure, a CSS stylesheet for the design, and a JavaScript file that sends messages to your backend and renders the conversation.

In this step, get ready to:
  • Create the HTML chat interface with a header, message area, and input form.
  • Style the chat with message bubbles, colors, and responsive layout.
  • Add JavaScript to send messages, render responses, and maintain conversation history.
Create the HTML chat structure

The HTML file defines the skeleton of your chat interface. It has three sections: a header with the assistant's name, a scrollable area where messages appear, and a fixed input bar at the bottom.

  • Create a new file called index.html inside your public/ folder, then paste the following code:
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Election Process Assistant</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div class="chat-container">
    <!-- Header section with the assistant title -->
    <header class="chat-header">
      <h1>🗳️ Election Process Assistant</h1>
      <p>Ask me anything about how elections work</p>
    </header>

    <!-- Scrollable message area -->
    <div class="chat-messages" id="chatMessages">
      <div class="message assistant-message">
        <div class="message-content">
          <p>Hello! I'm your Election Process Assistant. I can help you understand voter registration, election timelines, voting methods, the Electoral College, and more. What would you like to know?</p>
        </div>
      </div>
    </div>

    <!-- Fixed input bar at the bottom -->
    <div class="chat-input-container">
      <form id="chatForm" class="chat-input-form">
        <input
          type="text"
          id="messageInput"
          placeholder="Ask about the election process..."
          autocomplete="off"
        >
        <button type="submit" id="sendButton">Send</button>
      </form>
    </div>
  </div>
  <script src="script.js"></script>
</body>
</html>

What does this HTML do?

  • The .chat-container wraps the entire interface into a single column layout.
  • The div#chatMessages is where all messages (both user and assistant) get added by JavaScript. It starts with one welcome message from the assistant.
  • The form#chatForm captures the user's input. Using a form means pressing Enter submits the message automatically.
  • Save index.html.
  • Make sure your server is running. If it's not, start it by running:
node server.js
  • Open your browser and go to http://localhost:8080. You should see unstyled text with the assistant's welcome message, an input field, and a Send button.

Seeing a blank page?

  • Make sure the file is saved as public/index.html (inside the public folder, not in the project root).
  • Confirm your server is running. Your terminal should show Election Assistant server running on port 8080.
  • Try a hard refresh with Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows) to clear cached content.

Why isn't my HTML page showing up at localhost:8080?

Style the chat interface

The unstyled HTML works, but it doesn't look like a chat app. CSS will turn it into a polished chat interface with message bubbles, colors, and a responsive layout.

The stylesheet is split into two chunks. First, you'll lay out the page structure and header. Then, you'll style the messages and input bar.

  • Create a new file called style.css inside your public/ folder, then paste the following layout styles:
/* Reset browser defaults */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

/* Center the chat container on the page */
body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  background-color: #f0f2f5;
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
}

/* Main chat wrapper - full height column layout */
.chat-container {
  width: 100%;
  max-width: 720px;
  height: 100vh;
  display: flex;
  flex-direction: column;
  background-color: #ffffff;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}

/* Blue header bar */
.chat-header {
  background-color: #1a73e8;
  color: white;
  padding: 16px 20px;
  text-align: center;
}

.chat-header h1 {
  font-size: 1.25rem;
  margin-bottom: 4px;
}

.chat-header p {
  font-size: 0.85rem;
  opacity: 0.9;
}

What does this CSS do?

  • The * reset removes default browser margins and padding so the layout starts clean.
  • The body uses flexbox to center the chat container vertically and horizontally on the page.
  • The .chat-container is a column that fills the full viewport height, capped at 720px wide.
  • Save style.css and refresh your browser.
  • You should see a white container with a blue header bar showing the assistant title. The welcome message text should be left-aligned below the header.

Header not turning blue?

  • Check that your HTML file has <link rel="stylesheet" href="style.css"> in the <head> section.
  • Make sure style.css is in the public/ folder, right next to index.html.

Why isn't my CSS loading on the page?

Now add the styles for message bubbles, the input bar, and a loading animation. Add the following code below the header styles in style.css:

/* Scrollable message area */
.chat-messages {
  flex: 1;
  overflow-y: auto;
  padding: 20px;
  display: flex;
  flex-direction: column;
  gap: 12px;
}

/* Base message row */
.message {
  display: flex;
  max-width: 85%;
}

/* User messages align right, assistant messages align left */
.user-message {
  align-self: flex-end;
}

.assistant-message {
  align-self: flex-start;
}

/* Shared bubble styling */
.message-content {
  padding: 10px 14px;
  border-radius: 16px;
  line-height: 1.5;
  font-size: 0.95rem;
  word-wrap: break-word;
}

/* Blue bubble for user messages */
.user-message .message-content {
  background-color: #1a73e8;
  color: white;
  border-bottom-right-radius: 4px;
}

/* Gray bubble for assistant messages */
.assistant-message .message-content {
  background-color: #f1f3f4;
  color: #202124;
  border-bottom-left-radius: 4px;
}

/* Input bar pinned to the bottom */
.chat-input-container {
  padding: 12px 16px;
  border-top: 1px solid #e0e0e0;
  background-color: #ffffff;
}

.chat-input-form {
  display: flex;
  gap: 8px;
}

#messageInput {
  flex: 1;
  padding: 10px 16px;
  border: 1px solid #dadce0;
  border-radius: 24px;
  font-size: 0.95rem;
  outline: none;
  transition: border-color 0.2s;
}

#messageInput:focus {
  border-color: #1a73e8;
}

/* Blue send button */
#sendButton {
  padding: 10px 20px;
  background-color: #1a73e8;
  color: white;
  border: none;
  border-radius: 24px;
  font-size: 0.95rem;
  cursor: pointer;
  transition: background-color 0.2s;
}

#sendButton:hover {
  background-color: #1557b0;
}

#sendButton:disabled {
  background-color: #a8c7fa;
  cursor: not-allowed;
}

/* Animated loading dots while waiting for a response */
.loading-dots::after {
  content: "...";
  animation: dots 1.5s steps(4, end) infinite;
}

@keyframes dots {
  0%, 20% { content: ""; }
  40% { content: "."; }
  60% { content: ".."; }
  80%, 100% { content: "..."; }
}

/* Mobile responsiveness */
@media (max-width: 768px) {
  .chat-container {
    max-width: 100%;
    height: 100vh;
  }
}

What do these styles do?

  • User messages get a blue bubble (#1a73e8) aligned to the right. Assistant messages get a gray bubble (#f1f3f4) aligned to the left.
  • The border-bottom-right-radius: 4px on user messages and border-bottom-left-radius: 4px on assistant messages create the pointed-corner effect you see in most chat apps.
  • The .loading-dots animation cycles through ".", "..", and "..." to show the assistant is thinking.
  • Save style.css and refresh your browser.
  • You should see a polished chat interface. The welcome message sits in a light gray bubble on the left. The input bar is pinned to the bottom with a rounded text field and a blue Send button.

Messages not styled as bubbles?

  • Check that the class names in your HTML match the CSS selectors exactly. For example, assistant-message in the HTML must match .assistant-message in the CSS.
  • Make sure you pasted the second chunk of CSS below the existing header styles, not replacing them.

Why aren't my chat message bubbles showing the correct styles?

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  background-color: #f0f2f5;
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
}

.chat-container {
  width: 100%;
  max-width: 720px;
  height: 100vh;
  display: flex;
  flex-direction: column;
  background-color: #ffffff;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}

.chat-header {
  background-color: #1a73e8;
  color: white;
  padding: 16px 20px;
  text-align: center;
}

.chat-header h1 {
  font-size: 1.25rem;
  margin-bottom: 4px;
}

.chat-header p {
  font-size: 0.85rem;
  opacity: 0.9;
}

.chat-messages {
  flex: 1;
  overflow-y: auto;
  padding: 20px;
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.message {
  display: flex;
  max-width: 85%;
}

.user-message {
  align-self: flex-end;
}

.assistant-message {
  align-self: flex-start;
}

.message-content {
  padding: 10px 14px;
  border-radius: 16px;
  line-height: 1.5;
  font-size: 0.95rem;
  word-wrap: break-word;
}

.user-message .message-content {
  background-color: #1a73e8;
  color: white;
  border-bottom-right-radius: 4px;
}

.assistant-message .message-content {
  background-color: #f1f3f4;
  color: #202124;
  border-bottom-left-radius: 4px;
}

.chat-input-container {
  padding: 12px 16px;
  border-top: 1px solid #e0e0e0;
  background-color: #ffffff;
}

.chat-input-form {
  display: flex;
  gap: 8px;
}

#messageInput {
  flex: 1;
  padding: 10px 16px;
  border: 1px solid #dadce0;
  border-radius: 24px;
  font-size: 0.95rem;
  outline: none;
  transition: border-color 0.2s;
}

#messageInput:focus {
  border-color: #1a73e8;
}

#sendButton {
  padding: 10px 20px;
  background-color: #1a73e8;
  color: white;
  border: none;
  border-radius: 24px;
  font-size: 0.95rem;
  cursor: pointer;
  transition: background-color 0.2s;
}

#sendButton:hover {
  background-color: #1557b0;
}

#sendButton:disabled {
  background-color: #a8c7fa;
  cursor: not-allowed;
}

.loading-dots::after {
  content: "...";
  animation: dots 1.5s steps(4, end) infinite;
}

@keyframes dots {
  0%, 20% { content: ""; }
  40% { content: "."; }
  60% { content: ".."; }
  80%, 100% { content: "..."; }
}

@media (max-width: 768px) {
  .chat-container {
    max-width: 100%;
    height: 100vh;
  }
}
Add the chat JavaScript

The interface looks great, but nothing happens when you type a message and click Send. You need JavaScript to capture user input, send it to your backend, and render the conversation.

  • Create a new file called script.js inside your public/ folder, then paste the following setup code:
// Grab references to the key DOM elements
const chatMessages = document.getElementById("chatMessages");
const chatForm = document.getElementById("chatForm");
const messageInput = document.getElementById("messageInput");
const sendButton = document.getElementById("sendButton");

// Stores the full conversation so Gemini has context for follow-up questions
let conversationHistory = [];

// Listen for form submissions (Enter key or Send button click)
chatForm.addEventListener("submit", (e) => {
  e.preventDefault();
  const message = messageInput.value.trim();
  if (message) {
    sendMessage(message);
  }
});

What does this code do?

  • The four const declarations cache references to the DOM elements your code will interact with repeatedly.
  • The conversationHistory array tracks every message exchanged so your backend can send the full context to Gemini for follow-up questions.
  • The event listener intercepts the form submit, prevents a page reload with e.preventDefault(), and calls sendMessage() with the trimmed input.

You won't see a change yet. The next chunk adds the functions this setup code relies on.

  • Add the following helper functions below the event listener in script.js:
// Creates a message bubble and adds it to the chat
function addMessage(text, role) {
  const messageDiv = document.createElement("div");
  messageDiv.classList.add("message");
  messageDiv.classList.add(role === "user" ? "user-message" : "assistant-message");

  const contentDiv = document.createElement("div");
  contentDiv.classList.add("message-content");
  contentDiv.innerHTML = formatMessage(text);

  messageDiv.appendChild(contentDiv);
  chatMessages.appendChild(messageDiv);
  chatMessages.scrollTop = chatMessages.scrollHeight;

  return contentDiv;
}

// Creates a placeholder bubble with animated "Thinking..." dots
function createStreamingMessage() {
  const messageDiv = document.createElement("div");
  messageDiv.classList.add("message", "assistant-message");

  const contentDiv = document.createElement("div");
  contentDiv.classList.add("message-content");
  contentDiv.innerHTML = '<span class="loading-dots">Thinking</span>';

  messageDiv.appendChild(contentDiv);
  chatMessages.appendChild(messageDiv);
  chatMessages.scrollTop = chatMessages.scrollHeight;

  return contentDiv;
}

// Converts markdown bold (**text**) and newlines to HTML
function formatMessage(text) {
  let formatted = text
    .replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
    .replace(/\n/g, "<br>");
  return formatted;
}

What do these helper functions do?

  • addMessage() builds a new message bubble (user or assistant) and appends it to the chat. It scrolls to the bottom so the latest message is always visible.
  • createStreamingMessage() creates a placeholder assistant bubble with animated "Thinking..." dots. The dots use the .loading-dots CSS animation you already styled.
  • formatMessage() converts Gemini's markdown bold syntax and newlines into HTML so messages render with proper formatting.

You won't see a change yet. The next chunk ties everything together.

  • Add the sendMessage() function below the helpers in script.js. This is the main function that sends user input to the backend and renders the response:
// Main function: sends user message to backend and streams the response
async function sendMessage(message) {
  // Clear input and disable form while waiting
  messageInput.value = "";
  sendButton.disabled = true;
  messageInput.disabled = true;

  // Show the user's message in the chat
  addMessage(message, "user");

  // Add to conversation history in Gemini's expected format
  conversationHistory.push({
    role: "user",
    parts: [{ text: message }],
  });

  // Show animated "Thinking..." placeholder
  const contentDiv = createStreamingMessage();
  let fullResponse = "";

  try {
    // POST to the streaming endpoint with message and history
    const response = await fetch("/api/chat/stream", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        message,
        history: conversationHistory.slice(0, -1),
      }),
    });

    // Read the SSE stream chunk by chunk
    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = "";

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      buffer += decoder.decode(value, { stream: true });
      const lines = buffer.split("\n");
      buffer = lines.pop();

      // Parse each SSE data line and append text to the bubble
      for (const line of lines) {
        if (line.startsWith("data: ")) {
          const data = line.slice(6);
          if (data === "[DONE]") break;

          try {
            const parsed = JSON.parse(data);
            if (parsed.text) {
              fullResponse += parsed.text;
              contentDiv.innerHTML = formatMessage(fullResponse);
              chatMessages.scrollTop = chatMessages.scrollHeight;
            }
          } catch (parseError) {
            // Skip malformed chunks
          }
        }
      }
    }

    // Save the assistant's response to conversation history
    conversationHistory.push({
      role: "model",
      parts: [{ text: fullResponse }],
    });
  } catch (error) {
    console.error("Error:", error);
    contentDiv.innerHTML = "Sorry, something went wrong. Please try again.";
  }

  // Re-enable the input form
  sendButton.disabled = false;
  messageInput.disabled = false;
  messageInput.focus();
}

What does sendMessage() do?

  • It disables the input form so the user can't send duplicate messages while waiting for a response.
  • It POSTs the message and conversation history to /api/chat/stream. The history excludes the current message (.slice(0, -1)) because the backend receives it separately.
  • It reads the response as a stream using a ReadableStream reader. Each chunk of text replaces the "Thinking..." placeholder with the actual response, creating a live typing effect.
  • After the stream finishes, it saves the assistant's full response to conversationHistory so future messages have full context.

What is Server-Sent Events?

Server-Sent Events (SSE) is a protocol where the server sends a stream of text updates over a single HTTP connection. Each update is a line prefixed with data: .

Your frontend reads these lines one at a time and appends each text chunk to the assistant's message bubble. This creates the smooth typing effect you see in ChatGPT and other AI chat interfaces.

  • Save script.js and refresh your browser at http://localhost:8080.
  • Type a message and click Send. Your message should appear as a blue bubble on the right side. You'll see a "Thinking..." animation appear in a gray bubble.

Why don't I get a response yet?

The frontend sends messages to /api/chat/stream, but that endpoint doesn't exist in your backend yet. Your server only has the non-streaming /api/chat endpoint.

You'll add the streaming endpoint in the next step. For now, the important thing is that the UI loads, your message renders as a blue bubble, and the "Thinking..." animation appears.

Message not appearing when you click Send?

  • Open your browser's developer console with Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows). Look for JavaScript errors that point to the problem.
  • Make sure the element IDs in your HTML (chatMessages, chatForm, messageInput, sendButton) match the IDs in your getElementById() calls exactly.
  • Confirm that script.js is in the public/ folder and your HTML loads it with <script src="script.js"></script>.

Why isn't my chat form submitting messages?

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

const chatMessages = document.getElementById("chatMessages");
const chatForm = document.getElementById("chatForm");
const messageInput = document.getElementById("messageInput");
const sendButton = document.getElementById("sendButton");

let conversationHistory = [];

chatForm.addEventListener("submit", (e) => {
  e.preventDefault();
  const message = messageInput.value.trim();
  if (message) {
    sendMessage(message);
  }
});

function addMessage(text, role) {
  const messageDiv = document.createElement("div");
  messageDiv.classList.add("message");
  messageDiv.classList.add(role === "user" ? "user-message" : "assistant-message");

  const contentDiv = document.createElement("div");
  contentDiv.classList.add("message-content");
  contentDiv.innerHTML = formatMessage(text);

  messageDiv.appendChild(contentDiv);
  chatMessages.appendChild(messageDiv);
  chatMessages.scrollTop = chatMessages.scrollHeight;

  return contentDiv;
}

function createStreamingMessage() {
  const messageDiv = document.createElement("div");
  messageDiv.classList.add("message", "assistant-message");

  const contentDiv = document.createElement("div");
  contentDiv.classList.add("message-content");
  contentDiv.innerHTML = '<span class="loading-dots">Thinking</span>';

  messageDiv.appendChild(contentDiv);
  chatMessages.appendChild(messageDiv);
  chatMessages.scrollTop = chatMessages.scrollHeight;

  return contentDiv;
}

function formatMessage(text) {
  let formatted = text
    .replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
    .replace(/\n/g, "<br>");
  return formatted;
}

async function sendMessage(message) {
  messageInput.value = "";
  sendButton.disabled = true;
  messageInput.disabled = true;

  addMessage(message, "user");

  conversationHistory.push({
    role: "user",
    parts: [{ text: message }],
  });

  const contentDiv = createStreamingMessage();
  let fullResponse = "";

  try {
    const response = await fetch("/api/chat/stream", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        message,
        history: conversationHistory.slice(0, -1),
      }),
    });

    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = "";

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      buffer += decoder.decode(value, { stream: true });
      const lines = buffer.split("\n");
      buffer = lines.pop();

      for (const line of lines) {
        if (line.startsWith("data: ")) {
          const data = line.slice(6);
          if (data === "[DONE]") break;

          try {
            const parsed = JSON.parse(data);
            if (parsed.text) {
              fullResponse += parsed.text;
              contentDiv.innerHTML = formatMessage(fullResponse);
              chatMessages.scrollTop = chatMessages.scrollHeight;
            }
          } catch (parseError) {
            // Skip malformed chunks
          }
        }
      }
    }

    conversationHistory.push({
      role: "model",
      parts: [{ text: fullResponse }],
    });
  } catch (error) {
    console.error("Error:", error);
    contentDiv.innerHTML = "Sorry, something went wrong. Please try again.";
  }

  sendButton.disabled = false;
  messageInput.disabled = false;
  messageInput.focus();
}

Your chat interface is built and styled. Next up, you'll add the streaming backend endpoint so the assistant can actually respond to your questions in real time.

Add Streaming Responses and the Election System Prompt

In the last step, you built a complete chat frontend with message bubbles, a text input, and JavaScript that sends requests to /api/chat/stream. The problem is that endpoint does not exist yet on your backend.

Right now, your /api/chat endpoint waits for the entire AI response before sending it back. In this step, you will add a streaming endpoint that sends text to the browser piece by piece as Gemini generates it. You will also expand the system prompt so the assistant gives detailed, structured answers about the election process.

In this step, get ready to:
  • Add a streaming endpoint that sends AI responses to the browser in real time.
  • Refine the system prompt to cover voter registration, the Electoral College, voting methods, and more.
  • Test multi-turn conversations in the browser to confirm the full flow works.
Add the streaming chat endpoint

Your existing /api/chat endpoint uses sendMessage(), which waits for Gemini to finish generating the entire response before returning it. That means the user stares at a "Thinking" animation until the whole answer is ready.

A streaming endpoint sends text chunks to the browser as they are generated, so words appear in real time. The protocol that makes this possible is called Server-Sent Events (SSE).

  • In server.js, find the closing }); of your existing /api/chat route (just above app.listen).
  • Add the streaming endpoint below it by copying this code:
app.post("/api/chat/stream", async (req, res) => {
  try {
    const { message, history } = req.body;

    if (!message) {
      return res.status(400).json({ error: "Message is required" });
    }

    // Set headers for Server-Sent Events
    res.writeHead(200, {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
    });

    // Create a chat session with conversation history
    const chat = ai.chats.create({
      model: "gemini-2.5-flash",
      config: {
        systemInstruction: SYSTEM_PROMPT,
      },
      history: history || [],
    });

    // Stream the response chunk by chunk
    const stream = await chat.sendMessageStream({ message });

    for await (const chunk of stream) {
      const text = chunk.text;
      if (text) {
        res.write(`data: ${JSON.stringify({ text })}\n\n`);
      }
    }

    // Signal the end of the stream
    res.write("data: [DONE]\n\n");
    res.end();
  } catch (error) {
    console.error("Stream error:", error);
    res.write(`data: ${JSON.stringify({ error: "Stream failed" })}\n\n`);
    res.end();
  }
});

What does this code do?

This endpoint works differently from your original /api/chat route. Instead of sending one big JSON response, it opens a persistent connection and writes data in small pieces.

  • res.writeHead(200, { ... }) sets the SSE headers that tell the browser to expect a stream of events, not a single response.
  • chat.sendMessageStream({ message }) returns an async iterable. Each chunk contains a piece of the AI's answer as it is generated.
  • res.write(`data: ...`) sends each chunk to the browser using the SSE format. The browser reads these events one at a time.
  • The data: [DONE] line tells the frontend that the response is complete.

Why streaming instead of waiting for the full response?

AI-generated responses can take several seconds to complete. Streaming lets the user start reading while the model is still generating. It creates a natural typing effect that feels much more responsive than a loading spinner followed by a wall of text.

  • Save server.js.
  • In your terminal, stop the running server by pressing Ctrl+C.
  • Restart it by running:
node server.js
  • Open http://localhost:8080 in your browser and type a question like How do I register to vote?.
  • You should see the assistant's response appear word by word in the chat bubble, instead of all at once.

Not seeing streaming text?

  • Make sure you saved server.js and restarted the server. Changes to Node.js files do not take effect until you restart.
  • Check that the new endpoint is pasted below the closing }); of the /api/chat route but above app.listen.
  • Look for errors in your terminal output. If you see Stream error, your GEMINI_API_KEY may be incorrect in your .env file.

Help me debug why my streaming endpoint is not working

Understand how the frontend reads the stream

You do not need to write any new frontend code for streaming. The sendMessage() function in script.js already calls /api/chat/stream and processes the response as a stream. Now that the backend endpoint exists, it all works together.

How does the frontend consume the stream?

The sendMessage() function in script.js uses the browser's built-in ReadableStream API to read SSE events as they arrive.

  • response.body.getReader() opens a reader on the response stream. It reads raw bytes as they arrive over the network.
  • A while (true) loop calls reader.read() repeatedly. Each read returns a chunk of text, which gets split into lines and parsed.
  • Every line starting with data: is parsed as JSON. The text field is appended to fullResponse and the message bubble is re-rendered with formatMessage().
  • When the stream sends data: [DONE], the loop breaks. The completed response is pushed into conversationHistory so follow-up questions have full context.
Expand the system prompt for election expertise

Streaming is working, but the assistant's knowledge is limited by the placeholder system prompt. A well-crafted system prompt is what transforms a general-purpose LLM into a focused expert on a specific topic.

  • In server.js, find the SYSTEM_PROMPT variable near the top of the file.
  • Find this line:
const SYSTEM_PROMPT = `You are an Election Process Assistant...`;
  • Replace the entire SYSTEM_PROMPT variable with this expanded version:
const SYSTEM_PROMPT = `You are an Election Process Assistant, a helpful and non-partisan guide that helps users understand how elections work in the United States. Your role is to explain the election process clearly and accurately.

You cover the following topics:
- Voter registration: eligibility requirements, how to register, deadlines, and checking registration status
- Types of elections: primary elections, caucuses, general elections, midterm elections, and local elections
- The Electoral College: how it works, how electors are chosen, the 270 threshold, and winner-take-all vs proportional systems
- Voting methods: in-person voting, mail-in/absentee voting, early voting, and provisional ballots
- Election day procedures: what to bring, polling place locations, voting hours, and what to expect
- Election timeline: key dates, certification process, inauguration, and transition of power
- Campaign process: primaries, conventions, debates, and the general election campaign

Guidelines for your responses:
- Be non-partisan and factual. Do not express opinions about candidates or parties.
- Use clear, simple language. Break down complex processes into numbered steps when appropriate.
- If a question is outside your scope (not related to the election process), politely redirect the user.
- When discussing dates or deadlines, remind users that these can vary by state and they should check their local election office.
- Use bullet points and short paragraphs for readability.
- If you are unsure about a specific detail, say so rather than guessing.`;

What makes this system prompt effective?

This prompt does three things that turn a general-purpose model into a reliable assistant.

  • It defines a clear scope by listing exact topics the assistant covers. This helps the model stay focused instead of wandering into unrelated territory.
  • It sets behavioral guidelines like staying non-partisan, using simple language, and redirecting off-topic questions. These constraints prevent the model from generating unhelpful or biased content.
  • It instructs the model to admit uncertainty rather than guessing. This is critical for a factual topic where incorrect information could mislead users.
  • Save server.js.
  • Stop the server with Ctrl+C and restart it by running:
node server.js
  • Open http://localhost:8080 in your browser (or refresh if it is already open).
  • Type What is the Electoral College? and press Enter.
  • You should see a detailed, streaming response that covers how electors are chosen, the 270 threshold, and how states allocate their votes.
  • Send a follow-up question like Has a president ever won without the popular vote? to test multi-turn conversation.
  • The assistant should reference the Electoral College context from your first question in its answer, proving that conversation history is working.

Assistant giving vague or off-topic answers?

  • Make sure you replaced the entire placeholder string, including the opening backtick and closing backtick. The prompt should start with You are an Election Process Assistant, a helpful and end with say so rather than guessing.
  • If follow-up questions do not reference earlier context, check that your frontend sends history in the request body. Open the browser's **Network** tab, click on the /api/chat/stream request, and confirm the payload includes a history array.

Help me fix my system prompt or conversation history

✔️ Awesome, I've got everything!

Double check you have saved server.js and that your server is running.

ⓧ I'd like to double check the full code

Here is the full server.js file as it should look at the end of this step.

import "dotenv/config";
import express from "express";
import { GoogleGenAI } from "@google/genai";

const app = express();
const port = parseInt(process.env.PORT) || 8080;

app.use(express.json());
app.use(express.static("public"));

const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });

const SYSTEM_PROMPT = `You are an Election Process Assistant, a helpful and non-partisan guide that helps users understand how elections work in the United States. Your role is to explain the election process clearly and accurately.

You cover the following topics:
- Voter registration: eligibility requirements, how to register, deadlines, and checking registration status
- Types of elections: primary elections, caucuses, general elections, midterm elections, and local elections
- The Electoral College: how it works, how electors are chosen, the 270 threshold, and winner-take-all vs proportional systems
- Voting methods: in-person voting, mail-in/absentee voting, early voting, and provisional ballots
- Election day procedures: what to bring, polling place locations, voting hours, and what to expect
- Election timeline: key dates, certification process, inauguration, and transition of power
- Campaign process: primaries, conventions, debates, and the general election campaign

Guidelines for your responses:
- Be non-partisan and factual. Do not express opinions about candidates or parties.
- Use clear, simple language. Break down complex processes into numbered steps when appropriate.
- If a question is outside your scope (not related to the election process), politely redirect the user.
- When discussing dates or deadlines, remind users that these can vary by state and they should check their local election office.
- Use bullet points and short paragraphs for readability.
- If you are unsure about a specific detail, say so rather than guessing.`;

app.post("/api/chat", async (req, res) => {
  try {
    const { message, history } = req.body;

    if (!message) {
      return res.status(400).json({ error: "Message is required" });
    }

    const chat = ai.chats.create({
      model: "gemini-2.5-flash",
      config: {
        systemInstruction: SYSTEM_PROMPT,
      },
      history: history || [],
    });

    const response = await chat.sendMessage({ message });

    res.json({ reply: response.text });
  } catch (error) {
    console.error("Chat error:", error);
    res.status(500).json({ error: "Failed to get response from assistant" });
  }
});

app.post("/api/chat/stream", async (req, res) => {
  try {
    const { message, history } = req.body;

    if (!message) {
      return res.status(400).json({ error: "Message is required" });
    }

    res.writeHead(200, {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
    });

    const chat = ai.chats.create({
      model: "gemini-2.5-flash",
      config: {
        systemInstruction: SYSTEM_PROMPT,
      },
      history: history || [],
    });

    const stream = await chat.sendMessageStream({ message });

    for await (const chunk of stream) {
      const text = chunk.text;
      if (text) {
        res.write(`data: ${JSON.stringify({ text })}\n\n`);
      }
    }

    res.write("data: [DONE]\n\n");
    res.end();
  } catch (error) {
    console.error("Stream error:", error);
    res.write(`data: ${JSON.stringify({ error: "Stream failed" })}\n\n`);
    res.end();
  }
});

app.listen(port, () => {
  console.log(`Election Assistant server running on port ${port}`);
});

Your election assistant now streams detailed, expert-level answers in real time. Next up, you will deploy it to Google Cloud Run so anyone can access it from a public URL.

Deploy to Google Cloud Run

Your election assistant works perfectly on your local machine. Now it is time to put it on the internet so anyone can use it.

Google Cloud Run is a serverless platform that runs your app in a container without you managing any servers. It automatically scales to zero when nobody is using it, so you only pay for actual usage. You will deploy your entire application with a single command.

In this step, get ready to:
  • Configure your Google Cloud project and enable the Cloud Run API.
  • Deploy the application to Cloud Run from your source code.
  • Test the live assistant with election process questions.
Configure your Google Cloud project

Cloud Run needs a Google Cloud project to host your application. You will point the gcloud CLI at your project and enable the Cloud Run API.

  • Make sure your terminal is still in the election-assistant folder from the previous step.
  • Set your active Google Cloud project by running this command:
gcloud config set project [[GCP_PROJECT_ID="your-project-id"]]

Where do I find my project ID?

Your project ID is shown in the project selector dropdown at the top of the Google Cloud Console. It is also in the URL bar when you have a project selected.

If you do not have a project yet, create one from the project selector by clicking New Project and giving it a name.

  • You should see Updated property [core/project]. in the terminal.
  • Enable the Cloud Run API by running:
gcloud services enable run.googleapis.com
  • You should see Operation "operations/..." finished successfully. in the terminal output.

Seeing a permission or billing error?

Make sure your Google Cloud account has billing enabled. The free tier covers this project, but billing must be active for API access.

If you see a "project not found" error, double check that you copied the project ID exactly from the Cloud Console. The project ID is case-sensitive.

Why is gcloud not recognizing my project?

Deploy the application to Cloud Run

Cloud Run can deploy directly from your source code. It automatically builds a container image using Cloud Buildpacks, so you do not need Docker installed or a Dockerfile written.

  • Copy the command below. Before running it, replace your-api-key-here with the actual Gemini API key from your .env file.
  • Cloud Run may ask to enable additional APIs (like Cloud Build or Artifact Registry) or create an Artifact Registry repository during the build. Enter y for yes when prompted.
  • Deploy your application by running this command:
gcloud run deploy election-assistant --source . --region us-central1 --allow-unauthenticated --set-env-vars GEMINI_API_KEY=your-api-key-here

What does this command do?

  • election-assistant is the name of your Cloud Run service.
  • --source . tells Cloud Run to build a container from your current folder instead of requiring a pre-built image.
  • --region us-central1 picks the data center region for your service.
  • --allow-unauthenticated makes your service publicly accessible. Anyone with the URL can use it.
  • --set-env-vars passes your Gemini API key to the Cloud Run environment so the server can use it at runtime.

Why not just use the .env file?

The .env file works great for local development, but Cloud Run runs your app inside a container in the cloud. The --set-env-vars flag injects the API key directly into the container's environment so your server can read it with process.env.GEMINI_API_KEY.

The build and deploy process takes about 2-5 minutes. You will see logs streaming in the terminal as Cloud Run builds your container image and starts the service.

  • When the deploy finishes, you should see a Service URL printed in the terminal output. It looks something like https://election-assistant-XXXX-uc.a.run.app.

Deploy failed or timed out?

Make sure you are running the command from inside the election-assistant folder. The --source . flag looks for your package.json in the current directory.

If you see a billing-related error, confirm billing is enabled on your Google Cloud project. If you see an API error, run gcloud services enable run.googleapis.com cloudbuild.googleapis.com artifactregistry.googleapis.com to enable all required APIs manually.

Help me troubleshoot my Cloud Run deployment error.

Test the live assistant

Your assistant is now live on the internet. Time to make sure everything works end-to-end.

  • Copy the Service URL from the terminal output.
  • Open the URL in your browser.
  • Type How do I register to vote? in the message input and click Send.

You should see the assistant's response stream in character by character, just like it did locally. The chat header, message bubbles, and input bar should all look the same.

  • Ask a follow-up question like What is the Electoral College? to confirm multi-turn conversation history works.
  • Try one more question like Can I vote by mail? to verify the assistant stays on topic and provides clear, structured responses.

Page loads but chat does not respond?

This usually means the GEMINI_API_KEY environment variable was not set correctly during deploy. Check that you replaced your-api-key-here with your actual API key in the deploy command.

You can update the environment variable without redeploying by running:

gcloud run services update election-assistant --region us-central1 --set-env-vars GEMINI_API_KEY=your-api-key-here

My deployed assistant loads but does not respond to messages.

Your election assistant is live and accessible to anyone with the URL. Next up, you will make it even more user-friendly by adding suggested question buttons that help people discover what they can ask.

Secret mission

Add Suggested Question Buttons

Your assistant works, but new users might not know what to ask. Add clickable suggested question buttons that appear when the chat loads and refresh after each response. Clicking a button sends that question automatically, making the assistant feel more polished and approachable.

Before you go

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses Google Cloud resources that have generous free tiers, so costs are unlikely unless you exceed them.

Cost warning

The Gemini API free tier includes free input and output tokens for Gemini 2.5 Flash with rate limits. Google Cloud Run's free tier includes 2 million requests per month and 240,000 vCPU-seconds per month.

Your Cloud Run service scales to zero when idle, so no charges accrue when nobody is using it. You are unlikely to incur costs from this project, but if you are done experimenting, deleting the service removes any risk entirely.

Resources you used:

  • Gemini API key (used via the @google/genai SDK).
  • Google Cloud Run service (election-assistant in us-central1).
  • Artifact Registry repository (cloud-run-source-deploy, auto-created by Cloud Run during deployment).
  • Local project files (server.js, public/ folder, .env, package.json).

Keep everything running

No action needed. Choose this if you are still actively building or want to keep your live assistant URL accessible.

  • Your Cloud Run service automatically scales to zero when there is no traffic. No charges accrue while idle.
  • Your Gemini API key remains active. Free tier usage continues as long as you stay within the rate limits.
  • Your local project files stay on your Desktop for future editing.

Pause - I'll come back to this later

Cloud Run services automatically scale to zero when idle, so there is no separate "pause" action needed for the cloud side. You can stop your local development server and come back any time.

  • Stop the local Node.js server by pressing Ctrl+C in your terminal.
  • Your deployed Cloud Run service remains available at its public URL but costs nothing while idle.
  • Keep your local project folder and .env file intact so you can resume development later.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes the Cloud Run service, the auto-created Artifact Registry repository, and your local files.

  • Delete the Cloud Run service by running this command in your terminal:
gcloud run services delete election-assistant --region us-central1
  • Delete the Artifact Registry repository that Cloud Run auto-created during deployment:
gcloud artifacts repositories delete cloud-run-source-deploy --location us-central1
  • Confirm the deletion when prompted by typing Y and pressing Enter for each command.
  • Delete your local project folder from your Desktop.
  • Optionally, revoke your Gemini API key from the Google AI Studio console if you no longer need it.

Mission accomplished

That's a Wrap!

Nice work! You just built and deployed a full-stack AI-powered election process assistant. From a blank project folder to a live public URL, you now know how to wire up a Gemini-powered backend, build a chat interface, and ship it to the cloud.

You've learned how to:

  • Build a Node.js/Express backend that integrates with the Gemini API using the official @google/genai SDK, with a detailed system prompt that turns a general-purpose LLM into a focused election process expert.
  • Create a responsive chat interface with real-time streaming responses using Server-Sent Events and manage multi-turn conversation history so users can ask natural follow-up questions.
  • Deploy the full application to Google Cloud Run with a single command, getting a live public URL anyone can visit.
  • Secret Mission: Added suggested question buttons to make the assistant more discoverable and user-friendly.

Ready to quiz yourself?