Build custom Google Workspace Studio steps in Node.js on Cloud Run

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:

  1. 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's notifyUri (or the Google Workspace Studio triggers.fire REST API).
  2. 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 a studio_triggers Firestore collection, logs submitted tickets in a support_tickets Firestore collection, and fires whenever a ticket is submitted in the web portal—passing ticketTitle and ticketDescription into a Google Workspace Studio flow.
  • A Cloud Tasks retry worker (studio-trigger-retries queue and POST /api/tasks/fire-trigger) that enqueues and retries failed trigger calls with exponential backoff using the latest userOAuthToken stored in Firestore, guaranteeing delivery to notifyUri.
  • 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:

Google Workspace Studio Node.js Add-on Architecture with Firestore, Cloud Tasks, and Gemini 3.8 Flash

What you'll learn

  • How to configure an HTTP Google Workspace Add-on deployment manifest (deployment.json) with workflowTrigger and workflowAction elements.
  • How to persist Google Workspace Studio trigger subscriptions (triggerId, notifyUri, and userOAuthToken) in a studio_triggers Firestore collection and fire flows using notifyUri / triggers.fire.
  • How to use Cloud Tasks (@google-cloud/tasks) to retry failed trigger notifications with exponential backoff until notifyUri returns a 2xx success response.
  • How to build a workflowAction custom 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.

  1. Open the Google Cloud console, select your Google Cloud project, and click the Activate Cloud Shell icon in the top navigation bar.
  2. Configure the OAuth consent screen so Google Workspace can provision an OAuth client for your add-on:
    1. Open the Google Auth Platform Branding page.
    2. If prompted, click Get Started. (If your consent screen is already configured, skip to step 3.)
    3. Under App Information, enter Support Extension for the App name, select your User support email, and click Next.
    4. Under Audience, select Internal and click Next.
    5. Under Contact Information, enter your email address and click Next.
    6. Under Finish, agree to the Google API Services: User Data Policy, click Continue, and click Create.
  3. In Cloud Shell, set your active project (replace YOUR_PROJECT_ID with 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
    
  4. Provision your (default) Firestore database and the studio-trigger-retries Cloud Tasks queue in us-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_triggers and support_tickets collections) and refreshes cached OAuth tokens.
  • starter.js: Implements the New Support Ticket custom starter step webhooks (/onConfigTrigger and /onManageTrigger).
  • action.js: Implements the Detect Urgency custom action step webhooks (/onConfigUrgency and /onExecuteUrgency) using Gemini 3.8 Flash (gemini-3.8-flash) with a keyword-matching fallback.
  • tasks.js: Dispatches trigger notifications to trigger.notifyUri (falling back to https://workspacestudio.googleapis.com/v1/triggers/{triggerId}:fire), enqueues failed deliveries into the studio-trigger-retries Cloud Tasks queue, and implements the POST /api/tasks/fire-trigger retry 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

  1. Create a project directory and navigate into it:
    mkdir -p ~/studio-support-addon && cd ~/studio-support-addon
    
  2. Create package.json with express, @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, and userOAuthToken) 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-lived authorizationEventObject.userOAuthToken. This helper updates active trigger documents with the latest token so background triggers.fire calls 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 a pushCard navigation 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 the studio_triggers Firestore 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 a textInput widget that sets hostAppDataSource.workflowDataSource.includeVariables: true, allowing users to select the ticketDescription variable outputted by the starter step.
  • onExecuteUrgency (POST /onExecuteUrgency): Reads the ticketDescription input from req.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 (responseSchema enum ["High", "Normal"]), falls back to detectKeywordUrgency() (urgent, blocker, critical) if the model call fails, and returns the urgencyLevel output variable using hostAppAction.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): Uses trigger.notifyUri persisted from triggerCreation.notifyUri when present, or falls back to https://workspacestudio.googleapis.com/v1/triggers/{triggerId}:fire.
  • fireTriggerDirect(trigger, payload): Sends the POST request to the trigger URL with the ticketTitle and ticketDescription output variables and an idempotent requestId.
  • enqueueTriggerRetry(req, payload): Enqueues an HTTP task in the studio-trigger-retries queue targeting your Cloud Run service's POST /api/tasks/fire-trigger endpoint 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 latest trigger document in studio_triggers from Firestore (ensuring retries always use the freshest userOAuthToken), checks the X-CloudTasks-TaskRetryCount header (if simulateFailure is enabled, it fails the first Cloud Task execution with 503 so you can watch Cloud Tasks back off and succeed on retry #1), and returns 200 OK only when notifyUri returns a 2xx success response. Any 5xx response 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 the studio_triggers Firestore 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 in studio_triggers and calls fireTriggerDirect(). If the trigger URL returns a non-2xx response, throws a network error, or has simulateFailure enabled, it automatically enqueues a retry task in studio-trigger-retries using enqueueTriggerRetry() and logs the ticket in the support_tickets Firestore 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.

  1. Deploy the support-extension service 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
    
  2. Grant roles/run.invoker on 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
    
  3. Grant roles/datastore.user (for reading and writing the studio_triggers and support_tickets Firestore collections), roles/cloudtasks.enqueuer (for enqueueing retry tasks into studio-trigger-retries), and roles/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
    
  4. Retrieve your deployed Cloud Run URL and generate the deployment.json manifest defining the supportTicketTrigger (workflowTrigger) and urgencyDetectorStep (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
    
  5. 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

  1. Open Google Workspace Studio (refresh the browser tab if it was already open) and click New Flow.
  2. Click Select a starter > Support Extension > New Support Ticket.
  3. Click Add step (+) > Support Extension > Detect Urgency. In the Source Description field, click + Variables and select Ticket Description.
  4. 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.
  5. Toggle the flow On in the top-right corner. Google Workspace Studio immediately calls your /onManageTrigger endpoint on Cloud Run, which writes the active trigger subscription (including triggerId, notifyUri, and userOAuthToken) to the studio_triggers collection in Firestore.
  6. Open your Support Quick Portal URL (https://support-extension-...run.app) in a new tab, confirm that the portal displays 1 active 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 like urgent or blocker, gemini-3.8-flash classifies this ticket as High urgency.
    • 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-flash classifies this ticket as Normal urgency.
  7. Verify in Google Chat and in the Google Workspace Studio Activity tab that your flow ran for both tickets with Urgency Level: High and Urgency Level: Normal.
  8. Test Cloud Tasks retry delivery when notifyUri fails:
    1. In the Support Quick Portal, check Simulate transient failure (enqueue in Cloud Tasks and retry notifyUri).
    2. Enter Ticket Title: Checkout service outage and Issue Description: Production payment gateway is timing out for all customers, then click Submit Ticket.
    3. Notice that the portal responds immediately with: Ticket saved! Initial notifyUri call did not succeed; queued 1 retry task(s) in Cloud Tasks.
    4. In the background, Cloud Tasks executes POST /api/tasks/fire-trigger. The first task attempt (retryCount=0) simulates a 503 failure, causing Cloud Tasks to back off for 10s (--min-backoff=10s) and retry (retryCount=1), which calls notifyUri and 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) with workflowTrigger and workflowAction elements.
  • Persisting Google Workspace Studio workflowTrigger subscriptions in the studio_triggers Firestore collection and logging portal submissions in support_tickets so 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 latest userOAuthToken from Firestore.
  • Building a workflowAction step 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.

Next steps