Build an API + Drive + Gemini Web App

📘 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:

  1. Shows a chat UI (user + assistant bubbles).
  2. 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
    • Sends that prompt to Gemini.
    • Returns a combined answer in the chat UI.

🔧 Step 1 – Project Setup

Create a new Apps Script project.

You’ll need:

  1. A Gemini API key
  2. A Drive folder with some content:
    • One or more Google Docs
    • Optional .txt files
  3. A public JSON API URL, e.g.:
    • https://catfact.ninja/fact
    • https://www.boredapi.com/api/activity
    • An Open-Meteo weather URL

1.1 Set Script Properties

In Apps Script:

  1. Click ⚙ Project Settings
  2. 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:
https://drive.google.com/drive/folders/1AbCdEfGhIjKlMnOpQrStUvWxYz

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

  1. In Apps Script, click Deploy → New deployment
  2. Choose Web app
  3. Set:
    • Execute as: Me
    • Who has access: Anyone with the link (or your choice)
  4. 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

🧪 Practice Ideas for Learners

You can add these as exercises at the end of Lesson 2:

  1. 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)
    • Ask: “Summarize the folder and weave in today’s weather/cat fact/activity.”
  2. Filter Folder Files
    • Update getFolderText_() to only include files whose name contains “Guide” or “Notes”.
  3. Show Debug Information
    • Log apiData and folderText before the Gemini call.

Add a dev-only toggle to see the raw JSON/folder text.