Skip to content

Step-by-Step Guide to Building an AI-Powered Chatbot with Gemini

Core Concept LearningAugust 3, 20265 min read

The gap between a chatbot demo and a chatbot in production is not the model — it is everything wrapped around the model call. A demo sends one message and prints one reply. A production bot has to remember the last ten turns, decide when to call a real API instead of guessing an answer, stream tokens so the UI doesn't sit frozen for four seconds, and still respond sensibly when the model call times out mid-stream.

This guide builds one concrete bot — an order-status assistant that answers "where is order A1092" by calling a real lookup function instead of hallucinating a tracking number — using the Gemini API. Each section adds one production concern on top of the last: first a single-turn call, then conversation history, then a function-calling tool, then the streaming and failure-handling that make the difference between a toy and something you'd point real users at. For the retrieval-augmented variant of this pattern, see ChatGPT-style RAG with LangChain.

Layers of a Gemini-powered chatbot
Layers of a Gemini-powered chatbot

The smallest working call

Before adding history, tools, or streaming, get one request working end to end: a system instruction that defines the bot's persona and boundaries, one user message, one generated reply. This is the skeleton every later section extends — get the request/response shape right here and the rest is additive.

The system instruction matters more than most demos suggest. Without an explicit boundary ("you do not have access to order data unless a tool result is provided"), the model will happily invent a plausible-looking tracking number rather than say it doesn't know — a hallucination that is worse than silence because it looks authoritative.

Quick reference

  • Set the system instruction on every call, not just the first — some SDKs default to per-request state.
  • Treat "the model refuses to guess" as the correct behavior to test for, not a bug to prompt away.
  • gemini-2.5-flash favors latency; use a Pro-tier model only where the answer quality gap is measured, not assumed.
  • Log the raw request/response pair in development — most "the model is wrong" bugs are actually a missing or malformed system instruction.
Minimal call
1import { GoogleGenAI } from "@google/genai";2 3const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });4 5async function ask(message: string) {6  const response = await ai.models.generateContent({7    model: "gemini-2.5-flash",8    contents: message,9  });10  return response.text;11}
With a system instruction that sets a hard boundary
1const SYSTEM_INSTRUCTION = `You are the support assistant for Acme Orders.2You do not know order status, tracking numbers, or refund amounts unless3a tool result provides them in this turn. If you have no tool result,4say you need to look it up — never invent a status or number.`;5 6async function ask(message: string) {7  const response = await ai.models.generateContent({8    model: "gemini-2.5-flash",9    config: { systemInstruction: SYSTEM_INSTRUCTION },10    contents: message,11  });12  return response.text;13}14 15// Expected: ask("where is order A1092?") replies that it needs to check,16// not a made-up tracking number.17// Break it: remove SYSTEM_INSTRUCTION and re-run the same question —18// watch the model invent a plausible-sounding but fake status.

Remember this

A chatbot without an explicit "you don't know unless a tool told you" boundary will hallucinate plausible answers instead of admitting uncertainty — that boundary is a prompt-engineering decision, not a model capability.

Conversation state and the session boundary

A chatbot needs memory across turns, but that memory has an ownership question that demos skip: who stores it, and what happens when the process restarts? For a single-user CLI demo, an in-memory array is fine. For a multi-user web app, conversation history belongs in a session store keyed by user ID, with a bounded window — sending the entire history on every call both costs tokens and eventually exceeds the context window.

Gemini's SDK models a chat session that appends turns for you, but that convenience hides the ownership question rather than answering it: if the process restarts, an in-memory chat session's history is gone. Decide explicitly whether that's acceptable (support bot, low stakes) or not (an agent mid-way through a multi-step task).

Quick reference

  • Store conversation history server-side keyed by session/user ID — never trust a client to send an accurate history back.
  • Bound the window (e.g. last 10 turns or last N tokens) and summarize older turns instead of growing unbounded.
  • Decide the restart contract up front: acceptable data loss vs. a persisted store (Redis, Postgres) that survives a redeploy.
  • A stale session (user closed the tab three days ago) should expire, not silently resume with context the user has forgotten giving.
  • For the token-budget side of this same window-and-summarize trade-off, see Context window management for LLM apps.

Remember this

Conversation memory is a state-ownership decision, not a convenience API — decide who stores history, how far back it extends, and what a process restart is allowed to lose before writing the first line of the chat loop.

Function calling: answering with real data

The order-status bot only becomes trustworthy once it can call a real function instead of describing what an order status API might return. Gemini's function calling works by declaring a tool schema; the model emits a functionCall part instead of text when it decides a tool is needed, your code executes the real function, and you send the result back for the model to compose into a final reply.

The failure case to design for up front is a tool that times out or throws. If the handler doesn't catch that, the chat loop hangs waiting for a functionResponse that never arrives — from the user's perspective, indistinguishable from the app being broken.

One user message through the chatbot
One user message through the chatbot

Quick reference

  • Always send a functionResponse back, even on failure — an explicit error object, never a silent drop.
  • Bound every tool call with a timeout; an unbounded external call becomes an unbounded chat response time.
  • Validate functionCall arguments before calling your API — the model can pass a malformed order ID.
  • Log every tool call and its result for debugging — most "the bot answered wrong" reports are actually a tool that returned bad data.
Tool declaration + call, no failure path
1const orderStatusTool = {2  name: "order_status",3  description: "Look up the current status of an order by ID.",4  parameters: {5    type: "object",6    properties: { orderId: { type: "string" } },7    required: ["orderId"],8  },9};10 11const response = await ai.models.generateContent({12  model: "gemini-2.5-flash",13  config: { tools: [{ functionDeclarations: [orderStatusTool] }] },14  contents: "where is order A1092?",15});16// If response has a functionCall, the caller must run it —17// this snippet never does, so the model's request goes nowhere.
Execute the tool, handle timeout, send result back
1async function lookupOrderStatus(orderId: string, timeoutMs = 3000) {2  const controller = new AbortController();3  const timer = setTimeout(() => controller.abort(), timeoutMs);4  try {5    const res = await fetch(`https://api.acme.dev/orders/${orderId}`, {6      signal: controller.signal,7    });8    if (!res.ok) throw new Error(`order_lookup_failed:${res.status}`);9    return await res.json();10  } finally {11    clearTimeout(timer);12  }13}14 15async function handleTurn(message: string) {16  const first = await ai.models.generateContent({17    model: "gemini-2.5-flash",18    config: { tools: [{ functionDeclarations: [orderStatusTool] }] },19    contents: message,20  });21 22  const call = first.functionCalls?.[0];23  if (!call) return first.text;24 25  let result;26  try {27    result = await lookupOrderStatus(call.args.orderId as string);28  } catch {29    // Expected: on timeout/error, tell the model explicitly so it can30    // apologize instead of the stream hanging with no response.31    result = { error: "lookup_unavailable" };32  }33 34  const followUp = await ai.models.generateContent({35    model: "gemini-2.5-flash",36    contents: [37      { role: "user", parts: [{ text: message }] },38      { role: "model", parts: [{ functionCall: call }] },39      { role: "user", parts: [{ functionResponse: { name: call.name, response: result } }] },40    ],41  });42  return followUp.text;43}44// Break it: point the fetch URL at an unreachable host — expected: the45// bot replies with an apology, not a hang or a fabricated status.

Remember this

An unhandled tool timeout doesn't produce a wrong answer — it produces a hung stream, which is worse; every tool call needs a bounded timeout and an explicit error path back to the model.

Streaming and the user-facing failure path

Streaming tokens as they arrive is what makes a four-second response feel instant rather than frozen — the user sees words appearing immediately instead of staring at a spinner. Gemini's streaming API yields chunks you forward to the client over Server-Sent Events or a WebSocket; the harder part is what happens when the stream drops mid-response, which happens in production far more often than in a local demo.

A dropped stream should not leave the user with half a sentence and no error. Buffer enough state to detect a stall (no chunk for N seconds) and send an explicit "connection lost, retry" signal rather than letting the UI hang on a half-finished word.

order_status(order_id="A1092") tool-call round trip
order_status(order_id="A1092") tool-call round trip

Quick reference

  • Stream via SSE for simplicity; use a WebSocket only if you need bidirectional messages mid-response.
  • Detect stalls with a chunk-arrival timeout (e.g. 8s) — a silent stream is not the same as a finished one.
  • On stall or error mid-stream, send an explicit terminal event the client can render as "try again," not a dangling ellipsis.
  • Test the stall path deliberately: kill the network mid-response in development and confirm the UI shows a retry, not a hang.

Remember this

A stream that silently stops looks identical to a frozen app from the user's side — an explicit stall timeout and a terminal error event are what turn a network blip into a visible, recoverable "try again."

Key takeaway

Build the order-status bot end to end: single-turn call, then bounded conversation history, then the order_status function-calling tool against a stub API, then streaming with a stall detector. Verify success by asking "where is order A1092?" and confirming the reply cites the tool's returned status, not an invented one.

Then break it twice, deliberately: point the tool's fetch URL at an unreachable host and confirm the bot apologizes instead of hanging or fabricating a status; then kill the SSE connection mid-stream and confirm the client shows a retry prompt within your stall timeout instead of a frozen half-sentence. Pass criterion: both failures produce a visible, user-facing message within the timeout window you set, with no silent hang and no invented data.

Share:

Related Articles

Connecting AI agents (such as ChatGPT, Claude Code, and Gemini CLI) to external tools, enterprise microservices, and dat

Read

Gemini CLI (@google/gemini-cli) is an open-source terminal AI agent that brings Google's Gemini models directly into you

Read

While built-in tools (file editing, shell execution, web search) handle standard development workflows, engineering team

Read

Keep learning

Follow a structured path or browse all courses to go deeper.