
Quick automations die when someone inserts a column, renames a header, or sorts the sheet. Position-based writes hit the wrong cells. Formulas spill and you lose a weekend. However, the fix is simple: contracts over columns and ID-based upserts.
Minimal column set for a resilient loop
Create these headers. Names must stay the consistent.
- record_uuid
- source_fields (comma-separated list of the inputs you send to AI, or keep normal inputs in their own columns)
- ai_raw_json
- normalized_title
- category
- tags
- confidence
- schema_version
- model_version
- ai_status
- ai_error
- input_hash
- last_processed_at
Add or reorder any other columns as you like. The loop will not care.
The JSON contract
Save this in your project notes and in a hidden tab for easy reference.
{
"name": "enrichment_v1",
"required": ["normalized_title", "category", "confidence"],
"properties": {
"normalized_title": {"type": "string"},
"category": {"type": "string"},
"tags": {"type": "array", "items": {"type": "string"}},
"confidence": {"type": "number"}
}
}
Apps Script
Paste this into Extensions -> Apps Script and update CONFIG to match your sheet.
/* === CONFIG === */
const CONFIG = {
sheetName: 'Data', // your tab name
schemaVersion: 'v1',
modelName: 'gpt-4o-mini', // or your provider/model string
maxPerRun: 200, // batch size to avoid quotas
inputFields: ['title', 'description'], // columns you send to AI
// Name of the headers to write back into
outputMap: {
normalized_title: 'normalized_title',
category: 'category',
tags: 'tags', // will join array with commas
confidence: 'confidence'
}
};
/* === MENU === */
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('AI Enrich')
.addItem('Process changed rows', 'processChangedRows')
.addItem('Force reprocess selected', 'processSelection')
.addToUi();
}
/* === CORE === */
function processChangedRows() {
const sh = getSheet();
const {rows, header, colIndex} = readRows(sh);
let processed = 0;
for (const r of rows) {
if (processed >= CONFIG.maxPerRun) break;
const recordId = r[header.record_uuid] || setUuid(sh, r.row, colIndex.record_uuid);
const payload = pickInputs(r, header);
const hash = sha256(JSON.stringify(payload));
if (r[header.input_hash] === hash && r[header.ai_status] === 'done') continue;
try {
const ai = callAI(payload); // returns JS object
validate(ai); // throws if missing required keys
writeCell(sh, r.row, colIndex.ai_raw_json, JSON.stringify(ai));
mapToColumns(sh, r.row, ai, colIndex);
writeCell(sh, r.row, colIndex.schema_version, CONFIG.schemaVersion);
writeCell(sh, r.row, colIndex.model_version, CONFIG.modelName);
writeCell(sh, r.row, colIndex.ai_status, 'done');
writeCell(sh, r.row, colIndex.input_hash, hash);
writeCell(sh, r.row, colIndex.last_processed_at, new Date());
} catch (e) {
writeCell(sh, r.row, colIndex.ai_status, 'error');
writeCell(sh, r.row, colIndex.ai_error, String(e).slice(0, 500));
}
processed++;
Utilities.sleep(150); // light backoff
}
}
function processSelection() {
const sh = getSheet();
const range = sh.getActiveRange();
if (!range) return;
const {header, colIndex} = readRows(sh, true); // header only
const startRow = range.getRow();
const endRow = startRow + range.getNumRows() - 1;
for (let row = startRow; row <= endRow; row++) {
const rowObj = rowToObject(sh, row, header);
const recordId = rowObj.record_uuid || setUuid(sh, row, colIndex.record_uuid);
const payload = pickInputs(rowObj, header);
const hash = sha256(JSON.stringify(payload));
try {
const ai = callAI(payload);
validate(ai);
writeCell(sh, row, colIndex.ai_raw_json, JSON.stringify(ai));
mapToColumns(sh, row, ai, colIndex);
writeCell(sh, row, colIndex.schema_version, CONFIG.schemaVersion);
writeCell(sh, row, colIndex.model_version, CONFIG.modelName);
writeCell(sh, row, colIndex.ai_status, 'done');
writeCell(sh, row, colIndex.input_hash, hash);
writeCell(sh, row, colIndex.last_processed_at, new Date());
} catch (e) {
writeCell(sh, row, colIndex.ai_status, 'error');
writeCell(sh, row, colIndex.ai_error, String(e).slice(0, 500));
}
}
}
/* === HELPERS === */
function getSheet() {
const sh = SpreadsheetApp.getActive().getSheetByName(CONFIG.sheetName);
if (!sh) throw new Error(`Sheet "${CONFIG.sheetName}" not found`);
return sh;
}
function readRows(sh, headerOnly=false) {
const values = sh.getDataRange().getValues();
if (values.length < 2) throw new Error('No data rows');
const headerRow = values[0].map(h => String(h).trim());
const header = {};
const colIndex = {};
headerRow.forEach((name, i) => {
header[name] = i;
colIndex[name] = i + 1; // 1-based for setValue
});
['record_uuid','ai_raw_json','schema_version','model_version','ai_status','ai_error','input_hash','last_processed_at']
.forEach(h => { if (!(h in header)) throw new Error(`Missing header: ${h}`); });
if (headerOnly) return {header, colIndex};
const rows = [];
for (let i = 1; i < values.length; i++) {
const rowArr = values[i];
const obj = {};
headerRow.forEach((name, idx) => obj[name] = rowArr[idx]);
obj.row = i + 1;
rows.push(obj);
}
return {rows, header, colIndex};
}
function rowToObject(sh, row, header) {
const rowArr = sh.getRange(row, 1, 1, Object.keys(header).length).getValues()[0];
const obj = {};
Object.keys(header).forEach(h => obj[h] = rowArr[header[h]]);
obj.row = row;
return obj;
}
function setUuid(sh, row, col) {
const id = Utilities.getUuid();
sh.getRange(row, col).setValue(id);
return id;
}
function pickInputs(r, header) {
const input = {};
CONFIG.inputFields.forEach(f => {
if (!(f in header)) throw new Error(`Missing input field: ${f}`);
input[f] = r[header[f]];
});
return input;
}
function mapToColumns(sh, row, ai, colIndex) {
Object.entries(CONFIG.outputMap).forEach(([jsonKey, headerName]) => {
const col = colIndex[headerName];
if (!col) return; // skip unmapped columns
const val = Array.isArray(ai[jsonKey]) ? ai[jsonKey].join(', ') : ai[jsonKey];
writeCell(sh, row, col, val);
});
}
function writeCell(sh, row, col, val) {
sh.getRange(row, col).setValue(val);
}
function validate(ai) {
const req = ['normalized_title','category','confidence'];
for (const k of req) {
if (!(k in ai)) throw new Error(`Schema validation failed. Missing ${k}`);
}
}
function sha256(s) {
const bytes = Utilities.newBlob(s).getBytes();
const hash = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, bytes);
return hash.map(b => (b + 256) % 256).map(n => n.toString(16).padStart(2, '0')).join('');
}
/* === AI CALL (replace with your provider) === */
function callAI(payload) {
// Example with a generic endpoint that enforces a JSON schema server-side.
const url = 'https://api.your-llm.com/v1/enrich';
const body = {
system: 'Return JSON only that matches enrichment_v1.',
user: payload,
schema: {
name: 'enrichment_v1',
strict: true,
properties: {
normalized_title: {type:'string'},
category: {type:'string'},
tags: {type:'array', items:{type:'string'}},
confidence: {type:'number'}
},
required: ['normalized_title','category','confidence']
}
};
// Replace headers and auth for your provider.
const res = UrlFetchApp.fetch(url, {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify(body),
muteHttpExceptions: true,
headers: { Authorization: 'Bearer YOUR_API_KEY' }
});
if (res.getResponseCode() >= 300) {
throw new Error(`AI request failed: ${res.getResponseCode()} ${res.getContentText()}`);
}
let out = JSON.parse(res.getContentText());
// Ensure we return a plain object with expected keys.
return {
normalized_title: out.normalized_title,
category: out.category,
tags: out.tags || [],
confidence: out.confidence
};
}
What this script does
- Looks up columns by header name each run.
- Generates a UUID if missing.
- Hashes the input fields and skips rows that have not changed.
- Calls your AI with a schema.
- Writes raw JSON into ai_raw_json.
- Maps to typed columns.
- Tracks versions, status, error, and timestamps.
Create a time-driven trigger (Edit -> Current project’s triggers) to run processChangedRows every 10 or 15 minutes, or run it from the custom menu.
Batching and recovery
- Keep maxPerRun modest to respect quotas.
- If anything fails, the row keeps ai_status = error with a short reason in ai_error. Filter that column to re-run only the broken rows.
- For bulk replays, clear input_hash for the rows you want to force.
Guardrails for Google Sheets
- Avoid row numbers for targeting. Always upsert by record_uuid.
- Keep formulas out of the AI write-back columns. Use values only.
- Hide ai_raw_json from casual users to reduce noise.
- If you change the JSON contract, bump schema_version and roll out in one sheet copy before you touch production data.