Skip to main content

Connecting elvex to your Google Sheets

How-to guide for connecting your Google Sheets to an elvex assistant

Updated this week

You can call the elvex API directly from Google Sheets using a Google Apps Script. This integration enables you to seamlessly run elvex within Google Sheets, leveraging the power of custom AI applications to enhance your workflow.

Setting up your API key

To get started, create an API key. Go to Settings > API Keys and click Create an API Key. Provide it a name, and copy the key somewhere safe. We will use this key later.

Configuring your assistant

Next, create or find an assistant that you have access to edit. In the assistant configuration, under Security & Permissions, click the Assistant Users dropdown, and select your API key.

After selecting your key, you will see your it in the list of users.

Under Advanced, copy the Assistant ID and Latest Version values. We will use this later.

Important Note: Every time you update your assistant's rules or tools, elvex creates a new assistant version. The Latest Version number will change, and you'll need to update this value in your Google Sheets script (explained below).


At the bottom, click Save & Publish to save your changes.

Setting up the Google Sheets script

Now, go into your Google Sheets page. In the menu bar, click Extensions > Apps Script.

This will open the Apps Scripts Editor. Name this app whatever you want, I call mine elvex Integration. In the file Code.gs there will be a pre-loaded function. Delete this, so you have an empty file.

Copy and paste the following text into the Code.gs file:

Code.gs

/**
* ======================================================================
* ELVEX GOOGLE APPS SCRIPT ADD-ON - SERVER-SIDE FUNCTIONS
* ======================================================================
*
* This file contains the server-side logic for the Elvex Google Sheets add-on.
* Architecture pattern: Client (Sidebar.html) ↔ google.script.run ↔ Server (this file)
*
* Key responsibilities:
* - Google Sheets API operations (reading ranges, writing results)
* - External API calls to Elvex AI service
* - Data validation and processing
* - User settings persistence via PropertiesService
* - Email notifications
* - Cancellation handling via CacheService
*/

"use strict";

// ==================== CONFIGURATION & CONSTANTS ====================
// Global configuration and constants for the add-on
const documentProperties = PropertiesService.getDocumentProperties();
const ELVEX_API_KEY_SETTING = "ELVEX_API_KEY"; // Storage key for API key
const ELVEX_APP_ID_SETTING = "ELVEX_APP_ID"; // Storage key for app ID
const ELVEX_APP_VERSION_SETTING = "ELVEX_APP_VERSION"; // Storage key for app version
const ELVEX_API_BASE_URL = "https://api.elvex.ai"; // Elvex API endpoint

// ==================== GOOGLE APPS SCRIPT LIFECYCLE ====================

/**
* Called when the add-on is first installed
* Delegates to onOpen to set up the menu
* @param {Event} e - Installation event object
*/
function onInstall(e) {
onOpen(e);
}

/**
* Called when the spreadsheet is opened
* Creates the "elvex" menu in the Google Sheets UI
* @param {Event} e - Open event object
*/
function onOpen(e) {
const ui = SpreadsheetApp.getUi();
ui.createMenu("elvex")
.addItem("Set API Key", "setAPIKey")
.addItem("Generate from range", "showGenerateSidebar")
.addToUi();
}

// ==================== API KEY MANAGEMENT ====================

/**
* Validates an Elvex API key by making a test request
* @param {string} apiKey - The API key to validate
* @returns {boolean} True if the API key is valid, false otherwise
*/
function isElvexAPIKeyValid(apiKey) {
if (!apiKey || typeof apiKey !== "string" || apiKey.trim() === "")
return false;
const response = UrlFetchApp.fetch(`${ELVEX_API_BASE_URL}/ping`, {
method: "GET",
contentType: "application/json",
headers: { Authorization: `Bearer ${apiKey}` },
muteHttpExceptions: true,
});
return response.getResponseCode() === 200;
}

/**
* Prompts user to set their Elvex API key
* Validates the key before storing it in document properties
* Shows appropriate success/error messages to the user
*/
function setAPIKey() {
const currentApiKey = getElvexAPIKey();
const ui = SpreadsheetApp.getUi();
const promptMessage =
`Provide your elvex API Key for ${ELVEX_API_BASE_URL}.` +
(typeof currentApiKey === "string" && currentApiKey !== ""
? `\nCurrent API key: ${currentApiKey.slice(0, 10)}...`
: "") +
"\nNote: This API key will be saved for all users of this document.";

const response = ui.prompt(
"Configure your elvex API key",
promptMessage,
ui.ButtonSet.OK_CANCEL
);

if (response.getSelectedButton() !== ui.Button.OK) return;

const newApiKey = response.getResponseText().trim();
if (newApiKey === "") {
ui.alert("API Key Error", "API key cannot be empty.", ui.ButtonSet.OK);
return;
}
if (isElvexAPIKeyValid(newApiKey)) {
documentProperties.setProperty(ELVEX_API_KEY_SETTING, newApiKey);
ui.alert(
"Success",
"API key has been set successfully.",
ui.ButtonSet.OK
);
} else {
ui.alert(
"API Key Error",
`The provided API key does not appear valid for ${ELVEX_API_BASE_URL}. Please check the key and try again.`,
ui.ButtonSet.OK
);
}
}

/**
* Clears the stored API key from document properties
* Used for administrative purposes or troubleshooting
*/
function clearAPIKey() {
// Kept for completeness, not in menu by default
documentProperties.deleteProperty(ELVEX_API_KEY_SETTING);
SpreadsheetApp.getUi().alert(
"API key cleared. Set a new API key before using the add-on."
);
}

/**
* Retrieves the stored Elvex API key
* @returns {string|null} The stored API key or null if not set
*/
function getElvexAPIKey() {
return documentProperties.getProperty(ELVEX_API_KEY_SETTING);
}

// ==================== ELVEX API COMMUNICATION ====================

/**
* Builds the HTTP request configuration for Elvex API calls
* @param {string} apiKey - The Elvex API key
* @param {string} appId - The Elvex app ID
* @param {string} appVersion - The Elvex app version
* @param {string} prompt - The prompt to send to the AI
* @returns {Object} Request configuration object with URL and parameters
*/
function buildElvexAppRequest(apiKey, appId, appVersion, prompt) {
return {
url: `${ELVEX_API_BASE_URL}/v0/apps/${appId}/versions/${appVersion}/text/generate`,
params: {
method: "POST",
contentType: "application/json",
headers: { Authorization: `Bearer ${apiKey}` },
payload: JSON.stringify({ prompt }),
muteHttpExceptions: true,
},
};
}

/**
* Makes a request to the Elvex AI API to generate content from a prompt
* Handles validation, API communication, and error processing
* @param {string} appId - The Elvex app ID
* @param {string} appVersion - The Elvex app version
* @param {string} prompt - The prompt to send to the AI
* @returns {string} The AI-generated response content
* @throws {Error} If API key is missing, parameters are invalid, or API call fails
*/
function elvexGenerate(appId, appVersion, prompt) {
const apiKey = getElvexAPIKey();

if (!apiKey)
throw new Error(
"elvex API key is not yet set. Please set the API key via the elvex menu."
);

// Input validation and sanitization
appId = appId.toString().trim();
appVersion = appVersion.toString().trim();

if (!appId) throw new Error("appId is required");

const appVersionNum = Number(appVersion);

// Validate app version is a positive integer
if (!Number.isInteger(appVersionNum) || appVersionNum < 1)
throw new Error("appVersion must be an integer >= 1");

prompt = prompt.toString();

if (!prompt.trim()) return ""; // Return empty if prompt is empty after trim

// Make API request
const request = buildElvexAppRequest(apiKey, appId, appVersion, prompt);
const response = UrlFetchApp.fetch(request.url, request.params);
const code = response.getResponseCode();

// Handle HTTP errors
if (code !== 200) {
let errorContent = response.getContentText();
try {
const errorJson = JSON.parse(errorContent);
errorContent =
errorJson.message ||
errorJson.error ||
(typeof errorJson === "string"
? errorJson
: JSON.stringify(errorJson));
} catch (e) {
// Content is not JSON, might be HTML or plain text. Truncate if too long.
if (errorContent.length > 250) {
errorContent = errorContent.substring(0, 247) + "...";
}
}
throw new Error(`elvex API Error (${code}): ${errorContent}`);
}

const responseData = JSON.parse(response.getContentText());

// Validate response structure
if (
responseData &&
responseData.data &&
typeof responseData.data.response !== "undefined"
) {
return responseData.data.response;
} else {
throw new Error("elvex API Error: Unexpected response structure.");
}
}

// ==================== CANCELLATION MANAGEMENT ====================

/**
* Sets a cancellation flag in the cache to signal processing should stop
* Uses CacheService for temporary storage with automatic expiration
*/
function requestCancel() {
CacheService.getScriptCache().put("elvex_cancel", "1", 60 * 10); // 10 minutes timeout
}

/**
* Checks if a cancellation has been requested
* @returns {boolean} True if cancellation is requested, false otherwise
*/
function checkCancel() {
return CacheService.getScriptCache().get("elvex_cancel") === "1";
}

/**
* Clears the cancellation flag from the cache
* Called when processing completes or to reset cancellation state
*/
function clearCancel() {
CacheService.getScriptCache().remove("elvex_cancel");
}

// ==================== DATA PROCESSING & SHEET OPERATIONS ====================

/**
* Generates a preview of input data showing how it will be processed
* Shows first 3 rows with delimiter splitting applied
* @param {Object} params - Parameters object containing inputRange and delimiter
* @returns {Object} Preview data with rows, headers, and total row count
*/
function previewSheet(params) {
const { inputRange, delimiter } = params;
const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
const rangeObj = sheet.getRange(inputRange);
const vals = rangeObj.getValues().flat(); // .flat() requires V8 runtime
let toShow = vals.slice(0, 3);
let rows = [];
let headers = [];

for (const v of toShow) {
const cellValue =
v === null || typeof v === "undefined" ? "" : v.toString();
let split = delimiter ? cellValue.split(delimiter) : [cellValue];
if (!headers.length)
headers = [...Array(split.length).keys()].map(
(i) => "Value " + (i + 1)
);
rows.push(split);
}
return { rows, headers, inputRows: vals.length };
}

/**
* Prepares and validates input data from Google Sheets
* Performs range validation and extracts input values for processing
* @param {Object} params - Parameters containing inputRange, outputRange, appId, appVersion
* @returns {Object} Prepared data with totalRows and inputValues array
* @throws {string} If ranges are invalid or incompatible
*/
function prepareInputData(params) {
// Store app settings for future use
documentProperties.setProperties({
[ELVEX_APP_ID_SETTING]: params.appId,
[ELVEX_APP_VERSION_SETTING]: params.appVersion,
});

var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
var promptRangeObj = sheet.getRange(params.inputRange);
var numInputRows = promptRangeObj.getNumRows();
var numInputCols = promptRangeObj.getNumColumns();

var outputRangeObj = sheet.getRange(params.outputRange);
var numOutputRows = outputRangeObj.getNumRows();

// Validate range compatibility
if (numInputRows !== numOutputRows) {
// Note: Error prefix removed as client will add it
throw "Input range and output range must have the same number of rows.";
}
if (numInputCols !== 1) {
// Note: Error prefix removed as client will add it
throw "Input range must be a single column.";
}

const inputValues = promptRangeObj.getValues().map((row) => row[0]); // Extract single column
return { totalRows: inputValues.length, inputValues };
}

/**
* Processes a single prompt through the Elvex API and writes result to sheet
* This is the main worker function called for each row in batch processing
* @param {Object} params - Processing parameters including prompt, rowIndex, delimiter, etc.
* @returns {Object} Processing result with status, rowIndex, and response value
*/
function processSinglePrompt(params) {
// Check for cancellation before processing
if (checkCancel()) {
return {
status: "cancelled",
message: "User cancelled",
rowIndex: params.rowIndex,
};
}

let responseVal = "";
try {
// Generate AI response
responseVal = elvexGenerate(
params.appId,
params.appVersion,
params.prompt
);
} catch (err) {
// Handle errors without "Error:" prefix - client will add it
responseVal = err.message || err.toString();
}

// Apply delimiter splitting if specified
let vals = [responseVal];
if (params.delimiter && responseVal) {
try {
vals = responseVal.split(params.delimiter);
} catch (e) {
// If splitting fails, keep as single value
vals = [responseVal];
}
}

// Write results to the appropriate row in the output range
const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
const baseOutputRange = sheet.getRange(params.outputRange);
const outputRowStart = baseOutputRange.getRow();
const outputColStart = baseOutputRange.getColumn();
const targetRow = outputRowStart + (params.rowIndex || 0);

sheet.getRange(targetRow, outputColStart, 1, vals.length).setValues([vals]);
return { status: "success", rowIndex: params.rowIndex, responseVal };
}

// ==================== ERROR REPROCESSING ====================

/**
* Identifies and prepares error rows for reprocessing
* Scans the output range for cells that start with "Error:" or "elvex API Error" and extracts
* the corresponding input prompts for reprocessing with progress tracking
* @param {Object} params - Parameters containing inputRange, outputRange, appId, appVersion
* @returns {Object} Data structure with error rows, prompts, and total count for reprocessing
* @throws {string} If ranges are invalid or no errors are found
*/
function reprocessErrors(params) {
// Store app settings for future use
documentProperties.setProperties({
[ELVEX_APP_ID_SETTING]: params.appId,
[ELVEX_APP_VERSION_SETTING]: params.appVersion,
});

var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
var inputRangeObj = sheet.getRange(params.inputRange);
var outputRangeObj = sheet.getRange(params.outputRange);

var numInputRows = inputRangeObj.getNumRows();
var numOutputRows = outputRangeObj.getNumRows();
var numInputCols = inputRangeObj.getNumColumns();

// Validate range compatibility
if (numInputRows !== numOutputRows) {
throw "Input range and output range must have the same number of rows.";
}
if (numInputCols !== 1) {
throw "Input range must be a single column.";
}

// Get all input and output values
const inputValues = inputRangeObj.getValues().map((row) => row[0]);
const outputValues = outputRangeObj.getValues().map((row) => row[0]);

// Find rows with errors (output starts with "Error:" or "elvex API Error")
const errorRows = [];
const errorPrompts = [];

for (let i = 0; i < outputValues.length; i++) {
const outputValue = outputValues[i];
const stringValue =
outputValue === null || typeof outputValue === "undefined"
? ""
: outputValue.toString();

if (stringValue.startsWith("elvex API Error")) {
errorRows.push(i); // Store the row index
errorPrompts.push(inputValues[i]); // Store the corresponding input prompt
}
}

if (errorRows.length === 0) {
throw "No error rows found to reprocess. All rows appear to have been processed successfully.";
}

return {
totalRows: errorRows.length,
inputValues: errorPrompts,
errorRowIndices: errorRows, // Include original row indices for proper output mapping
};
}

/**
* Processes a single error prompt during reprocessing
* Similar to processSinglePrompt but uses the original row index for output placement
* @param {Object} params - Processing parameters including prompt, originalRowIndex, delimiter, etc.
* @returns {Object} Processing result with status, rowIndex, and response value
*/
function processSingleErrorPrompt(params) {
// Check for cancellation before processing
if (checkCancel()) {
return {
status: "cancelled",
message: "User cancelled",
rowIndex: params.rowIndex,
};
}

let responseVal = "";
try {
// Generate AI response
responseVal = elvexGenerate(
params.appId,
params.appVersion,
params.prompt
);
} catch (err) {
// Handle errors without "Error:" prefix - client will add it
responseVal = err.message || err.toString();
}

// Apply delimiter splitting if specified
let vals = [responseVal];
if (params.delimiter && responseVal) {
try {
vals = responseVal.split(params.delimiter);
} catch (e) {
// If splitting fails, keep as single value
vals = [responseVal];
}
}

// Write results to the appropriate row in the output range using the original row index
const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
const baseOutputRange = sheet.getRange(params.outputRange);
const outputRowStart = baseOutputRange.getRow();
const outputColStart = baseOutputRange.getColumn();
const targetRow = outputRowStart + (params.originalRowIndex || 0); // Use originalRowIndex for correct placement

sheet.getRange(targetRow, outputColStart, 1, vals.length).setValues([vals]);
return { status: "success", rowIndex: params.rowIndex, responseVal };
}

// ==================== UI & NOTIFICATION FUNCTIONS ====================

/**
* Creates and displays the sidebar interface
* Injects stored app settings into the HTML template
*/
function showGenerateSidebar() {
const template = HtmlService.createTemplateFromFile("Sidebar");
template.data = {
appId: documentProperties.getProperty(ELVEX_APP_ID_SETTING) ?? "",
appVersion:
documentProperties.getProperty(ELVEX_APP_VERSION_SETTING) ?? "",
};
const html = template.evaluate().setTitle("elvex Assistant");
SpreadsheetApp.getUi().showSidebar(html);
}

/**
* Sends completion notification email to the specified recipient
* Called when processing completes, is cancelled, or encounters errors
* @param {string} recipient - Email address to send notification to
* @param {number} processedCount - Number of rows that were processed
* @param {boolean} wasCancelledAborted - Whether processing was interrupted
*/
function sendCompletionEmail(recipient, processedCount, wasCancelledAborted) {
if (!recipient) return;
var subject =
"elvex GSheets Processing " +
(wasCancelledAborted ? "Interrupted" : "Complete");
var body =
"elvex GSheets batch processing is " +
(wasCancelledAborted
? "interrupted (cancelled or aborted due to error)"
: "complete") +
".\nRows processed: " +
processedCount;
try {
MailApp.sendEmail(recipient, subject, body);
} catch (e) {
console.error("Failed to send completion email: " + e.toString());
// Note: Email failures are logged but don't interrupt the main process
}
}


Create a new HTML file and call it Sidebar.

This will create an HTML file with a preloaded HTML script. Delete this, so you have an empty file.

Copy and paste the following text into the Sidebar.html file:

Sidebar.html

<!DOCTYPE html>
<html>
<head>
<base target="_top" />
<script src="https://cdn.tailwindcss.com"></script>
<style type="text/tailwindcss">
html,
body {
background: #f5fbfd;
font-family: sans-serif;
}
@layer components {
.brand-primary-bg {
background-color: #52bee0;
}
.brand-primary-border {
border-color: #52bee0;
}
.brand-primary-txt {
color: #52bee0;
}
.form-label {
@apply block text-sm font-semibold text-gray-700 mb-1;
}
.form-input {
@apply w-full p-2 border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[#52bee0];
}
.form-section {
@apply mb-4;
}
.form-helper {
@apply text-xs text-gray-400 mb-1;
}
.btn-primary {
@apply w-full py-2 px-4 rounded-lg text-white font-bold transition disabled:opacity-50 disabled:cursor-not-allowed;
background-color: #52bee0;
border: none;
}
.btn-primary:hover:not(:disabled) {
background-color: #399dc1;
}
.btn-secondary {
@apply w-full py-2 px-4 rounded-lg text-gray-700 font-semibold transition disabled:opacity-50 disabled:cursor-not-allowed;
background-color: #e8f8fd;
border: 1px solid #52bee0;
}
.btn-secondary:hover:not(:disabled) {
background-color: #d3f0fc;
}
#progressBarFill {
background-color: #52bee0;
@apply h-2.5 rounded-full transition-all duration-300;
}
#progressBar {
@apply bg-gray-200 rounded-full h-2.5;
}
#status {
@apply mt-3 p-2 text-center rounded-lg border font-medium shadow-sm break-words; /* Added break-words */
background: #e8f8fd;
color: #2088a0;
border-color: #52bee0;
}
#status.error {
/* Style for error messages */
background: #fee2e2;
color: #991b1b;
border-color: #f87171;
}
#preview-panel {
@apply mt-4 p-3 bg-white rounded border border-gray-200 text-xs;
}
}
</style>
</head>
<body class="p-5" style="background: #f5fbfd">
<div class="mb-6 text-center">
<h2
class="text-xl font-bold tracking-tight brand-primary-txt mb-1"
style="color: #52bee0"
>
elvex Assistant
</h2>
<p class="text-sm text-gray-500">
Batch AI prompt generation for Google Sheets
</p>
</div>
<form id="form">
<div class="form-section">
<label class="form-label" for="appId">Assistant ID</label>
<input
type="text"
id="appId"
class="form-input"
required
placeholder="e.g. 12345"
autocomplete="off"
/>
</div>
<div class="form-section">
<label class="form-label" for="appVersion"
>Assistant Version</label
>
<input
type="text"
id="appVersion"
class="form-input"
required
placeholder="e.g. 1"
/>
<div class="form-helper">
Version must be an integer, e.g., 1 or higher.
</div>
</div>
<div class="form-section">
<label class="form-label" for="inputRange">Input Range</label>
<input
type="text"
id="inputRange"
class="form-input"
required
placeholder="e.g. B2:B101"
/>
<div class="form-helper">
Excel-style (e.g., A2:A20). Inputs must be one column.
</div>
</div>
<div class="form-section">
<label class="form-label" for="outputRange">Output Range</label>
<input
type="text"
id="outputRange"
class="form-input"
required
placeholder="e.g. C2:C101"
/>
<div class="form-helper">
Must have the same number of rows as your input range.
</div>
</div>
<div class="form-section">
<label class="form-label" for="delimiter"
>Split Delimiter (optional)</label
>
<input
type="text"
id="delimiter"
class="form-input w-28"
placeholder=", (comma)"
/>
<div class="form-helper">
Character to split API responses into multiple columns
(e.g., comma, semicolon). Leave empty for no split.
</div>
</div>
<div class="form-section">
<label class="form-label" for="notifyEmail"
>Notify Email (optional)</label
>
<input
type="email"
id="notifyEmail"
class="form-input"
placeholder="[email protected]"
/>
<div class="form-helper">
You'll get an email when complete or processing is
interrupted.
</div>
</div>
<div class="flex gap-3 mb-2">
<button type="button" id="previewBtn" class="btn-secondary">
Preview
</button>
<button id="submitBtn" class="btn-primary" type="submit">
Generate
</button>
</div>
<button
id="cancelBtn"
class="btn-primary mt-2 hidden"
type="button"
style="background: #e05d52"
>
Cancel
</button>
<div id="status" class="hidden"></div>
<button
type="button"
class="btn-primary hidden mt-2"
id="reprocess-errors-btn"
onclick="handleReprocessErrors()"
>
Reprocess Errors
</button>
<div id="progressContainer" class="mt-4 hidden">
<div class="flex justify-between mb-1">
<span id="progressText" class="text-xs text-gray-700"
>0%</span
>
<span id="progressCount" class="text-xs text-gray-700"
>0 / 0</span
>
</div>
<div id="progressBar" class="mb-2">
<div id="progressBarFill" style="width: 0%"></div>
</div>
</div>
<div id="preview-panel" class="hidden"></div>
</form>

<script>
/**
* ======================================================================
* CLIENT-SIDE JAVASCRIPT FOR ELVEX GOOGLE SHEETS SIDEBAR
* ======================================================================
*
* This script handles the UI interactions for batch AI prompt processing.
* Architecture pattern: Client (this file) ↔ google.script.run ↔ Server (Code.gs)
*
* Key responsibilities:
* - Form validation and user input handling
* - Progress tracking and UI updates during batch processing
* - Asynchronous communication with Google Apps Script backend
* - Error handling and user feedback
* - Concurrent processing management with rate limiting
*
* DATA FLOW:
* User Input (Form) → buildParams() → Server (Code.gs) → Process Data →
* Server Response → Update UI → Repeat until complete
*
* ERROR HANDLING STRATEGY:
* - Client-side validation prevents bad requests
* - Server errors are caught and displayed to user
* - Processing can be cancelled at any time
* - UI always returns to ready state after completion/error
*/
"use strict";

// ==================== INITIALIZATION & DATA BINDING ====================
// Initialize form fields with data passed from server template
const elvexData = JSON.parse("<?=JSON.stringify(data)?>"); // Server-injected data
if (elvexData.appId)
document.getElementById("appId").value = elvexData.appId;
if (elvexData.appVersion)
document.getElementById("appVersion").value =
elvexData.appVersion;

// ==================== STATE MANAGEMENT ====================
// Global state variables for tracking batch processing progress
let totalPrompts = 0, // Total number of prompts to process
processedPrompts = 0, // Number of completed prompts
processingInProgress = false; // Flag to prevent concurrent processing

let promptsArray = [], // Array of input prompts from sheet
cancelRequested = false, // User requested cancellation
wasUserCancel = false, // Distinguishes user cancel vs error abort
errorRowIndices = []; // Array of original row indices for error reprocessing

let currentPromptIndex = 0; // Next prompt index to process
let activeWorkers = 0; // Number of currently running API calls

// ==================== DOM ELEMENT REFERENCES ====================
// Cache DOM elements for performance and code clarity
const form = document.getElementById("form");
const submitBtn = document.getElementById("submitBtn");
const cancelBtn = document.getElementById("cancelBtn");
const previewBtn = document.getElementById("previewBtn");
const statusDiv = document.getElementById("status");
const previewPanel = document.getElementById("preview-panel");
const progressContainer =
document.getElementById("progressContainer");
const progressText = document.getElementById("progressText");
const progressBarFill = document.getElementById("progressBarFill");
const progressCount = document.getElementById("progressCount");

// ==================== EVENT HANDLERS ====================
// Main UI interaction handlers
form.addEventListener("submit", startGeneration); // Main processing trigger
previewBtn.addEventListener("click", previewInput); // Show preview without processing
cancelBtn.addEventListener("click", cancelProcessing); // User cancellation

// ==================== UTILITY FUNCTIONS ====================

/**
* Updates the progress bar and percentage display
* Called after each prompt is processed to show real-time progress
*/
function updateProgressUI() {
const pct =
totalPrompts > 0
? Math.round((processedPrompts / totalPrompts) * 100)
: 0;
progressText.innerText = pct + "%";
progressBarFill.style.width = pct + "%";
progressCount.innerText =
processedPrompts + " / " + totalPrompts;
}

/**
* Builds parameter object from form inputs
* @returns {Object} Parameters needed for server-side processing
*/
function buildParams() {
return {
appId: document.getElementById("appId").value.trim(),
appVersion: document
.getElementById("appVersion")
.value.trim(),
inputRange: document
.getElementById("inputRange")
.value.trim(),
outputRange: document
.getElementById("outputRange")
.value.trim(),
delimiter: document.getElementById("delimiter").value, // Keep as is, empty string means no delimiter effectively
};
}

/**
* Centralized error display with consistent formatting
* Ensures all errors are prefixed with "Error:" for UI consistency
* @param {string} message - Error message to display
*/
function displayError(message) {
// Ensure we only prefix with "Error:" if it's not already there
const errorMsg = message.toString();
statusDiv.innerText = errorMsg.startsWith("Error:")
? errorMsg
: `Error: ${errorMsg}`;
statusDiv.classList.add("error");
}

// ==================== PREVIEW FUNCTIONALITY ====================

/**
* Shows a preview of input data without processing
* Calls server to parse input range and display first 3 rows
* Helps users verify their input range is correct before processing
*/
function previewInput() {
if (processingInProgress) return;
const params = buildParams();
if (!params.inputRange) {
previewPanel.innerHTML = `<span class='text-red-500'>Please enter an input range for preview.</span>`;
previewPanel.classList.remove("hidden");
return;
}
previewPanel.innerHTML = "Loading preview...";
previewPanel.classList.remove("hidden");
statusDiv.classList.add("hidden"); // Hide status during preview

// GOOGLE APPS SCRIPT COMMUNICATION PATTERN:
// google.script.run
// .withSuccessHandler(callback) // Handle successful response
// .withFailureHandler(callback) // Handle errors/exceptions
// .serverFunction(parameters); // Call function in Code.gs
google.script.run
.withSuccessHandler((res) => {
let html = `<div class='mb-1 font-semibold' style='color:#52bee0;'>Preview (first 3 input rows):</div>`;
if (res.rows && res.rows.length) {
html += `<div class='mb-2 text-gray-500'>Data as it would be split into columns:</div>`;
html +=
'<table class="mt-2 w-full text-left table-auto"><thead><tr>';
(res.headers || []).forEach(
(h) =>
(html += `<th class='text-xs p-1 border border-gray-300' style='color:#52bee0;'>${h}</th>`)
);
html += "</tr></thead><tbody>";
res.rows.forEach((r) => {
html +=
"<tr>" +
r
.map(
(cell) =>
`<td class='text-xs p-1 border border-gray-300' style='color:#2088a0;'>${cell}</td>`
)
.join("") +
"</tr>";
});
html += "</tbody></table>";
html += `<div class='text-xs text-gray-500 mt-2'>Total rows detected in input range: ${res.inputRows}</div>`;
} else {
html += `<div class="text-xs text-gray-400">No data detected or range is invalid.</div>`;
}
previewPanel.innerHTML = html;
})
.withFailureHandler((e) => {
const errorMsg = e.message || e.toString();
previewPanel.innerHTML = `<span class='text-red-500'>${
errorMsg.startsWith("Error:")
? errorMsg
: `Error: ${errorMsg}`
}</span>`;
})
.previewSheet(params);
}

// ==================== CANCELLATION HANDLING ====================

/**
* Handles user-initiated cancellation of processing
* Sets cancellation flags and notifies server to stop processing
*/
function cancelProcessing() {
if (!processingInProgress) return;
cancelRequested = true;
wasUserCancel = true; // Mark that this was a user-initiated cancel
google.script.run.requestCancel(); // Notify server
statusDiv.innerText = "Cancellation requested...";
statusDiv.classList.remove("hidden");
statusDiv.classList.remove("error");
cancelBtn.classList.add("hidden");
submitBtn.disabled = true; // Keep generate disabled until fully resolved
}

// ==================== MAIN PROCESSING FLOW ====================

/**
* BATCH PROCESSING FLOW:
* 1. Validate form inputs and prepare UI
* 2. Fetch input data from Google Sheet (prepareInputData)
* 3. Process prompts concurrently (processQueue)
* 4. Update progress after each completion
* 5. Handle cancellation and cleanup (cleanupUI)
*/
function startGeneration(evt) {
evt.preventDefault();
if (processingInProgress) return;

// Initialize UI for processing state
statusDiv.innerText = "Preparing...";
statusDiv.classList.remove("hidden");
statusDiv.classList.remove("error"); // Reset error style
previewPanel.classList.add("hidden");
submitBtn.disabled = true;
previewBtn.disabled = true;
cancelBtn.classList.remove("hidden");
progressContainer.classList.remove("hidden");

// Reset processing state
processingInProgress = true;
cancelRequested = false;
wasUserCancel = false;
processedPrompts = 0;
totalPrompts = 0;
currentPromptIndex = 0;
activeWorkers = 0;
promptsArray = [];
errorRowIndices = [];
updateProgressUI(); // Initialize progress display

const params = buildParams();

// Step 1: Prepare input data from Google Sheet
google.script.run
.withSuccessHandler((data) => {
if (cancelRequested) {
// Check if cancelled during prepareInputData
cleanupUI(true); // Pass true as it's an interruption
return;
}
promptsArray = data.inputValues || [];
totalPrompts = data.totalRows || promptsArray.length;

if (totalPrompts === 0) {
displayError(
"No input data to process based on the provided range."
);
cleanupUI(true); // Treat as an abort/error condition
return;
}
updateProgressUI(); // Update with actual total
statusDiv.innerText = "Processing...";
processQueue(); // Step 2: Start concurrent processing
})
.withFailureHandler((err) => {
displayError(err.message || err);
cleanupUI(true); // Indicate an aborted state due to error
})
.prepareInputData(params);
}

/**
* CONCURRENT PROCESSING MANAGEMENT
*
* Processes multiple prompts simultaneously while respecting:
* - Maximum concurrent workers (prevents API rate limiting)
* - Cancellation requests (checked before each dispatch)
* - Error handling and recovery
* - Progress tracking and completion detection
*
* This function is recursive - it calls itself after each completion
* to dispatch more work until all prompts are processed.
*/
function processQueue() {
const MAX_CONCURRENT = 3; // Limit concurrent API calls to prevent rate limiting

// Check for cancellation before proceeding
if (cancelRequested && processingInProgress) {
cleanupUI(true); // Use 'true' for aborted/cancelled
return;
}

// Dispatch new workers while under concurrency limit
while (
activeWorkers < MAX_CONCURRENT &&
currentPromptIndex < totalPrompts &&
processingInProgress
) {
if (cancelRequested) {
// Double check before dispatching
if (processingInProgress) cleanupUI(true);
return;
}
activeWorkers++;
const params = buildParams(); // Get fresh params (in case delimiter, etc. changes - though not expected mid-run)
params.prompt = promptsArray[currentPromptIndex];
params.rowIndex = currentPromptIndex;

// Process single prompt asynchronously
google.script.run
.withSuccessHandler((result) => {
activeWorkers--;
processedPrompts++;
updateProgressUI();

// Handle server-side cancellation or errors
if (result && result.status === "cancelled") {
console.warn(
`Row ${
params.rowIndex + 1
} processing was cancelled by server logic.`
);
cancelRequested = true; // Sync client state
} else if (
result &&
result.responseVal &&
result.responseVal.startsWith("Error:")
) {
console.error(
`Error processing row ${
params.rowIndex + 1
}: ${result.responseVal}`
);
}

// Check for completion or continuation
if (
cancelRequested ||
processedPrompts >= totalPrompts
) {
if (processingInProgress)
cleanupUI(cancelRequested || wasUserCancel); // Pass true if any cancel flag is set
} else {
processQueue(); // Recursive call to process more items
}
})
.withFailureHandler((err) => {
activeWorkers--;
processedPrompts++; // Count as processed to not hang completion
updateProgressUI();
console.error(
`Failed to process row ${
params.rowIndex + 1
}: ${err.message || err}`
);

// Check for completion or continuation after error
if (
cancelRequested ||
processedPrompts >= totalPrompts
) {
if (processingInProgress)
cleanupUI(cancelRequested || wasUserCancel);
} else {
processQueue(); // Continue processing despite error
}
})
.processSinglePrompt(params);
currentPromptIndex++;
}

// Final completion check: all prompts dispatched and all workers done
if (
currentPromptIndex >= totalPrompts &&
activeWorkers === 0 &&
processingInProgress &&
!cancelRequested
) {
if (processedPrompts >= totalPrompts) {
// Ensure all callbacks have fired
cleanupUI(false); // All successfully completed
}
}
}

/**
* Handles reprocessing of failed/error rows with progress tracking
* Called when user clicks "Reprocess Errors" button
* Follows the same pattern as startGeneration but targets only error rows
*/
function handleReprocessErrors() {
if (processingInProgress) return;

// Initialize UI for reprocessing state
statusDiv.innerText = "Preparing to reprocess errors...";
statusDiv.classList.remove("hidden");
statusDiv.classList.remove("error"); // Reset error style
previewPanel.classList.add("hidden");
submitBtn.disabled = true;
previewBtn.disabled = true;
cancelBtn.classList.remove("hidden");
progressContainer.classList.remove("hidden");

// Reset processing state
processingInProgress = true;
cancelRequested = false;
wasUserCancel = false;
processedPrompts = 0;
totalPrompts = 0;
currentPromptIndex = 0;
activeWorkers = 0;
promptsArray = [];
errorRowIndices = []; // Store original row indices for error reprocessing
updateProgressUI(); // Initialize progress display

const params = buildParams();

// Step 1: Identify error rows and prepare data for reprocessing
google.script.run
.withSuccessHandler((data) => {
if (cancelRequested) {
// Check if cancelled during reprocessErrors
cleanupUI(true); // Pass true as it's an interruption
return;
}
promptsArray = data.inputValues || [];
errorRowIndices = data.errorRowIndices || [];
totalPrompts = data.totalRows || promptsArray.length;

if (totalPrompts === 0) {
displayError(
"No error rows found to reprocess. All rows appear to have been processed successfully."
);
cleanupUI(true); // Treat as an abort/error condition
return;
}
updateProgressUI(); // Update with actual total
statusDiv.innerText = `Reprocessing ${totalPrompts} error row${totalPrompts === 1 ? '' : 's'}...`;
processErrorQueue(); // Step 2: Start concurrent error reprocessing
})
.withFailureHandler((err) => {
displayError(err.message || err);
cleanupUI(true); // Indicate an aborted state due to error
})
.reprocessErrors(params);
}

/**
* CONCURRENT ERROR REPROCESSING MANAGEMENT
*
* Reprocesses multiple error rows simultaneously while respecting:
* - Maximum concurrent workers (prevents API rate limiting)
* - Cancellation requests (checked before each dispatch)
* - Error handling and recovery
* - Progress tracking and completion detection
*
* This function is recursive - it calls itself after each completion
* to dispatch more work until all error rows are reprocessed.
*/
function processErrorQueue() {
const MAX_CONCURRENT = 3; // Limit concurrent API calls to prevent rate limiting

// Check for cancellation before proceeding
if (cancelRequested && processingInProgress) {
cleanupUI(true); // Use 'true' for aborted/cancelled
return;
}

// Dispatch new workers while under concurrency limit
while (
activeWorkers < MAX_CONCURRENT &&
currentPromptIndex < totalPrompts &&
processingInProgress
) {
if (cancelRequested) {
// Double check before dispatching
if (processingInProgress) cleanupUI(true);
return;
}
activeWorkers++;
const params = buildParams(); // Get fresh params (in case delimiter, etc. changes - though not expected mid-run)
params.prompt = promptsArray[currentPromptIndex];
params.rowIndex = currentPromptIndex; // Current index in the error processing array
params.originalRowIndex = errorRowIndices[currentPromptIndex]; // Original row index in the sheet

// Process single error row asynchronously
google.script.run
.withSuccessHandler((result) => {
activeWorkers--;
processedPrompts++;
updateProgressUI();

// Handle server-side cancellation or errors
if (result && result.status === "cancelled") {
console.warn(
`Row ${
params.originalRowIndex + 1
} reprocessing was cancelled by server logic.`
);
cancelRequested = true; // Sync client state
} else if (
result &&
result.responseVal &&
result.responseVal.startsWith("Error:")
) {
console.error(
`Error reprocessing row ${
params.originalRowIndex + 1
}: ${result.responseVal}`
);
}

// Check for completion or continuation
if (
cancelRequested ||
processedPrompts >= totalPrompts
) {
if (processingInProgress)
cleanupUI(cancelRequested || wasUserCancel); // Pass true if any cancel flag is set
} else {
processErrorQueue(); // Recursive call to reprocess more error rows
}
})
.withFailureHandler((err) => {
activeWorkers--;
processedPrompts++; // Count as processed to not hang completion
updateProgressUI();
console.error(
`Failed to reprocess row ${
params.originalRowIndex + 1
}: ${err.message || err}`
);

// Check for completion or continuation after error
if (
cancelRequested ||
processedPrompts >= totalPrompts
) {
if (processingInProgress)
cleanupUI(cancelRequested || wasUserCancel);
} else {
processErrorQueue(); // Continue reprocessing despite error
}
})
.processSingleErrorPrompt(params);
currentPromptIndex++;
}

// Final completion check: all error rows dispatched and all workers done
if (
currentPromptIndex >= totalPrompts &&
activeWorkers === 0 &&
processingInProgress &&
!cancelRequested
) {
if (processedPrompts >= totalPrompts) {
// Ensure all callbacks have fired
cleanupUI(false); // All successfully completed
}
}
}

/**
* CRITICAL: UI CLEANUP FUNCTION
*
* This function is called when processing completes, fails, or is cancelled.
* It's responsible for:
* - Resetting UI state (buttons, progress bars)
* - Displaying final status messages
* - Sending completion emails if configured
* - Preventing memory leaks from global state
*
* @param {boolean} abortedOrCancelled - true if processing was interrupted
*/
function cleanupUI(abortedOrCancelled) {
// Prevent duplicate cleanup calls
if (
!processingInProgress &&
!abortedOrCancelled &&
processedPrompts === 0 &&
totalPrompts === 0
) {
// Avoid running cleanup if it's already been run or nothing happened
// Exception: if abortedOrCancelled is true, means we need to clean up from error/cancel.
}

// Reset processing state
processingInProgress = false; // Mark as no longer in progress
submitBtn.disabled = false;
previewBtn.disabled = false;
submitBtn.innerText = "Generate";
cancelBtn.classList.add("hidden");

// Determine final status message
let statusMsg = "";
let isErrorState = false;

if (abortedOrCancelled) {
if (wasUserCancel) {
statusMsg = "Processing cancelled by user.";
} else {
// For other aborts (e.g., setup errors), statusDiv is already set by the caller.
// We just ensure it's visible and styled.
statusMsg = statusDiv.innerText; // Keep the specific error message
isErrorState = true;
}
} else {
statusMsg = "Processing complete!";
}

// Update UI with final status
statusDiv.innerText = statusMsg;
statusDiv.classList.remove("hidden");
document
.getElementById("reprocess-errors-btn")
.classList.remove("hidden");
if (isErrorState || (abortedOrCancelled && !wasUserCancel)) {
statusDiv.classList.add("error");
} else {
statusDiv.classList.remove("error");
}

// Send completion email if configured
const notifyEmail = document
.getElementById("notifyEmail")
.value.trim();
if (
notifyEmail &&
(abortedOrCancelled || processedPrompts > 0)
) {
// Send email if aborted/cancelled or if any work was done
google.script.run.sendCompletionEmail(
notifyEmail,
processedPrompts,
abortedOrCancelled
);
}

// Reset cancellation flags for next run
wasUserCancel = false; // Reset for the next potential run
errorRowIndices = []; // Reset error row indices for next run
}
</script>
</body>
</html>

Go to the Code.gs file. Click the Save button, then the Run button.


This will prompt you to allow the script permissions to your sheets file. Give the script access.

Using the integration​

Go back to your Google Sheet and refresh the page. You should see an elvex button in the menu bar. Click elvex > Set API Key. Paste the API key you created.

This will save the API key for this sheet. Click elvex > Generate from range. This will open a sidebar that we can enter information for this sheet.

Paste in the Assistant ID and the Assistant Version.

Important Note: Every time you make a change to your assistant configuration by updating the rules or tools, elvex will save that as a new assistant version and the value in the Latest Version bubble will change under the Advanced tab in the assistant configuration. You will need to copy each new version into this input box.

For the Input Range, you can provide a single cell reference (example - A1) or a range reference (example - A1:A5). This will be the input data to the elvex assistant we are connecting to. If you provide a range of a single cell, it will make a single API call to the assistant. If you provide a range of cells, it will make multiple API calls to the assistant in parallel.

For the Output Range, you can provide a single cell reference (example - B1) or a range reference (example - B1:B5).This will be where the assistant outputs the data from the elvex assistant.

Tip: for testing, start by setting the input range to only a single cell, or a small number of cells (less than 5). The script only outputs data when all calls are done, so having a large input range can take some time for the data to return.

Testing and Using the Integration

Run your first test

Click the "Generate" button to run the script. A loading indicator will appear while the API calls are processing. Once complete, check your specified output range for results from elvex.

Optimizing your usage

For initial testing, use a small input range (1-5 cells) to ensure quick response times. The script waits for all API calls to complete before displaying results, so larger ranges will take longer. Consider organizing your sheet with input data in one section and output data in another for clarity.

Troubleshooting common issues

If you see "API Key Invalid" errors, verify your API key is correct and has permission to access the assistant. If outputs show "App Version Not Found," check that you've entered the latest version number from your assistant configuration. For "Rate Limit Exceeded" messages, try processing smaller batches of data or implementing a delay between calls.

Best practices
Update your Assistant Version number whenever you modify your elvex assistant. Save your sheet after successful runs to preserve the generated outputs. For complex workflows, consider adding a timestamp column to track when data was processed.

Next steps
Once your basic integration is working, you can enhance it by adding triggers to automatically run the script on a schedule, creating custom buttons or menus for different elvex assistants, or setting up conditional formatting to highlight important insights from elvex.

Did this answer your question?