I integrated an AI chat panel directly into PanelControl, the internal commercial team management tool I maintain. No external libraries, no framework: a fetch call to the Gemini API with a system prompt built dynamically from live Firebase data — orders, operators, leads, bonuses — plus a static company knowledge base hardcoded in the prompt itself. All in vanilla JavaScript.
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 | Anthropic API | |
|---|---|---|
| Free tier | Yes (generous) | No |
| Billing required | Yes (card on file, not charged) | Yes |
| REST call |
fetch POST |
fetch POST |
| Response path | candidates[0].content.parts[0].text |
content[0].text |
The choice fell on Gemini for the more generous free tier for light internal use (a few dozen questions per day). An important note: 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.
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();
// Handle model unavailability error
if (!response.ok) {
const msg = data?.error?.message || 'API Error';
throw new Error(msg);
}
return data.candidates[0].content.parts[0].text;
}
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 I 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.
<!-- Trigger button -->
<button id="aiBtn" class="fab-btn ai-fab">
<span class="fab-label">✦ Ask AI</span>
<span class="fab-icon">✦</span>
</button>
<!-- Conversation panel -->
<div id="aiPanel" class="ai-panel hidden">
<div class="ai-panel-header">
<span>✦ AI Assistant · [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="Ask a question..."></textarea>
<button onclick="sendAiMessage()">↑</button>
</div>
</div>
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.
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.
function buildSystemPrompt() {
const mese = getCurrentMonthLabel(); // e.g. "June 2026"
const utente = sessionStorage.getItem('panelUser') || 'Unknown';
// Operator ranking with totals per category
const rankingText = Object.entries(state.operatori)
.map(([nome, dati]) =>
`${nome}: ${dati.totale} orders (Cat-A: ${dati.catA}, Cat-B: ${dati.catB})`
).join('\n');
return `You are an internal assistant for the commercial team of [COMPANY].
Always respond in a direct and professional manner.
=== CURRENT CONTEXT ===
Logged-in user: ${utente}
Reference month: ${mese}
Open section: ${state.sezioneAttiva}
=== OPERATORS AND RESULTS ===
${rankingText}
=== MONTHLY KPIs ===
Total leads: ${state.leadTotali}
Activations: ${state.attivazioni}
Cancelled: ${state.annullati}
Bonus threshold: ${state.sogliaBonus} activations
=== COMPANY KNOWLEDGE ===
[Static knowledge goes here — see next section]
`;
}
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, operational procedures, team roles, glossary of internal terms.
const STATIC_KNOWLEDGE = `
=== PRODUCTS ===
[PRODUCT_A]: portable POS terminal. Available plans: [BASIC_PLAN] (€0/mo, 1.20% fee),
[PRO_PLAN] (€12/mo, 0.95% fee), [CUSTOM_PLAN] (negotiated).
[PRODUCT_B]: countertop fixed terminal. Available as rental only.
[PRODUCT_C]: business account + prepaid card. Plans: Freemium (free),
Smart (€9/mo), Business (€25/mo).
=== PROCEDURES ===
Customer email confirmation:
1. Open the [INTERNAL_PORTAL]
2. Search the customer by tax ID or company name
3. Send the verification link from the "Communications" section
4. Wait for confirmation (usually within 24h)
Activation registration:
1. Enter the case number in the management panel
2. Verify status is "Active" on the provider portal
3. Update the database record with the activation date
=== GLOSSARY ===
Lead: acquired commercial contact, not yet converted
Activation: signed contract with service active on provider portal
Cancelled: case withdrawn by client or rejected by provider
[INTERNAL_TERM_1]: digital contract signing system
[INTERNAL_TERM_2]: CRM platform for lead management
`;
This block is written directly as a JavaScript string in the 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).
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".
let conversationHistory = []; // reset on panel open
async function sendAiMessage() {
const userText = document.getElementById('aiInput').value.trim();
if (!userText) return;
// Add the user turn to history
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 // full history on every call
})
});
const data = await response.json();
const aiText = data.candidates[0].content.parts[0].text;
// Add the model response to history
conversationHistory.push({
role: 'model',
parts: [{ text: aiText }]
});
renderMessage('ai', aiText);
}
Note that buildSystemPrompt() is called fresh on every API request — so the live Firebase data (operators, KPIs) is always up to date, even mid-conversation.
What we learned
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.
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.
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.
For internal apps, API key security is a tradeoff. The ideal solution is a serverless proxy (e.g. Netlify Function) that hides the key from the frontend. For a management panel with restricted access and a monthly budget cap set on the provider's console, the risk is acceptable.
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.
Full session on my blog: roversia.it
Top comments (2)
This is a cool use case! I'm curious
Thanks! Curious about anything specific? Happy to go deeper on the architecture, the prompt design, or how the live Firebase state gets serialized at call time.