Yes. The next stable move is to canonize the Apps Script interface, not the individual scripts.
Your GAS logic can stay diverse internally. What must become uniform is: • request envelope • response envelope • auth • error format • trace/log ids • action naming • payload shape
Canonical contract
Request from your app to GAS
type GoogleToolRequest<TInput = unknown> = { action: string; requestId: string; source: "folio" | "trip" | "product" | "experience" | "offer" | "assistant"; entityType?: string; entityId?: string | number; documentId?: number; userId?: string; payload: TInput; };
Response from GAS to your app
type GoogleToolResponse<TResult = unknown> = { ok: boolean; action: string; requestId: string; timestamp: string; result?: TResult; error?: { code: string; message: string; details?: unknown; }; meta?: { spreadsheetId?: string; documentId?: string; fileId?: string; fileUrl?: string; rowsProcessed?: number; batchComplete?: boolean; nextCursor?: string | number | null; durationMs?: number; }; };
⸻
Canonical Apps Script router
Instead of exposing many ad hoc script entrypoints, use one doPost(e) router.
function doPost(e) { const startedAt = Date.now();
try { const body = JSON.parse(e.postData.contents || "{}"); const action = body.action; const requestId = body.requestId || Utilities.getUuid();
if (!action) {
return jsonResponse({
ok: false,
action: "unknown",
requestId,
timestamp: new Date().toISOString(),
error: {
code: "MISSING_ACTION",
message: "Missing action field",
},
meta: { durationMs: Date.now() - startedAt },
});
}
let result;
switch (action) {
case "coreOffice.generateTemplates":
result = CoreOfficeHandlers.generateTemplates(body.payload || {});
break;
case "coreOffice.generateEntityDocs":
result = CoreOfficeHandlers.generateEntityDocs(body.payload || {});
break;
case "docs.create":
result = DocsHandlers.create(body.payload || {});
break;
case "docs.exportPdf":
result = DocsHandlers.exportPdf(body.payload || {});
break;
case "gmail.send":
result = GmailHandlers.send(body.payload || {});
break;
case "calendar.createEvent":
result = CalendarHandlers.createEvent(body.payload || {});
break;
case "sheets.appendRows":
result = SheetsHandlers.appendRows(body.payload || {});
break;
default:
return jsonResponse({
ok: false,
action,
requestId,
timestamp: new Date().toISOString(),
error: {
code: "UNKNOWN_ACTION",
message: "Unsupported action: " + action,
},
meta: { durationMs: Date.now() - startedAt },
});
}
return jsonResponse({
ok: true,
action,
requestId,
timestamp: new Date().toISOString(),
result: result || {},
meta: {
durationMs: Date.now() - startedAt,
},
});
} catch (err) { return jsonResponse({ ok: false, action: "unhandled", requestId: Utilities.getUuid(), timestamp: new Date().toISOString(), error: { code: "UNHANDLED_ERROR", message: err && err.message ? err.message : String(err), }, meta: { durationMs: Date.now() - startedAt, }, }); } }
function jsonResponse(obj) { return ContentService .createTextOutput(JSON.stringify(obj)) .setMimeType(ContentService.MimeType.JSON); }
⸻
Canonical handler style inside GAS
Your current script can be adapted very cleanly.
var CoreOfficeHandlers = (function () { function generateTemplates(payload) { CoreOfficeServer.generateTemplates();
return {
message: "Templates generated",
};
}
function generateEntityDocs(payload) { const result = CoreOfficeServer.generateEntityDocs(payload || {});
return {
message: "Entity docs processed",
rowsProcessed: result.rowsProcessed || 0,
batchComplete: result.batchComplete !== false,
nextCursor: result.nextCursor ?? null,
};
}
return { generateTemplates: generateTemplates, generateEntityDocs: generateEntityDocs, }; })();
⸻
Adapt your existing GAS function to return structured results
Your current generateEntityDocs() should stop being UI-only and return machine data.
Replace its end behavior with something like:
function generateEntityDocs(options) { const startTime = Date.now(); const configSheet = _getConfigSheet(); const configData = configSheet.getDataRange().getValues(); const configMap = {};
let totalProcessed = 0; let batchComplete = true; let nextCursor = null; const createdDocs = [];
for (let i = 1; i < configData.length; i++) { const name = configData[i][0]; if (_shouldIgnore(name)) continue; configMap[name] = { templateId: configData[i][1], lastRow: parseInt(configData[i][2], 10) || 1, configRow: i + 1 }; }
_getEntities().forEach(entity => { const conf = configMap[entity.name]; if (!conf || !conf.templateId) return;
const sheet = entity.sheet;
const data = sheet.getDataRange().getValues();
if (data.length < 2) return;
const headers = data[0];
let docIdCol = headers.length;
if (headers[docIdCol - 1] !== 'DOC ID') {
sheet.getRange(1, docIdCol + 1).setValue('DOC ID');
docIdCol = headers.length;
} else {
docIdCol = headers.length - 1;
}
const idxRefExp = headers.indexOf('RefExp');
const idxName = headers.indexOf('Name');
const idxNombre = headers.indexOf('Nombre');
let processedForEntity = 0;
for (let r = conf.lastRow; r < data.length; r++) {
if (Date.now() - startTime > TIME_LIMIT_MS) {
batchComplete = false;
nextCursor = { entity: entity.name, row: r + 1 };
return;
}
const docIdCell = sheet.getRange(r + 1, docIdCol + 1);
if (data[r][docIdCol] && data[r][docIdCol].toString().trim() !== "") {
continue;
}
let docTitle = "";
if (idxRefExp !== -1 && data[r][idxRefExp]) {
docTitle = String(data[r][idxRefExp]);
} else if (idxName !== -1 && data[r][idxName]) {
docTitle = String(data[r][idxName]);
} else if (idxNombre !== -1 && data[r][idxNombre]) {
docTitle = String(data[r][idxNombre]);
} else {
docTitle = `${entity.name} - Row ${r}`;
}
const templateFile = DriveApp.getFileById(conf.templateId);
const copyFile = templateFile.makeCopy(docTitle, _getTemplateFolder());
const doc = DocumentApp.openById(copyFile.getId());
const body = doc.getBody();
headers.forEach((h, i) => {
body.replaceText(`\\{\\{${h}\\}\\}`, data[r][i] !== undefined ? String(data[r][i]) : "");
});
doc.saveAndClose();
const url = doc.getUrl();
docIdCell.setFormula(`=HYPERLINK("${url}", "Doc Link")`);
createdDocs.push({
entity: entity.name,
row: r + 1,
title: docTitle,
fileId: doc.getId(),
url: url,
});
processedForEntity++;
totalProcessed++;
configSheet.getRange(conf.configRow, 3).setValue(r + 1);
if (processedForEntity >= BATCH_SIZE) {
batchComplete = false;
nextCursor = { entity: entity.name, row: r + 2 };
break;
}
}
});
return { rowsProcessed: totalProcessed, batchComplete: batchComplete, nextCursor: nextCursor, createdDocs: createdDocs, }; }
⸻
What your Next.js server action should do
Your server action should: 1. build canonical payload 2. call GAS endpoint 3. validate response 4. persist response meta to Prisma 5. optionally update document footer/meta/history 6. revalidate path
Example pattern
"use server";
import { revalidatePath } from "next/cache"; import { prisma } from "@/lib/prisma";
type RunGoogleToolInput<T = unknown> = { action: string; source: "folio" | "trip" | "product" | "experience" | "offer" | "assistant"; documentId?: number; entityType?: string; entityId?: string | number; payload: T; };
export async function runGoogleTool<TPayload, TResult>( input: RunGoogleToolInput<TPayload> ) { const requestId = crypto.randomUUID();
const res = await fetch(process.env.GAS_WEBAPP_URL!, { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": process.env.GAS_SHARED_SECRET!, }, body: JSON.stringify({ action: input.action, requestId, source: input.source, documentId: input.documentId, entityType: input.entityType, entityId: input.entityId, payload: input.payload, }), cache: "no-store", });
const json = await res.json();
if (!json.ok) { throw new Error(json?.error?.message || "Google tool failed"); }
if (input.documentId) {
await prisma.projectDocument.update({
where: { id: input.documentId },
data: {
data: JSON.stringify({
requestId,
lastGoogleToolAction: input.action,
lastGoogleToolAt: new Date().toISOString(),
lastGoogleToolMeta: json.meta ?? {},
}),
},
});
revalidatePath(/documents/${input.documentId});
}
return json as TResult; }
⸻
Best toolbar integration pattern
For toolbar direct commands:
Example • button: “Generate Google Doc from this document” • toolbar click • calls server action generateGoogleDocFromDocument(documentId) • server action: • reads current document data • converts to markdown/plain text • calls runGoogleTool({ action: "docs.create", ... }) • stores returned doc URL/id in document meta • revalidates
That is the right pattern.
⸻
For coaching mode
This fits very well.
Coaching mode should do: • parse user intent • fill entity form draft • precompute action payload • show user preview or form review • optional confirm • call server action • server action calls internal Prisma actions and/or GAS tools
So coaching mode becomes: • intent-to-structured-payload • not just chat
Exactly as you described.
⸻
Recommended persistent log model
Add one generic operation log table later if needed:
model ToolRunLog { id String @id @default(cuid()) source String action String requestId String @unique entityType String? entityId String? documentId Int? ok Boolean response Json? error Json? createdAt DateTime @default(now()) }
That will make the whole toolchain traceable.
⸻
Final conclusion
Your path is now clear:
Canonical rule • GAS = atomic external executors • Next actions = orchestration + persistence + routing • toolbar = command surface • sidebar = context composition • coaching mode = structured intent-to-work layer
Immediate next best move
Take your existing GAS script and wrap it in: • doPost • action • requestId • structured JSON response
Then wire one real toolbar command end-to-end: • Generate Google Doc from this document
That is the cleanest first production loop.