📘 Lesson 2 — Build an API + Drive + Gemini Web App (No Indexing, Just Context)
Github https://github.com/lsvekis/Google-Apps-Script-APIs-and-Gemini
In Lesson 1, you learned how to:
- Call public APIs with UrlFetchApp
- Parse JSON in Apps Script
- Use Script Properties (DATA_API_URL) to swap APIs without changing code
Now in Lesson 2, we’ll go one level up:
You’ll build a chat-style web app where each question is answered using:
- Data from a live API (e.g., cat facts, weather, activities)
- Text from files in a Google Drive folder
- The Gemini model to combine everything into one answer
No embeddings, no indexing — just direct context:
- “Here’s the API data”
- “Here’s what’s in the folder”
- “Here’s the user’s question”
- Gemini: “OK, let me respond.”
🧩 What You’ll Build
By the end of this lesson, you’ll have a web app that:
- Shows a chat UI (user + assistant bubbles).
- When you send a message:
- Reads all text from a specific Drive folder (Docs + text files).
- Fetches JSON data from a public API (DATA_API_URL).
- Builds a single prompt with:
- Your question
- The API JSON
- The folder content
- Your question
- Sends that prompt to Gemini.
- Returns a combined answer in the chat UI.
- Reads all text from a specific Drive folder (Docs + text files).
🔧 Step 1 – Project Setup
Create a new Apps Script project.
You’ll need:
- A Gemini API key
- A Drive folder with some content:
- One or more Google Docs
- Optional .txt files
- One or more Google Docs
- A public JSON API URL, e.g.:
- https://catfact.ninja/fact
- https://www.boredapi.com/api/activity
- An Open-Meteo weather URL
- https://catfact.ninja/fact
1.1 Set Script Properties
In Apps Script:
- Click ⚙ Project Settings
- Under Script properties → Add script property add:
- GEMINI_API_KEY → your Gemini API key
- DATA_API_URL → your chosen API URL
Example values:
- GEMINI_API_KEY = AIza… (whatever your key is)
- DATA_API_URL = https://catfact.ninja/fact
1.2 Get Your Folder ID
- Open your Drive folder in the browser
- The URL will look like:
Copy the long ID part (1AbCdEfGhIjKlMnOpQrStUvWxYz).
We’ll plug that into the code as DEFAULT_FOLDER_ID.
🧠 Step 2 – Backend Logic (Code.gs)
Create a file named Code.gs and paste this full script.
✏️ Don’t forget to replace PASTE_YOUR_FOLDER_ID_HERE with your real folder ID.
/**
* Lesson 2 — Combine API data + Drive folder content in a Gemini request
*
* What this file includes:
* – doGet() / chatWithFolderAndApi() → Web app entrypoints
* – fetchDataFromApi_() → API helper (from Lesson 1)
* – getFolderText_() → Read text from a Drive folder
* – callGeminiWithContext_() → Send question + folder + API data to Gemini
* – answerWithFolderAndApi() → Main function used by the web app
*/
/***************************************
* CONFIG
***************************************/
// Replace with your own folder ID before use
const DEFAULT_FOLDER_ID = ‘PASTE_YOUR_FOLDER_ID_HERE’;
// Gemini model + output config
const GEMINI_MODEL = ‘gemini-2.5-flash’;
const MAX_OUTPUT_TOKENS = 768;
/***************************************
* WEB APP ENTRYPOINTS
***************************************/
/**
* Serves the HTML UI for the web app.
*/
function doGet(e) {
return HtmlService.createHtmlOutputFromFile(‘Index’)
.setTitle(‘API + Drive + Gemini Assistant’);
}
/**
* Called from the web UI.
* Wraps answerWithFolderAndApi() and returns an object.
*/
function chatWithFolderAndApi(question) {
const answer = answerWithFolderAndApi(question);
return { answer: answer || ” };
}
/***************************************
* MAIN ENTRY — LESSON 2 FUNCTION
***************************************/
/**
* Main function for Lesson 2.
* Call this with a question. It will:
* – Get data from an API
* – Read text from a Drive folder
* – Send both, plus your question, to Gemini
* – Return Gemini’s answer.
*
* Example:
* answerWithFolderAndApi(
* “Summarize what the docs say and relate it to the API data.”
* );
*/
function answerWithFolderAndApi(question) {
const q = (question || ”).trim();
if (!q) {
throw new Error(‘Question is empty.’);
}
// 1) Fetch data from the configured API (Lesson 1 helper)
const apiData = fetchDataFromApi_();
// 2) Read text content from the Drive folder
const folderText = getFolderText_(DEFAULT_FOLDER_ID);
// 3) Call Gemini with all context
const answer = callGeminiWithContext_(q, apiData, folderText);
return answer;
}
/**
* Quick test function you can run from the editor.
*/
function testAnswerWithFolderAndApi() {
const question =
‘Give me a short summary that connects the folder docs with the API data.’;
const result = answerWithFolderAndApi(question);
Logger.log(‘AI answer:\n’ + result);
}
/***************************************
* PART 1 — API HELPER (FROM LESSON 1)
***************************************/
/**
* Fetch data from the API specified in
* Script Properties → DATA_API_URL.
*
* You can set DATA_API_URL to, for example:
* – https://catfact.ninja/fact
* – https://www.boredapi.com/api/activity
* – An Open-Meteo weather URL
*
* If you use an API that requires headers (for example,
* https://icanhazdadjoke.com/ with Accept: application/json),
* you can adapt this helper accordingly.
*/
function fetchDataFromApi_() {
const props = PropertiesService.getScriptProperties();
const dataApiUrl = props.getProperty(‘DATA_API_URL’);
if (!dataApiUrl) {
throw new Error(‘DATA_API_URL is not set in Script properties.’);
}
const res = UrlFetchApp.fetch(dataApiUrl, { muteHttpExceptions: true });
const code = res.getResponseCode();
const body = res.getContentText();
if (code < 200 || code >= 300) {
return {
source: ‘error’,
status: code,
body
};
}
try {
return JSON.parse(body);
} catch (e) {
return {
source: ‘error’,
status: code,
body,
parseError: String(e)
};
}
}
/***************************************
* PART 2 — READ DRIVE FOLDER CONTENT
***************************************/
/**
* Read text from all supported files in a Drive folder.
* Supports:
* – Google Docs
* – Plain text files
*
* For Lesson 2, we keep it simple:
* – Concatenate all text into a single long string.
* – If it’s very long, we truncate to avoid hitting token limits.
*/
function getFolderText_(folderId) {
if (!folderId) {
throw new Error(‘Folder ID is missing.’);
}
const folder = DriveApp.getFolderById(folderId);
const files = folder.getFiles();
let combined = ”;
while (files.hasNext()) {
const file = files.next();
const mime = file.getMimeType();
let text = ”;
if (mime === MimeType.GOOGLE_DOCS) {
text = DocumentApp.openById(file.getId()).getBody().getText();
} else if (mime === MimeType.PLAIN_TEXT) {
text = file.getBlob().getDataAsString();
} else {
// Skip unsupported formats for now (PDF, Sheets, etc.)
continue;
}
text = (text || ”).trim();
if (!text) continue;
combined += ‘\n\n=== FILE: ‘ + file.getName() + ‘ ===\n’ + text;
}
// Optional: truncate very long content to avoid huge prompts
const MAX_CHARS = 6000;
if (combined.length > MAX_CHARS) {
combined =
combined.slice(0, MAX_CHARS) +
‘\n\n[… truncated folder content for prompt length …]’;
}
if (!combined) {
combined = ‘[No readable text files found in the folder.]’;
}
return combined;
}
/***************************************
* PART 3 — GEMINI REQUEST WITH CONTEXT
***************************************/
/**
* Build a prompt that includes:
* – The user’s question
* – Raw JSON data from the API
* – Text from the Drive folder
*
* Then send it to Gemini and return the answer text.
*/
function callGeminiWithContext_(question, apiData, folderText) {
const prompt =
‘You are an AI assistant. You will receive three things:\n’ +
‘1) A user question\n’ +
‘2) Raw JSON data from an API\n’ +
‘3) Text content from a Google Drive folder\n\n’ +
‘Your job is to answer the user\’s question in a clear, helpful way, ‘ +
‘using both the API data and the folder content whenever they are relevant.\n’ +
‘If some information is missing or unclear, mention that politely.\n\n’ +
‘— USER QUESTION —\n’ +
question +
‘\n\n’ +
‘— API DATA (JSON) —\n’ +
JSON.stringify(apiData, null, 2) +
‘\n\n’ +
‘— FOLDER CONTENT —\n’ +
folderText +
‘\n\n’ +
‘Now write your answer below:’;
return callGeminiText_(prompt);
}
/**
* Core Gemini text call using the REST API.
*/
function callGeminiText_(prompt) {
const apiKey = getGeminiApiKey_();
const url =
‘https://generativelanguage.googleapis.com/v1beta/models/’ +
GEMINI_MODEL +
‘:generateContent?key=’ +
apiKey;
const payload = {
contents: [{ role: ‘user’, parts: [{ text: prompt }] }],
generationConfig: {
temperature: 0.3,
maxOutputTokens: MAX_OUTPUT_TOKENS
}
};
const options = {
method: ‘post’,
contentType: ‘application/json’,
muteHttpExceptions: true,
payload: JSON.stringify(payload)
};
const res = UrlFetchApp.fetch(url, options);
const data = JSON.parse(res.getContentText());
const text =
data &&
data.candidates &&
data.candidates[0] &&
data.candidates[0].content &&
data.candidates[0].content.parts &&
data.candidates[0].content.parts[0].text;
return text || ‘No response from Gemini. Raw: ‘ + res.getContentText();
}
/***************************************
* UTIL — GEMINI API KEY
***************************************/
function getGeminiApiKey_() {
const key = PropertiesService.getScriptProperties().getProperty(
‘GEMINI_API_KEY’
);
if (!key) {
throw new Error(‘Missing GEMINI_API_KEY in Script Properties.’);
}
return key;
}
/***************************************
* OPTIONAL — LESSON 1 TEST HELPERS
***************************************/
/**
* Uses DATA_API_URL from Script Properties and logs the full JSON.
*/
function testDataApiProperty() {
const data = fetchDataFromApi_();
Logger.log(JSON.stringify(data, null, 2));
}
/**
* Weather example (if DATA_API_URL is an Open-Meteo URL).
*/
function testWeather() {
const data = fetchDataFromApi_();
Logger.log(JSON.stringify(data, null, 2));
const cw = data.current_weather;
if (cw) {
Logger.log(‘Temperature: ‘ + cw.temperature + ‘°C’);
Logger.log(‘Wind speed: ‘ + cw.windspeed + ‘ km/h’);
}
}
/**
* Cat fact example (if DATA_API_URL is https://catfact.ninja/fact).
*/
function testCatFact() {
const data = fetchDataFromApi_();
Logger.log(‘Cat fact: ‘ + data.fact);
}
/**
* Joke example — if you want to use https://icanhazdadjoke.com/,
* update fetchDataFromApi_() to send the proper Accept header.
*/
function testJoke() {
const data = fetchDataFromApi_();
Logger.log(‘Joke (or raw data): ‘ + JSON.stringify(data));
}
💬 Step 3 – Chat UI (Index.html)
Now, let’s add the web UI.
In your Apps Script project:
- File → New → HTML file
- Name it: Index
- Paste this:
<!DOCTYPE html>
<html>
<head>
<base target=”_top” />
<meta charset=”UTF-8″ />
<style>
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, ‘Segoe UI’,
sans-serif;
margin: 0;
padding: 16px;
background: #020617;
color: #e5e7eb;
}
.app {
max-width: 900px;
margin: 0 auto;
background: #020617;
border-radius: 16px;
padding: 16px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6);
border: 1px solid #1f2937;
display: flex;
flex-direction: column;
height: calc(100vh – 32px);
box-sizing: border-box;
}
h1 {
font-size: 1.5rem;
margin-top: 0;
margin-bottom: 0.4rem;
}
.subtitle {
font-size: 0.9rem;
color: #9ca3af;
margin-bottom: 0.75rem;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 8px;
border-radius: 8px;
background: #020617;
font-size: 0.9rem;
border: 1px solid #1f2937;
margin-bottom: 8px;
}
.message {
max-width: 80%;
margin-bottom: 8px;
padding: 8px 10px;
border-radius: 12px;
line-height: 1.4;
white-space: pre-wrap;
}
.message.user {
margin-left: auto;
background: #0b1120;
border: 1px solid #1d4ed8;
text-align: right;
}
.message.bot {
margin-right: auto;
background: #020617;
border: 1px solid #334155;
}
.message.system {
margin: 4px auto 10px auto;
background: #020617;
border: 1px dashed #374151;
font-size: 0.8rem;
color: #9ca3af;
text-align: center;
}
.bubble-label {
display: block;
font-size: 0.7rem;
color: #9ca3af;
margin-bottom: 2px;
}
.input-area {
border-top: 1px solid #1f2937;
padding-top: 8px;
}
.input-row {
display: flex;
gap: 8px;
}
textarea {
flex: 1;
resize: none;
min-height: 46px;
max-height: 110px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid #374151;
background: #020617;
color: #e5e7eb;
font-family: inherit;
font-size: 0.9rem;
}
button {
padding: 8px 12px;
border-radius: 8px;
border: none;
background: #3b82f6;
color: white;
font-weight: 600;
cursor: pointer;
font-size: 0.9rem;
white-space: nowrap;
}
button:disabled {
opacity: 0.6;
cursor: default;
}
.status {
font-size: 0.8rem;
color: #9ca3af;
margin-top: 4px;
min-height: 1.2em;
}
.status.error {
color: #f97373;
}
.typing {
display: inline-flex;
align-items: center;
gap: 4px;
}
.typing-dot {
width: 4px;
height: 4px;
border-radius: 999px;
background: #9ca3af;
opacity: 0.7;
animation: pulse 1s infinite ease-in-out;
}
.typing-dot:nth-child(2) {
animation-delay: 0.15s;
}
.typing-dot:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes pulse {
0%, 100% { transform: translateY(0); opacity: 0.3; }
50% { transform: translateY(-2px); opacity: 1; }
}
</style>
</head>
<body>
<div class=”app”>
<header>
<h1>API + Drive + Gemini Assistant</h1>
<div class=”subtitle”>
Ask a question and the assistant will use both your configured API
(DATA_API_URL) and the text in your Drive folder to answer.
</div>
</header>
<main>
<div id=”messages” class=”messages”></div>
<div class=”input-area”>
<div class=”input-row”>
<textarea
id=”userInput”
placeholder=”Ask a question… (Enter to send, Shift+Enter for a new line)”
></textarea>
<button id=”sendBtn” onclick=”onSend()”>Send</button>
</div>
<div id=”status” class=”status”></div>
</div>
</main>
</div>
<script>
const messagesEl = document.getElementById(‘messages’);
const userInputEl = document.getElementById(‘userInput’);
const sendBtn = document.getElementById(‘sendBtn’);
const statusEl = document.getElementById(‘status’);
let typingMessageEl = null;
function addMessage(text, sender) {
const wrapper = document.createElement(‘div’);
wrapper.className = ‘message ‘ + sender;
if (sender === ‘user’ || sender === ‘bot’) {
const label = document.createElement(‘span’);
label.className = ‘bubble-label’;
label.textContent = sender === ‘user’ ? ‘You’ : ‘Assistant’;
wrapper.appendChild(label);
}
const content = document.createElement(‘div’);
content.textContent = text;
wrapper.appendChild(content);
messagesEl.appendChild(wrapper);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function addSystemMessage(text) {
const wrapper = document.createElement(‘div’);
wrapper.className = ‘message system’;
wrapper.textContent = text;
messagesEl.appendChild(wrapper);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function showTyping() {
if (typingMessageEl) return;
typingMessageEl = document.createElement(‘div’);
typingMessageEl.className = ‘message bot’;
const label = document.createElement(‘span’);
label.className = ‘bubble-label’;
label.textContent = ‘Assistant’;
typingMessageEl.appendChild(label);
const typing = document.createElement(‘div’);
typing.className = ‘typing’;
typing.innerHTML =
‘<span class=”typing-dot”></span>’ +
‘<span class=”typing-dot”></span>’ +
‘<span class=”typing-dot”></span>’;
typingMessageEl.appendChild(typing);
messagesEl.appendChild(typingMessageEl);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function hideTyping() {
if (typingMessageEl && typingMessageEl.parentNode) {
typingMessageEl.parentNode.removeChild(typingMessageEl);
}
typingMessageEl = null;
}
function setLoading(isLoading) {
sendBtn.disabled = isLoading;
sendBtn.textContent = isLoading ? ‘Thinking…’ : ‘Send’;
}
function showStatus(msg, isError) {
statusEl.textContent = msg || ”;
statusEl.classList.toggle(‘error’, !!isError);
}
function autoGrowTextarea() {
userInputEl.style.height = ‘auto’;
userInputEl.style.height = userInputEl.scrollHeight + ‘px’;
}
function onSend() {
const text = userInputEl.value.trim();
if (!text) {
showStatus(‘Please enter a question.’, true);
return;
}
showStatus(”, false);
addMessage(text, ‘user’);
userInputEl.value = ”;
autoGrowTextarea();
setLoading(true);
showTyping();
google.script.run
.withSuccessHandler(function (res) {
setLoading(false);
hideTyping();
if (res && res.answer) {
addMessage(res.answer, ‘bot’);
} else {
addMessage(
‘I could not generate an answer from the API and folder content.’,
‘bot’
);
}
})
.withFailureHandler(function (err) {
setLoading(false);
hideTyping();
showStatus(err.message || String(err), true);
addMessage(‘Sorry, something went wrong on the server.’, ‘bot’);
})
.chatWithFolderAndApi(text);
}
document.addEventListener(‘DOMContentLoaded’, function () {
addSystemMessage(
‘This assistant uses both your configured API (via DATA_API_URL) and ‘ +
‘the text from your Drive folder (DEFAULT_FOLDER_ID in Code.gs). ‘ +
‘Ask a question to see how it combines both sources.’
);
userInputEl.addEventListener(‘input’, autoGrowTextarea);
userInputEl.addEventListener(‘keydown’, function (e) {
if (e.key === ‘Enter’ && !e.shiftKey) {
e.preventDefault();
onSend();
}
});
autoGrowTextarea();
});
</script>
</body>
</html>
🚀 Step 4 – Deploy the Web App
- In Apps Script, click Deploy → New deployment
- Choose Web app
- Set:
- Execute as: Me
- Who has access: Anyone with the link (or your choice)
- Execute as: Me
- Click Deploy and copy the URL
Open the URL in your browser:
- Type a question
- The app will:
- Fetch API JSON from DATA_API_URL
- Read Docs/text from DEFAULT_FOLDER_ID
- Ask Gemini to combine both and answer
- Fetch API JSON from DATA_API_URL
🧪 Practice Ideas for Learners
You can add these as exercises at the end of Lesson 2:
- Change the API
- Switch DATA_API_URL between:
- Cat facts (https://catfact.ninja/fact)
- Bored API (https://www.boredapi.com/api/activity)
- Weather (Open-Meteo)
- Cat facts (https://catfact.ninja/fact)
- Ask: “Summarize the folder and weave in today’s weather/cat fact/activity.”
- Switch DATA_API_URL between:
- Filter Folder Files
- Update getFolderText_() to only include files whose name contains “Guide” or “Notes”.
- Update getFolderText_() to only include files whose name contains “Guide” or “Notes”.
- Show Debug Information
- Log apiData and folderText before the Gemini call.
- Log apiData and folderText before the Gemini call.
Add a dev-only toggle to see the raw JSON/folder text.
