The context

PanelControl is the internal management panel used by the commercial team to track orders, leads, activations and monthly bonuses. All data lives in Firebase Realtime Database. The team asks the same repetitive questions every day: who sold the most this month? How many activations are missing to reach the bonus threshold? How does procedure X work?

The idea was to add a ✦ Ask AI button that opens a conversation panel — same glassmorphism style already present in the panel — responding with full awareness of the business context and the current month's live data.

The key technical point: an AI model knows nothing about your management panel. You have to build the context and pass it with every question in the system prompt. This article documents how that was done, including the API selection process and Gemini model versioning issues.

Gemini API vs Anthropic API: the choice

The first evaluation was which API to use. The two main options were: Google Gemini API (via generativelanguage.googleapis.com) and Anthropic API (via api.anthropic.com).

Gemini API (Google) Anthropic API
Free tier 1.500 req/giorno su Gemini Flash $5 di credito iniziale
Fatturazione Account Google Cloud richiesto (carta non addebitata nel free tier) Separata dall'abbonamento claude.ai
Pattern chiamata fetch su endpoint REST fetch su endpoint REST
CORS in Netlify Nessun blocco (dominio pubblico) Già in whitelist Netlify

The choice fell on Gemini for the more generous free tier for light internal use (a few dozen questions per day). An important point: Google Cloud requires a billing account even to use the free plan, but adding a card incurs no charges as long as you stay within the free tier.

The fetch call: no library needed

The Gemini API is called with a simple fetch POST. The request body contains the prompt in the contents field. The response comes back in candidates[0].content.parts[0].text.

JavaScript — chiamata base a Gemini API
async function askGemini(userMessage, systemPrompt) {
  const GEMINI_KEY = '[GEMINI_API_KEY]';
  const MODEL     = 'gemini-2.5-flash-lite';
  const ENDPOINT  = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${GEMINI_KEY}`;

  const response = await fetch(ENDPOINT, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      system_instruction: { parts: [{ text: systemPrompt }] },
      contents: [{ parts: [{ text: userMessage }] }]
    })
  });

  const data = await response.json();

  // Gestione errore modello non disponibile
  if (!response.ok) {
    const msg = data?.error?.message || 'Errore API';
    throw new Error(msg);
  }

  return data.candidates[0].content.parts[0].text;
}
⚠️

Non mettere mai la API key direttamente nel frontend di un'app pubblica — chiunque apra il sorgente la vedrebbe. Per un gestionale interno ad accesso limitato è un tradeoff accettabile. In alternativa: salvarla come variabile d'ambiente su Netlify e fare le chiamate tramite una Netlify Function proxy.

The Gemini model versioning problem

The first obstacle wasn't technical, but one of availability. Gemini deprecates models quickly for new accounts. The error sequence encountered:

  • gemini-2.0-flash"no longer available to new users"
  • gemini-2.0-flash-lite → same error
  • gemini-2.5-flash-lite → working, free tier active

The lesson: don't trust a hardcoded model name from a tutorial. Before integrating, always verify on ai.google.dev/gemini-api/docs/models which models are available for your account and plan. The API key format matters too: Gemini keys always start with AIzaSy, not other prefixes.

The UI button and glassmorphism panel

The ✦ Ask AI button is positioned above the existing chat button in the management panel, with a green/teal gradient to visually distinguish it from the chat's blue. On click it opens a side panel with the same glassmorphism aesthetic as the rest of the interface.

The panel contains: a header with icon and AI provider identifier, a message area with animated typing indicator, quick suggestion chips clickable on first launch, and an auto-resize input field with Enter to send.

HTML — struttura pannello AI
<!-- Pulsante trigger -->
<button id="aiBtn" class="fab-btn ai-fab">
  <span class="fab-label">✦ Chiedi all'AI</span>
  <span class="fab-icon">✦</span>
</button>

<!-- Pannello conversazione -->
<div id="aiPanel" class="ai-panel hidden">
  <div class="ai-panel-header">
    <span>✦ Assistente AI · [PROVIDER]</span>
    <button onclick="closeAiPanel()">✕</button>
  </div>
  <div id="aiMessages" class="ai-messages"></div>
  <div class="ai-input-row">
    <textarea id="aiInput" rows="1" placeholder="Scrivi una domanda..."></textarea>
    <button onclick="sendAiMessage()">↑</button>
  </div>
</div>

The dynamic system prompt: live data from Firebase

The most interesting part of the integration is building the system prompt. With every question, before sending the request to the API, a text block is assembled that serializes the current state of the management panel: logged-in user, current month, operator rankings with orders by category, total leads and activations, bonus thresholds.

All this data is already in memory in the management panel because Firebase loads it at startup via onValue listeners. The system prompt reads from the existing JavaScript state — no additional database calls.

JavaScript — costruzione del system prompt dinamico
function buildSystemPrompt() {
  const mese   = getCurrentMonthLabel();   // es. "Giugno 2026"
  const utente = sessionStorage.getItem('panelUser') || 'Sconosciuto';

  // Classifica operatori con totali per categoria
  const rankingText = Object.entries(state.operatori)
    .map(([nome, dati]) =>
      `${nome}: ${dati.totale} ordini (Cat-A: ${dati.catA}, Cat-B: ${dati.catB})`
    ).join('\n');

  return `Sei un assistente interno per il team commerciale di [AZIENDA].
Rispondi sempre in italiano, in modo diretto e professionale.

=== CONTESTO ATTUALE ===
Utente connesso: ${utente}
Mese di riferimento: ${mese}
Sezione aperta: ${state.sezioneAttiva}

=== OPERATORI E RISULTATI ===
${rankingText}

=== KPI MENSILI ===
Lead totali: ${state.leadTotali}
Attivazioni: ${state.attivazioni}
Annullati: ${state.annullati}
Soglia bonus: ${state.sogliaBonus} attivazioni

=== CONOSCENZA AZIENDALE ===
[Qui va la conoscenza statica — vedi sezione successiva]
`;
}
💡

La chiave è che il modello riceve ogni volta un system prompt fresco con lo snapshot aggiornato dei dati. Non c'è memoria tra una sessione e l'altra, ma all'interno della conversazione i messaggi precedenti vengono accodati nell'array contents per mantenere il filo del dialogo.

Static knowledge: the "company manual" in the prompt

In addition to live data, the system prompt includes a static knowledge block that doesn't change: products and pricing plans sold by the company, operational procedures (how to confirm a customer email, how to register an activation), team roles, glossary of internal terms.

This block is written directly as a JavaScript string in the management panel's source code. It's not read from Firebase — it's part of the source. The advantage: no additional latency. The disadvantage: updating it requires a deploy.

The right balance is putting in the static block the things that change rarely (product structure, core procedures, glossary) and leaving to Firebase data everything that changes daily (who sold what, month's leads, target reached).

JavaScript — frammento di conoscenza statica nel prompt
const STATIC_KNOWLEDGE = `
=== PRODOTTI ===
[PRODOTTO_A]: terminale POS portatile. Piani disponibili: [PIANO_BASE] (€0/mese, commissione 1,20%),
  [PIANO_PRO] (€12/mese, commissione 0,95%), [PIANO_CUSTOM] (negoziato).
[PRODOTTO_B]: terminale fisso per banco. Disponibile solo a noleggio.
[PRODOTTO_C]: conto business + carta prepagata. Piani: Freemium (gratuito),
  Smart (€9/mese), Business (€25/mese).

=== PROCEDURE ===
Conferma email cliente:
  1. Aprire il portale [PORTALE_INTERNO]
  2. Cercare il cliente per codice fiscale o ragione sociale
  3. Inviare il link di verifica dalla sezione "Comunicazioni"
  4. Attendere la conferma (solitamente entro 24h)

Registrazione attivazione:
  1. Inserire il numero pratica nel gestionale
  2. Verificare che lo stato sia "Attivo" sul portale provider
  3. Aggiornare il record nel database con data attivazione

=== GLOSSARIO ===
Lead: contatto commerciale acquisito, non ancora convertito
Attivazione: contratto firmato e servizio attivo sul portale
Annullato: pratica ritirata dal cliente o rifiutata dal provider
[TERMINE_INTERNO_1]: sistema di firma contratti digitale
[TERMINE_INTERNO_2]: piattaforma CRM per la gestione lead
`;

Multi-turn conversation: queuing messages

The Gemini API has no memory of its own. To simulate a conversation with multiple exchanges, each subsequent call must include in the contents array all previous messages — alternating role: "user" and role: "model".

JavaScript — conversazione multi-turn
let conversationHistory = []; // resettata all'apertura del pannello

async function sendAiMessage() {
  const userText = document.getElementById('aiInput').value.trim();
  if (!userText) return;

  // Aggiunge il turno utente alla storia
  conversationHistory.push({
    role: 'user',
    parts: [{ text: userText }]
  });

  const response = await fetch(ENDPOINT, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      system_instruction: { parts: [{ text: buildSystemPrompt() }] },
      contents: conversationHistory  // intera storia ad ogni chiamata
    })
  });

  const data   = await response.json();
  const aiText = data.candidates[0].content.parts[0].text;

  // Aggiunge la risposta del modello alla storia
  conversationHistory.push({
    role: 'model',
    parts: [{ text: aiText }]
  });

  renderMessage('ai', aiText);
}
💡

Attenzione alla dimensione del contesto: con conversazioni molto lunghe, l'array contents cresce e ogni chiamata diventa più pesante. Per un uso interno con domande brevi non è un problema, ma è buona pratica resettare la storia al riavvio del pannello o ogni N turni.

When this approach makes sense (and when it doesn't)

This pattern — a dynamic system prompt built client-side from Firebase data already in memory, with no dedicated backend — fits a specific context. Worth clarifying before reusing it elsewhere.

When to use it

  • Internal apps with few authenticated users (teams, company management panels), where daily question volume stays in the tens.
  • Data already available in memory client-side via Firebase listeners (onValue), avoiding extra queries just for the prompt.
  • Budget and API key risk under control: access limited to trusted people, with a spending cap set on the provider side.
  • Rapid prototyping without wanting to introduce a backend or a dedicated Netlify Function just for this.

When not to use it

  • Public or multi-tenant apps: exposing the API key client-side, even with restrictions, is an unacceptable risk on an app with untrusted external users.
  • Sensitive or regulated data (health data, third-party financial data): a server-side proxy that filters what goes into the prompt, with logging and access control, is better.
  • High volumes: beyond a few hundred requests a day, a backend with real rate limiting, response caching and centralized cost management makes more sense.

Alternatives

For those who don't want to expose the API key client-side even in an internal context, the alternative is the pattern used elsewhere on this site: a Netlify Function as a proxy to Gemini, keeping the key server-side and adding rate limiting via Firestore. This is also the correct approach for an external audience, at the cost of slightly higher latency and one more serverless function to maintain.

Common mistakes

  • Hardcoding the model name without checking current availability — Gemini models get deprecated for new accounts faster than expected.
  • Letting the message history grow indefinitely: without a periodic reset, the contents array bloats every call and needlessly increases token cost.
  • Mixing live data and static knowledge in the same block without clear separation, making the prompt hard to update as the app grows.

What we learned

  1. The system prompt is the product. Response quality depends almost entirely on how well-structured the context you pass to the model is. AI isn't magic — it responds with what you give it.
  2. Gemini models deprecate quickly. Don't hardcode the model name without verifying current availability. Keep it in an easy-to-update constant and document which model you're using and why.
  3. Live data + static knowledge = the right mix. Separating what changes every day (Firebase data) from what changes rarely (procedures, glossary) keeps the prompt maintainable over time.
  4. For internal apps, API key security is a tradeoff. The ideal solution is a Netlify Function proxy that hides the key from the frontend. For a management panel with limited access and a monthly budget set on the provider's console, the risk is acceptable.
  5. No library needed. A fetch, some JSON and a bit of DOM manipulation are all you need to integrate an AI model into an existing vanilla JavaScript application.

Frequently asked questions

Why was Gemini chosen over the Anthropic API for the AI assistant in the management panel?

For the more generous free tier, suited to light internal use of a few dozen questions per day. It's worth noting that Google Cloud still requires a billing account even for the free plan, but adding a card incurs no charges as long as you stay within the free tier limits.

How does the system prompt know the management panel's live data (rankings, KPIs, current month) without extra database calls?

All that data is already in the app's JavaScript state, since Firebase loads it at startup via onValue listeners. The system prompt simply serializes the existing state into a text block before every question, with no additional reads from Firebase.

Why did some Gemini model names like gemini-2.0-flash return a "no longer available to new users" error?

Gemini deprecates models available to new accounts quickly. The lesson is not to trust a hardcoded model name from a tutorial, but to always check on ai.google.dev/gemini-api/docs/models which models are actually available for your account and plan before integrating.