1. Introduction
Google Workspace Studio lets you automate business processes across Google Workspace applications like Gmail, Google Chat, Google Drive, and Google Sheets. When your workflows need to start from an external web application or run custom logic in your own cloud environment, you can extend Google Workspace Studio with an HTTP Google Workspace Add-on in Node.js on Cloud Run:
- Custom starter step (
workflowTrigger): Starts a Google Workspace Studio flow when an external event occurs—such as a customer submitting a ticket in an internal web portal—by calling the trigger'snotifyUri(or the Google Workspace Studiotriggers.fireREST API). - Custom action step (
workflowAction): Receives variables from earlier steps in the flow, runs custom logic or AI inference in your Node.js service, and returns output variables back to Google Workspace Studio.
In this codelab, you build and deploy a complete Node.js HTTP extension for Google Workspace Studio on Cloud Run, use Cloud Firestore to persist active flow trigger subscriptions and submitted tickets across container revisions, use Cloud Tasks to automatically retry trigger notifications if notifyUri does not return a success response, and classify ticket urgency in your custom action step using Gemini 3.8 Flash (gemini-3.8-flash) on the Gemini Enterprise Agent Platform with a resilient keyword-matching fallback.
What you'll build
- A Support Quick Portal web app and HTTP Google Workspace Add-on built with Node.js, Express, Firestore, and Cloud Tasks on Cloud Run.
- A New Support Ticket custom starter step (
supportTicketTrigger) that persists active flow subscriptions in astudio_triggersFirestore collection, logs submitted tickets in asupport_ticketsFirestore collection, and fires whenever a ticket is submitted in the web portal—passingticketTitleandticketDescriptioninto a Google Workspace Studio flow. - A Cloud Tasks retry worker (
studio-trigger-retriesqueue andPOST /api/tasks/fire-trigger) that enqueues and retries failed trigger calls with exponential backoff using the latestuserOAuthTokenstored in Firestore, guaranteeing delivery tonotifyUri. - A Detect Urgency custom action step (
urgencyDetectorStep) that classifies ticket urgency using Gemini 3.8 Flash (gemini-3.8-flash) on the Gemini Enterprise Agent Platform (aiplatform.googleapis.com), with an automatic fallback to keyword matching (urgent,blocker,critical).
Architecture
The following diagram illustrates how the Support Quick Portal, your Node.js add-on on Cloud Run, Cloud Firestore, Cloud Tasks (studio-trigger-retries), Gemini 3.8 Flash, and Google Workspace Studio interact from flow registration through resilient trigger delivery and action execution:

What you'll learn
- How to configure an HTTP Google Workspace Add-on deployment manifest (
deployment.json) withworkflowTriggerandworkflowActionelements. - How to persist Google Workspace Studio trigger subscriptions (
triggerId,notifyUri, anduserOAuthToken) in astudio_triggersFirestore collection and fire flows usingnotifyUri/triggers.fire. - How to use Cloud Tasks (
@google-cloud/tasks) to retry failed trigger notifications with exponential backoff untilnotifyUrireturns a2xxsuccess response. - How to build a
workflowActioncustom step in Node.js using Gemini 3.8 Flash (gemini-3.8-flash) structured output on the Gemini Enterprise Agent Platform with a keyword fallback.
Prerequisites
- A Google Workspace account with access to Google Workspace Studio.
- A Google Cloud project with billing enabled, created within the same Google Workspace organization as your user account so you can install unpublished add-on deployments for testing.
2. Set up your Google Cloud project, Firestore, and Cloud Tasks
In this step, you configure your project's OAuth consent screen, enable the required Google Cloud and Google Workspace APIs in Cloud Shell, provision your (default) Firestore database, create your Cloud Tasks retry queue, and initialize your add-on authorization service account.
- Open the Google Cloud console, select your Google Cloud project, and click the Activate Cloud Shell icon in the top navigation bar.
- Configure the OAuth consent screen so Google Workspace can provision an OAuth client for your add-on:
- Open the Google Auth Platform Branding page.
- If prompted, click Get Started. (If your consent screen is already configured, skip to step 3.)
- Under App Information, enter
Support Extensionfor the App name, select your User support email, and click Next. - Under Audience, select Internal and click Next.
- Under Contact Information, enter your email address and click Next.
- Under Finish, agree to the Google API Services: User Data Policy, click Continue, and click Create.
- In Cloud Shell, set your active project (replace
YOUR_PROJECT_IDwith your Google Cloud project ID) and enable the required APIs:gcloud config set project YOUR_PROJECT_ID gcloud services enable \ aiplatform.googleapis.com \ firestore.googleapis.com \ cloudtasks.googleapis.com \ run.googleapis.com \ cloudbuild.googleapis.com \ artifactregistry.googleapis.com \ appsmarket-component.googleapis.com \ gsuiteaddons.googleapis.com \ workspacestudio.googleapis.com - Provision your
(default)Firestore database and thestudio-trigger-retriesCloud Tasks queue inus-central1(if they don't already exist), and initialize your Google Workspace Add-ons service account:gcloud firestore databases describe --database="(default)" >/dev/null 2>&1 || \ gcloud firestore databases create \ --database="(default)" \ --location=us-central1 \ --type=firestore-native \ --quiet gcloud tasks queues describe studio-trigger-retries --location=us-central1 >/dev/null 2>&1 || \ gcloud tasks queues create studio-trigger-retries \ --location=us-central1 \ --max-attempts=5 \ --min-backoff=10s \ --max-backoff=300s \ --max-doublings=4 gcloud workspace-add-ons get-authorization
3. Build the Node.js app with Firestore, Cloud Tasks, and Gemini 3.8 Flash
In this step, you build the Node.js application in ~/studio-support-addon. To keep the code modular and readable, you organize the project into six focused files:
firestore.js: Initializes the Cloud Firestore client (studio_triggersandsupport_ticketscollections) and refreshes cached OAuth tokens.starter.js: Implements the New Support Ticket custom starter step webhooks (/onConfigTriggerand/onManageTrigger).action.js: Implements the Detect Urgency custom action step webhooks (/onConfigUrgencyand/onExecuteUrgency) using Gemini 3.8 Flash (gemini-3.8-flash) with a keyword-matching fallback.tasks.js: Dispatches trigger notifications totrigger.notifyUri(falling back tohttps://workspacestudio.googleapis.com/v1/triggers/{triggerId}:fire), enqueues failed deliveries into thestudio-trigger-retriesCloud Tasks queue, and implements thePOST /api/tasks/fire-triggerretry worker endpoint.portal.js: Serves the Support Quick Portal web UI (GET /) and fires active Google Workspace Studio triggers (POST /api/submit-ticket), with an option to simulate a transient failure and watch Cloud Tasks retry.index.js: Wires all Express routes together and starts the HTTP server.
Initialize the project and package.json
- Create a project directory and navigate into it:
mkdir -p ~/studio-support-addon && cd ~/studio-support-addon - Create
package.jsonwithexpress,@google-cloud/firestore,@google-cloud/tasks, and@google/genai:cat << 'EOF' > package.json { "name": "studio-support-addon", "version": "1.0.0", "type": "module", "main": "index.js", "scripts": { "start": "node index.js" }, "dependencies": { "@google-cloud/firestore": "^7.11.0", "@google-cloud/tasks": "^5.6.0", "@google/genai": "^1.0.0", "express": "^4.21.2" } } EOF
firestore.js — Shared Firestore state and OAuth token refresh
Create firestore.js to manage your two Firestore collections and keep cached user OAuth tokens fresh:
studio_triggers: Stores active Google Workspace Studio flow subscriptions (triggerId,notifyUri, anduserOAuthToken) so they survive Cloud Run container restarts and scale-to-zero events.support_tickets: Stores a record of each support ticket submitted through the portal.refreshTokenInFirestore(req): Whenever Google Workspace Studio calls any of your webhook endpoints, it includes a short-livedauthorizationEventObject.userOAuthToken. This helper updates active trigger documents with the latest token so backgroundtriggers.firecalls remain authorized.
cat << 'EOF' > firestore.js
import { Firestore } from '@google-cloud/firestore';
export const db = new Firestore({
projectId: process.env.GOOGLE_CLOUD_PROJECT || undefined,
databaseId: '(default)',
ignoreUndefinedProperties: true,
});
export const triggersCollection = db.collection(
process.env.FIRESTORE_TRIGGERS_COLLECTION || 'studio_triggers'
);
export const ticketsCollection = db.collection(
process.env.FIRESTORE_TICKETS_COLLECTION || 'support_tickets'
);
// Keep cached userOAuthToken fresh across active trigger documents
export async function refreshTokenInFirestore(req) {
const freshToken = req.body?.authorizationEventObject?.userOAuthToken;
if (!freshToken) return;
try {
const snapshot = await triggersCollection.get();
if (snapshot.empty) return;
const batch = db.batch();
snapshot.docs.forEach((doc) => {
batch.set(
doc.ref,
{ userOAuthToken: freshToken, updatedAt: new Date().toISOString() },
{ merge: true }
);
});
await batch.commit();
} catch (err) {
console.error('Error refreshing OAuth token in Firestore:', err.message);
}
}
EOF
starter.js — Custom Starter Step (workflowTrigger)
Create starter.js to implement the two webhook endpoints required by a Google Workspace Studio custom starter step:
onConfigTrigger(POST /onConfigTrigger): Called when a user selects New Support Ticket as their flow's starter in Google Workspace Studio. Returns apushCardnavigation action describing the starter.onManageTrigger(POST /onManageTrigger): Called when a user turns a flow On (req.body.workflow.triggerCreation) or turns it Off / deletes it (req.body.workflow.triggerDeletion). Saves or deletes the subscription document in thestudio_triggersFirestore collection.
cat << 'EOF' > starter.js
import { triggersCollection, refreshTokenInFirestore } from './firestore.js';
// Custom Starter Configuration Card
export async function onConfigTrigger(req, res) {
await refreshTokenInFirestore(req);
res.status(200).json({
action: {
navigations: [
{
pushCard: {
sections: [
{
header: 'Support Ticket Listener',
widgets: [
{
textParagraph: {
text: 'This starter listens for new tickets submitted through the Support Quick Portal and stores subscriptions in Firestore.',
},
},
],
},
],
},
},
],
},
});
}
// Custom Starter Subscription Lifecycle (Firestore studio_triggers collection)
export async function onManageTrigger(req, res) {
try {
const triggerCreation = req.body?.workflow?.triggerCreation;
const triggerDeletion = req.body?.workflow?.triggerDeletion;
const userOAuthToken =
req.body?.authorizationEventObject?.userOAuthToken || null;
if (triggerCreation?.triggerId) {
const triggerId = String(triggerCreation.triggerId);
const docId = encodeURIComponent(triggerId);
await triggersCollection.doc(docId).set(
{
triggerId,
notifyUri: triggerCreation.notifyUri || null,
...(userOAuthToken ? { userOAuthToken } : {}),
updatedAt: new Date().toISOString(),
},
{ merge: true }
);
}
if (triggerDeletion?.triggerId) {
const triggerId = String(triggerDeletion.triggerId);
const docId = encodeURIComponent(triggerId);
await triggersCollection.doc(docId).delete();
}
if (!triggerCreation && !triggerDeletion && userOAuthToken) {
await refreshTokenInFirestore(req);
}
} catch (err) {
console.error('Error in /onManageTrigger:', err.message);
}
res.status(200).json({});
}
EOF
action.js — Custom Action Step (workflowAction) with Gemini 3.8 Flash
Create action.js to implement the two webhook endpoints required by a Google Workspace Studio custom action step:
onConfigUrgency(POST /onConfigUrgency): Returns a configuration card with atextInputwidget that setshostAppDataSource.workflowDataSource.includeVariables: true, allowing users to select theticketDescriptionvariable outputted by the starter step.onExecuteUrgency(POST /onExecuteUrgency): Reads theticketDescriptioninput fromreq.body.workflow.actionInvocation.inputs, classifies its business impact and urgency using Gemini 3.8 Flash (gemini-3.8-flash) on the Gemini Enterprise Agent Platform with structured output (responseSchemaenum["High", "Normal"]), falls back todetectKeywordUrgency()(urgent,blocker,critical) if the model call fails, and returns theurgencyLeveloutput variable usinghostAppAction.workflowAction.returnOutputVariablesAction.
cat << 'EOF' > action.js
import { GoogleGenAI, Type } from '@google/genai';
import { refreshTokenInFirestore } from './firestore.js';
// Initialize Google GenAI SDK for the Gemini Enterprise Agent Platform
const ai = new GoogleGenAI({
vertexai: true,
project: process.env.GOOGLE_CLOUD_PROJECT,
location: 'global',
});
// Fallback keyword classifier if the Gemini API call is unavailable
export function detectKeywordUrgency(description) {
const lower = (description || '').toLowerCase();
return lower.includes('urgent') ||
lower.includes('blocker') ||
lower.includes('critical')
? 'High'
: 'Normal';
}
// Custom Action Step Configuration Card
export async function onConfigUrgency(req, res) {
await refreshTokenInFirestore(req);
res.status(200).json({
action: {
navigations: [
{
pushCard: {
sections: [
{
header: 'Urgency Detector Step',
widgets: [
{
textInput: {
name: 'ticketDescription',
label: 'Source Description',
hintText:
'Select the description variable outputted by the starter',
hostAppDataSource: {
workflowDataSource: {
includeVariables: true,
},
},
},
},
],
},
],
},
},
],
},
});
}
// Custom Action Step Execution: Gemini 3.8 Flash with keyword fallback
export async function onExecuteUrgency(req, res) {
await refreshTokenInFirestore(req);
const inputs = req.body?.workflow?.actionInvocation?.inputs || {};
const description = inputs.ticketDescription?.stringValues?.[0] || '';
let urgencyLevel = detectKeywordUrgency(description);
try {
const response = await ai.models.generateContent({
model: 'gemini-3.8-flash',
contents: `Evaluate the business impact and time sensitivity of this support ticket description and classify its urgency:\n\n${description}`,
config: {
responseMimeType: 'text/x.enum',
responseSchema: {
type: Type.STRING,
enum: ['High', 'Normal'],
},
},
});
const candidate = (response.text || '').trim();
if (candidate === 'High' || candidate === 'Normal') {
urgencyLevel = candidate;
}
} catch (err) {
console.error('Gemini fallback to keyword matching:', err.message);
}
res.status(200).json({
hostAppAction: {
workflowAction: {
returnOutputVariablesAction: {
variables: {
urgencyLevel: {
stringValues: [urgencyLevel],
},
},
},
},
},
});
}
EOF
tasks.js — Cloud Tasks retry queue and worker endpoint
Create tasks.js to dispatch trigger calls to trigger.notifyUri (falling back to https://workspacestudio.googleapis.com/v1/triggers/{triggerId}:fire), enqueue failed calls into your studio-trigger-retries Cloud Tasks queue, and process retries at POST /api/tasks/fire-trigger:
resolveTriggerUrl(trigger): Usestrigger.notifyUripersisted fromtriggerCreation.notifyUriwhen present, or falls back tohttps://workspacestudio.googleapis.com/v1/triggers/{triggerId}:fire.fireTriggerDirect(trigger, payload): Sends thePOSTrequest to the trigger URL with theticketTitleandticketDescriptionoutput variables and an idempotentrequestId.enqueueTriggerRetry(req, payload): Enqueues an HTTP task in thestudio-trigger-retriesqueue targeting your Cloud Run service'sPOST /api/tasks/fire-triggerendpoint whenever the initial trigger call fails or returns a non-2xx HTTP status.handleFireTriggerTask(POST /api/tasks/fire-trigger): Invoked by Cloud Tasks on each retry attempt. Looks up the latesttriggerdocument instudio_triggersfrom Firestore (ensuring retries always use the freshestuserOAuthToken), checks theX-CloudTasks-TaskRetryCountheader (ifsimulateFailureis enabled, it fails the first Cloud Task execution with503so you can watch Cloud Tasks back off and succeed on retry#1), and returns200 OKonly whennotifyUrireturns a2xxsuccess response. Any5xxresponse tells Cloud Tasks to retry again according to your queue's backoff policy.
cat << 'EOF' > tasks.js
import { CloudTasksClient } from '@google-cloud/tasks';
import { triggersCollection } from './firestore.js';
const tasksClient = new CloudTasksClient();
const PROJECT_ID = process.env.GOOGLE_CLOUD_PROJECT;
const LOCATION = process.env.CLOUD_TASKS_LOCATION || 'us-central1';
const QUEUE = process.env.CLOUD_TASKS_QUEUE || 'studio-trigger-retries';
// Prefer trigger.notifyUri from triggerCreation, falling back to triggers.fire
export function resolveTriggerUrl(trigger) {
return (
trigger.notifyUri ||
`https://workspacestudio.googleapis.com/v1/triggers/${encodeURIComponent(trigger.triggerId)}:fire`
);
}
// Call the Google Workspace Studio trigger URL directly
export async function fireTriggerDirect(
trigger,
{ title, description, requestId }
) {
const targetUrl = resolveTriggerUrl(trigger);
const response = await fetch(targetUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${trigger.userOAuthToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'triggers/' + trigger.triggerId,
outputs: {
ticketTitle: { stringValues: [title] },
ticketDescription: { stringValues: [description] },
},
requestId,
}),
});
return { ok: response.ok, status: response.status, targetUrl };
}
// Enqueue a retry task in Cloud Tasks targeting POST /api/tasks/fire-trigger
export async function enqueueTriggerRetry(req, payload) {
const parent = tasksClient.queuePath(PROJECT_ID, LOCATION, QUEUE);
const baseUrl = process.env.SERVICE_URL || `https://${req.get('host')}`;
const workerUrl = `${baseUrl}/api/tasks/fire-trigger`;
const task = {
httpRequest: {
httpMethod: 'POST',
url: workerUrl,
headers: { 'Content-Type': 'application/json' },
body: Buffer.from(JSON.stringify(payload)).toString('base64'),
},
};
const [response] = await tasksClient.createTask({ parent, task });
return response.name;
}
// Cloud Tasks worker endpoint: retries calling notifyUri using fresh Firestore state
export async function handleFireTriggerTask(req, res) {
const { triggerId, title, description, requestId, simulateFailure } =
req.body || {};
const retryCount = Number(req.get('X-CloudTasks-TaskRetryCount') || 0);
if (!triggerId) {
return res.status(200).json({ skipped: true, reason: 'Missing triggerId' });
}
try {
const docId = encodeURIComponent(String(triggerId));
const doc = await triggersCollection.doc(docId).get();
if (!doc.exists) {
return res
.status(200)
.json({ skipped: true, reason: 'Trigger no longer active' });
}
const trigger = doc.data();
if (!trigger.userOAuthToken) {
return res
.status(503)
.json({ ok: false, error: 'Missing userOAuthToken in Firestore' });
}
// When simulateFailure is enabled, fail the 1st Cloud Task attempt (retryCount === 0)
// so learners can observe Cloud Tasks back off and succeed on retry #1.
if (simulateFailure && retryCount < 1) {
console.warn(
`[Cloud Tasks] Simulated transient failure on initial task attempt (retryCount=${retryCount}) for trigger ${triggerId}`
);
return res
.status(503)
.json({ ok: false, simulated: true, retryCount, triggerId });
}
const outcome = await fireTriggerDirect(trigger, {
title,
description,
requestId,
});
if (outcome.ok) {
console.log(
`[Cloud Tasks] Successfully delivered trigger ${triggerId} to ${outcome.targetUrl} on retryCount=${retryCount}`
);
return res
.status(200)
.json({ ok: true, status: outcome.status, retryCount });
}
console.error(
`[Cloud Tasks] Non-2xx response (${outcome.status}) from ${outcome.targetUrl} on retryCount=${retryCount}`
);
return res
.status(502)
.json({ ok: false, status: outcome.status, retryCount });
} catch (err) {
console.error(
`[Cloud Tasks] Error firing trigger ${triggerId} on retryCount=${retryCount}:`,
err.message
);
return res
.status(503)
.json({ ok: false, error: err.message, retryCount });
}
}
EOF
portal.js — Support Quick Portal UI and notifyUri dispatcher with Cloud Tasks fallback
Create portal.js to serve the web portal and dispatch ticket submissions to Google Workspace Studio with automatic Cloud Tasks retry scheduling:
renderPortal(GET /): Queries thestudio_triggersFirestore collection to display how many active Google Workspace Studio triggers are registered, and serves the HTML ticket submission form (including a Simulate transient failure checkbox to test Cloud Tasks retries).submitTicket(POST /api/submit-ticket): Iterates through all active trigger documents instudio_triggersand callsfireTriggerDirect(). If the trigger URL returns a non-2xx response, throws a network error, or hassimulateFailureenabled, it automatically enqueues a retry task instudio-trigger-retriesusingenqueueTriggerRetry()and logs the ticket in thesupport_ticketsFirestore collection.
cat << 'EOF' > portal.js
import { triggersCollection, ticketsCollection } from './firestore.js';
import { fireTriggerDirect, enqueueTriggerRetry } from './tasks.js';
// Support Quick Portal UI
export async function renderPortal(req, res) {
let activeCount = 0;
try {
const snapshot = await triggersCollection.get();
activeCount = snapshot.size;
} catch (err) {
console.error('Error reading studio_triggers collection:', err.message);
}
res.status(200).send(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Support Quick Portal</title>
<style>
body { font-family: Arial, sans-serif; padding: 24px; max-width: 540px; margin: auto; color: #202124; }
.badge { background: #e8f0fe; color: #1967d2; padding: 8px 12px; border-radius: 6px; font-size: 14px; margin-bottom: 16px; }
label { font-weight: bold; display: block; margin-top: 16px; }
.checkbox-label { font-weight: normal; margin-top: 14px; display: flex; align-items: center; gap: 8px; cursor: pointer; }
.checkbox-label input { width: auto; margin: 0; }
input, textarea { width: 100%; margin-top: 6px; padding: 10px; box-sizing: border-box; border: 1px solid #dadce0; border-radius: 4px; }
button { background-color: #1a73e8; color: white; border: none; padding: 10px 18px; margin-top: 16px; font-weight: bold; cursor: pointer; border-radius: 4px; }
button:hover { background-color: #1557b0; }
#status { margin-top: 16px; font-weight: bold; }
</style>
</head>
<body>
<h2>Support Quick Portal</h2>
<div class="badge">
Active Google Workspace Studio triggers in Firestore (<code>studio_triggers</code>):
<strong id="trigger-count">${activeCount}</strong>
</div>
<p>Submit a support ticket below to fire your Google Workspace Studio flow.</p>
<label for="title">Ticket Title</label>
<input type="text" id="title" placeholder="e.g. Database failure">
<label for="description">Issue Description</label>
<textarea id="description" rows="4" placeholder="Describe the issue..."></textarea>
<label class="checkbox-label" for="simulateFailure">
<input type="checkbox" id="simulateFailure">
Simulate transient failure (enqueue in Cloud Tasks and retry notifyUri)
</label>
<button onclick="submitTicket()">Submit Ticket</button>
<div id="status"></div>
<script>
async function submitTicket() {
const title = document.getElementById('title').value.trim();
const description = document.getElementById('description').value.trim();
const simulateFailure = document.getElementById('simulateFailure').checked;
const statusEl = document.getElementById('status');
if (!title || !description) {
statusEl.style.color = '#d93025';
statusEl.textContent = 'Please fill out both fields.';
return;
}
statusEl.style.color = '#188038';
statusEl.textContent = 'Submitting ticket...';
try {
const res = await fetch('/api/submit-ticket', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, description, simulateFailure }),
});
const data = await res.json();
if (data.ok) {
if (data.tasksQueued > 0) {
statusEl.textContent =
'Ticket saved! Initial notifyUri call did not succeed; queued ' +
data.tasksQueued +
' retry task(s) in Cloud Tasks.';
} else {
statusEl.textContent =
'Ticket saved to Firestore! Fired ' +
data.triggersFired +
' Google Workspace Studio trigger(s).';
}
} else {
statusEl.style.color = '#d93025';
statusEl.textContent = 'Error: ' + (data.error || 'Unknown error');
}
} catch (err) {
statusEl.style.color = '#d93025';
statusEl.textContent = 'Request failed: ' + err.message;
}
}
</script>
</body>
</html>`);
}
// Portal Form Submission -> Call notifyUri directly, or enqueue Cloud Task on failure
export async function submitTicket(req, res) {
const title = req.body?.title || 'Untitled Ticket';
const description = req.body?.description || '';
const simulateFailure = Boolean(req.body?.simulateFailure);
const results = [];
let triggersFired = 0;
let tasksQueued = 0;
try {
const snapshot = await triggersCollection.get();
for (const doc of snapshot.docs) {
const trigger = doc.data();
if (!trigger.triggerId || !trigger.userOAuthToken) continue;
const requestId =
'req_' + Date.now() + '_' + Math.floor(Math.random() * 1000);
let outcome = { ok: false, status: 503 };
if (!simulateFailure) {
try {
outcome = await fireTriggerDirect(trigger, {
title,
description,
requestId,
});
} catch (fetchErr) {
console.error(
`Direct trigger call failed for ${trigger.triggerId}:`,
fetchErr.message
);
outcome = { ok: false, status: 0, error: fetchErr.message };
}
}
if (outcome.ok) {
triggersFired++;
results.push({
triggerId: trigger.triggerId,
status: outcome.status,
queuedRetry: false,
});
} else {
const taskName = await enqueueTriggerRetry(req, {
triggerId: trigger.triggerId,
title,
description,
requestId,
simulateFailure,
});
tasksQueued++;
results.push({
triggerId: trigger.triggerId,
status: outcome.status,
queuedRetry: true,
taskName,
});
}
}
await ticketsCollection.add({
title,
description,
triggersFired,
tasksQueued,
createdAt: new Date().toISOString(),
});
res.status(200).json({
ok: true,
triggersFired,
tasksQueued,
results,
});
} catch (err) {
console.error('Error in /api/submit-ticket:', err.message);
res.status(500).json({ ok: false, error: err.message });
}
}
EOF
index.js — Express server entry point
Create index.js to wire your portal routes, Cloud Tasks retry worker endpoint, and Google Workspace Studio webhook endpoints together into an Express HTTP server listening on PORT (default 8080 on Cloud Run):
cat << 'EOF' > index.js
import express from 'express';
import { onConfigTrigger, onManageTrigger } from './starter.js';
import { onConfigUrgency, onExecuteUrgency } from './action.js';
import { renderPortal, submitTicket } from './portal.js';
import { handleFireTriggerTask } from './tasks.js';
const app = express();
app.use(express.json());
// Support Quick Portal routes
app.get('/', renderPortal);
app.post('/api/submit-ticket', submitTicket);
// Cloud Tasks retry worker route
app.post('/api/tasks/fire-trigger', handleFireTriggerTask);
// Google Workspace Studio Custom Starter Step webhooks
app.post('/onConfigTrigger', onConfigTrigger);
app.post('/onManageTrigger', onManageTrigger);
// Google Workspace Studio Custom Action Step webhooks
app.post('/onConfigUrgency', onConfigUrgency);
app.post('/onExecuteUrgency', onExecuteUrgency);
const PORT = process.env.PORT || 8080;
app.listen(PORT, () => {
console.log(`Support Extension listening on port ${PORT}`);
});
EOF
4. Deploy to Cloud Run and install the add-on
In this step, you deploy the Node.js service to Cloud Run, grant IAM permissions for the Google Workspace Add-ons invoker and Firestore, Cloud Tasks, and Agent Platform runtime access, generate the deployment.json manifest, and install the add-on deployment.
- Deploy the
support-extensionservice to Cloud Run with your project, Firestore collection, and Cloud Tasks queue environment variables:gcloud run deploy support-extension \ --source . \ --region us-central1 \ --allow-unauthenticated \ --set-env-vars="GOOGLE_CLOUD_PROJECT=$(gcloud config get-value project),FIRESTORE_TRIGGERS_COLLECTION=studio_triggers,FIRESTORE_TICKETS_COLLECTION=support_tickets,CLOUD_TASKS_QUEUE=studio-trigger-retries,CLOUD_TASKS_LOCATION=us-central1" \ --quiet - Grant
roles/run.invokeron the Cloud Run service to the Google Workspace Add-ons service account so Google Workspace Studio can call your webhook endpoints:ADDON_SA=$(gcloud workspace-add-ons get-authorization --format="value(serviceAccountEmail)") gcloud run services add-iam-policy-binding support-extension \ --region=us-central1 \ --member="serviceAccount:${ADDON_SA}" \ --role="roles/run.invoker" \ --quiet - Grant
roles/datastore.user(for reading and writing thestudio_triggersandsupport_ticketsFirestore collections),roles/cloudtasks.enqueuer(for enqueueing retry tasks intostudio-trigger-retries), androles/aiplatform.user(for calling Gemini on the Gemini Enterprise Agent Platform) to the Cloud Run runtime service account:PROJECT_ID=$(gcloud config get-value project) PROJECT_NUMBER=$(gcloud projects describe "${PROJECT_ID}" --format="value(projectNumber)") RUNTIME_SA=$(gcloud run services describe support-extension --region=us-central1 --format="value(spec.template.spec.serviceAccountName)") RUNTIME_SA=${RUNTIME_SA:-"${PROJECT_NUMBER}-compute@developer.gserviceaccount.com"} gcloud projects add-iam-policy-binding "${PROJECT_ID}" \ --member="serviceAccount:${RUNTIME_SA}" \ --role="roles/datastore.user" \ --condition=None \ --quiet gcloud projects add-iam-policy-binding "${PROJECT_ID}" \ --member="serviceAccount:${RUNTIME_SA}" \ --role="roles/cloudtasks.enqueuer" \ --condition=None \ --quiet gcloud projects add-iam-policy-binding "${PROJECT_ID}" \ --member="serviceAccount:${RUNTIME_SA}" \ --role="roles/aiplatform.user" \ --condition=None \ --quiet - Retrieve your deployed Cloud Run URL and generate the
deployment.jsonmanifest defining thesupportTicketTrigger(workflowTrigger) andurgencyDetectorStep(workflowAction) elements:SERVICE_URL=$(gcloud run services describe support-extension --region=us-central1 --format="value(status.url)") cat << EOF > deployment.json { "oauthScopes": [ "https://www.googleapis.com/auth/workspace.studio.trigger", "https://www.googleapis.com/auth/userinfo.email" ], "addOns": { "common": { "name": "Support Extension", "logoUrl": "https://www.gstatic.com/images/branding/productlogos/buganizer/v1/192px.svg" }, "studio": { "flows": { "workflowElements": [ { "id": "supportTicketTrigger", "state": "ACTIVE", "name": "New Support Ticket", "description": "Fires when someone submits a ticket in the Support Quick Portal.", "workflowTrigger": { "inputs": [], "outputs": [ { "id": "ticketTitle", "description": "Ticket Title", "cardinality": "SINGLE", "dataType": { "basicType": "STRING" } }, { "id": "ticketDescription", "description": "Ticket Description", "cardinality": "SINGLE", "dataType": { "basicType": "STRING" } } ], "onConfigFunction": "${SERVICE_URL}/onConfigTrigger", "onManageFunction": "${SERVICE_URL}/onManageTrigger" } }, { "id": "urgencyDetectorStep", "state": "ACTIVE", "name": "Detect Urgency", "description": "Evaluates the ticket description to detect High or Normal urgency.", "workflowAction": { "inputs": [ { "id": "ticketDescription", "description": "Description to scan", "cardinality": "SINGLE", "dataType": { "basicType": "STRING" } } ], "outputs": [ { "id": "urgencyLevel", "description": "Detected Urgency (High / Normal)", "cardinality": "SINGLE", "dataType": { "basicType": "STRING" } } ], "onConfigFunction": "${SERVICE_URL}/onConfigUrgency", "onExecuteFunction": "${SERVICE_URL}/onExecuteUrgency" } } ] } }, "httpOptions": { "authorizationHeader": "SYSTEM_ID_TOKEN" } } } EOF - Create (or replace) and install the Google Workspace Add-on deployment for your account, and print your Support Quick Portal URL:
gcloud workspace-add-ons deployments create support-extension --deployment-file=deployment.json || \ gcloud workspace-add-ons deployments replace support-extension --deployment-file=deployment.json gcloud workspace-add-ons deployments install support-extension echo "Support Quick Portal URL: ${SERVICE_URL}"
5. Test your custom steps and Cloud Tasks retries
- Open Google Workspace Studio (refresh the browser tab if it was already open) and click New Flow.
- Click Select a starter > Support Extension > New Support Ticket.
- Click Add step (
+) > Support Extension > Detect Urgency. In the Source Description field, click + Variables and select Ticket Description. - Click Add step (
+) and add a Chat > Notify me in Chat step that includes the Ticket Title, Urgency Level, and Ticket Description variables in the message. - Toggle the flow On in the top-right corner. Google Workspace Studio immediately calls your
/onManageTriggerendpoint on Cloud Run, which writes the active trigger subscription (includingtriggerId,notifyUri, anduserOAuthToken) to thestudio_triggerscollection in Firestore. - Open your Support Quick Portal URL (
https://support-extension-...run.app) in a new tab, confirm that the portal displays1active trigger in Firestore, and submit two tickets to test Gemini 3.8 Flash (gemini-3.8-flash) classification:- High-impact ticket (Ticket Title:
London office lockout, Issue Description:All 500 employees in the London office are locked out of their workstations 20 minutes before the board presentation.): Even without hardcoded keywords likeurgentorblocker,gemini-3.8-flashclassifies this ticket asHighurgency. - Routine ticket (Ticket Title:
Conference room adapters, Issue Description:Could we order two extra HDMI adapters for the third-floor conference room next month?):gemini-3.8-flashclassifies this ticket asNormalurgency.
- High-impact ticket (Ticket Title:
- Verify in Google Chat and in the Google Workspace Studio Activity tab that your flow ran for both tickets with
Urgency Level: HighandUrgency Level: Normal. - Test Cloud Tasks retry delivery when
notifyUrifails:- In the Support Quick Portal, check Simulate transient failure (enqueue in Cloud Tasks and retry notifyUri).
- Enter Ticket Title:
Checkout service outageand Issue Description:Production payment gateway is timing out for all customers, then click Submit Ticket. - Notice that the portal responds immediately with:
Ticket saved! Initial notifyUri call did not succeed; queued 1 retry task(s) in Cloud Tasks. - In the background, Cloud Tasks executes
POST /api/tasks/fire-trigger. The first task attempt (retryCount=0) simulates a503failure, causing Cloud Tasks to back off for10s(--min-backoff=10s) and retry (retryCount=1), which callsnotifyUriand succeeds. After about 10–15 seconds, verify that your Google Chat notification arrives!
6. Clean up
To avoid incurring ongoing charges to your Google Cloud account, remove the deployed resources in Cloud Shell:
gcloud workspace-add-ons deployments uninstall support-extension
gcloud workspace-add-ons deployments delete support-extension --quiet
gcloud run services delete support-extension --region=us-central1 --quiet
gcloud tasks queues delete studio-trigger-retries --location=us-central1 --quiet
gcloud firestore databases delete --database="(default)" --quiet
7. Congratulations!
You built, deployed, and tested a Node.js HTTP extension for Google Workspace Studio using Firestore, Cloud Tasks, Cloud Run, and Gemini 3.8 Flash on the Gemini Enterprise Agent Platform!
What we've covered
- Configuring a Google Workspace Studio HTTP Add-on manifest (
deployment.json) withworkflowTriggerandworkflowActionelements. - Persisting Google Workspace Studio
workflowTriggersubscriptions in thestudio_triggersFirestore collection and logging portal submissions insupport_ticketsso triggers survive Cloud Run scaling and redeployments. - Guaranteeing
notifyUri(triggers.fire) delivery with Cloud Tasks (studio-trigger-retries) so transient HTTP failures are automatically retried with exponential backoff using the latestuserOAuthTokenfrom Firestore. - Building a
workflowActionstep that classifies support tickets with Gemini 3.8 Flash (gemini-3.8-flash) structured output on the Gemini Enterprise Agent Platform, with a resilient keyword-matching fallback.