Skip to content
Skip to chapter

Chapter 34

Advanced integrations and data

Answer a request in Google Chat

Build a private Apps Script Chat app that looks up fictional requests, replies with a card, updates it on a button click, and logs its removal.

By the end

Configure one private development Chat app, ask for a fictional request, update its card to show a next step, and explain what the event contains, what the app may send, and how Chat delivers its replies.

Chapter navigation

Build Practice Desk Dev, a private Google Chat app. Send it status REQ-001 and it looks up a fictional workshop request and replies with a card showing Workshop map and Waiting for review. Click Show next step and the app updates that same message with a suggestion. The app does not assign work, change a request, or contact the suggested person.

The three sample requests are defined in Code.gs, so you need no ticket system or other service. You need a Google Workspace account that allows custom Chat apps. Limit the app to your own account says which accounts qualify. You also need to know the objects, arrays, callbacks, and runtime distinctions from the prerequisites. Read the Workspace add-on chapter for comparison, but do not install or modify its Drive project for this lesson.

Choose how Chat calls your code

Google supports more than one way to build an app that appears in Chat. This project uses the Apps Script connection and native Chat interaction events from Google's current Apps Script Chat quickstart. In the Chat API configuration you clear Build this Chat app as a Google Workspace add-on. That setting selects the event and response formats used here.

The Apps Script manifest reference recommends addOns.chat for new Chat apps, while the native quickstart documents the separate interaction-event route used here. If you choose the Workspace add-on model for a larger application, use its manifest, event, and response formats throughout. Do not mix its configuration with this project's callbacks.

App typeWhat reaches the codeWhat this lesson does with it
Native Apps Script Chat appTop-level Chat type, message, space, user, and card common fieldsImplements onAddToSpace, onMessage, onCardClick, and onRemoveFromSpace
Workspace add-onA host-specific event with commonEventObject. The add-on chapter's Drive project answers with CardService cards and action responsesComparison only
HTTP Chat appA request delivered to a separately hosted HTTP endpoint, which must verify Google's bearer tokenNot used
Incoming Chat webhookAn outbound message sent to one space's webhook URLNot used. A webhook cannot receive this interactive conversation

The two models display similar cards, but their event objects differ, as the native Event reference explains. This app returns plain JavaScript objects shaped as Chat Message resources. It does not return HtmlOutput, a JSON string, or a Drive CardService.ActionResponse.

Limit the app to your own account

Use an approved Business or Enterprise Google Workspace account with Chat and Apps Script access, permission to configure a new Google Cloud project, and permission to install your own test Chat app. The quickstart prerequisites and testing guide describe the account and organization requirements. Personal Gmail accounts do not support custom Chat apps, and workspace administrators can restrict app installation.

Use only your own approved account as the tester and only its direct message with this app. Leave space and group joining disabled. An unpublished app's Chat API Visibility setting controls who can find and install it. If an app is added to a shared space, other members can see its responses even when they are not listed testers.

The script's OAuth scope list is empty, but interaction events still deliver message and sender metadata to the handler. Google applies the organization's Chat retention policies to those messages. See how Chat apps access interaction information.

Install the complete source

The runnable project is Code.gs and appsscript.json. The other two files are for reading only: chat-configuration.json records the console settings, and practice-interactions.json shows sample event payloads.

Code.gs
/**
 * Native Google Chat interaction app, not a Workspace add-on or HTTP receiver.
 * Use only the private owner-test configuration described in the chapter.
 *
 * @typedef {{id: string, title: string, status: string, nextStep: string}} PracticeRequest
 * @typedef {'summary' | 'next'} RequestView
 * @typedef {{text: string, onClick: {action: {function: string, parameters: Array<{key: string, value: string}>, loadIndicator: string}}}} RequestButton
 * @typedef {{textParagraph: {text: string}} | {buttonList: {buttons: RequestButton[]}}} RequestWidget
 * @typedef {{cardId: string, card: {header: {title: string, subtitle: string}, sections: Array<{widgets: RequestWidget[]}>}}} RequestCard
 * @typedef {{text?: string, fallbackText?: string, cardsV2?: RequestCard[], actionResponse?: {type: 'UPDATE_MESSAGE'}}} ChatReply
 */

/** @type {PracticeRequest[]} */
const PRACTICE_REQUESTS = [
  {
    id: 'REQ-001',
    title: 'Workshop map',
    status: 'Waiting for review',
    nextStep: 'Ask the fictional workshop lead to review the map labels.'
  },
  {
    id: 'REQ-002',
    title: 'Room checklist',
    status: 'Ready',
    nextStep: 'Compare the fictional room checklist with the workshop plan.'
  },
  {
    id: 'REQ-003',
    title: 'Feedback summary',
    status: 'In progress',
    nextStep: 'Group the fictional feedback notes into two themes.'
  }
];

/**
 * Google Chat calls this for ADDED_TO_SPACE, including a new direct message.
 * @param {unknown} event The native Chat event supplied by Google, not doPost(e).
 * @returns {ChatReply}
 */
function onAddToSpace(event) {
  requireChatEvent_(event, 'ADDED_TO_SPACE');
  console.info('CHAT_APP_ADDED');
  return {
    text: 'Practice Desk reads three fictional requests. Send status REQ-001, ' +
      'status REQ-002, or status REQ-003. Use the card to show a next step. ' +
      'No request is changed and no other person is contacted.'
  };
}

/**
 * Google Chat calls this for a direct message or supported invocation in a space.
 * @param {unknown} event The native MESSAGE event.
 * @returns {ChatReply}
 */
function onMessage(event) {
  const input = requireChatEvent_(event, 'MESSAGE');
  const message = requireChatRecord_(input.message);
  // argumentText strips Chat app mentions. An absent field can use text in a DM.
  const raw = message.argumentText === undefined ? message.text : message.argumentText;
  if (typeof raw !== 'string' || raw.length > 80) return chatHelp_();
  const command = raw.trim();
  if (command === '' || command.toLowerCase() === 'help') return chatHelp_();
  const match = /^status (REQ-\d{3})$/i.exec(command);
  if (!match) return chatHelp_();
  const request = findPracticeRequest_(match[1].toUpperCase());
  if (!request) {
    return {text: 'That request is not in the fictional catalog. Send help for the three IDs.'};
  }
  return requestReply_(request, 'summary', false);
}

/**
 * The only card action target is onCardClick; its parameters select a view.
 * @param {unknown} event The native CARD_CLICKED event, with common.parameters.
 * @returns {ChatReply}
 */
function onCardClick(event) {
  const input = requireChatEvent_(event, 'CARD_CLICKED');
  const message = requireChatRecord_(input.message);
  const sender = requireChatRecord_(message.sender);
  if (sender.type !== 'BOT') {
    throw new Error('CHAT_EXPECTED_APP_MESSAGE');
  }
  const common = requireChatRecord_(input.common);
  const parameters = requireChatRecord_(common.parameters);
  if (common.invokedFunction !== 'onCardClick' ||
      Object.keys(parameters).length !== 3 || parameters.revision !== '1' ||
      typeof parameters.requestId !== 'string' ||
      !/^REQ-\d{3}$/.test(parameters.requestId) ||
      (parameters.view !== 'summary' && parameters.view !== 'next')) {
    return {text: 'This card action is not supported. Send status REQ-001 for a fresh card.'};
  }
  const request = findPracticeRequest_(parameters.requestId);
  if (!request) {
    return {text: 'That request is no longer in the fictional catalog. Send help.'};
  }
  return requestReply_(request, parameters.view, true);
}

/**
 * The app is already removed: there is no legal farewell message response.
 * There are no subscriptions, credentials or per-space records to delete.
 * @param {unknown} event The native REMOVED_FROM_SPACE event.
 * @returns {void}
 */
function onRemoveFromSpace(event) {
  requireChatEvent_(event, 'REMOVED_FROM_SPACE');
  console.info('CHAT_APP_REMOVED');
}

/** @returns {ChatReply} */
function chatHelp_() {
  return {
    text: 'Send status REQ-001, status REQ-002, or status REQ-003. ' +
      'Use one space between status and the ID. These are fictional read-only records.'
  };
}

/**
 * @param {string} id An exact catalog ID, never a remote resource identifier.
 * @returns {PracticeRequest | undefined}
 */
function findPracticeRequest_(id) {
  if (PRACTICE_REQUESTS.length !== 3) throw new Error('CHAT_EXPECTED_THREE_REQUESTS');
  /** @type {Set<string>} */
  const seen = new Set();
  for (const request of PRACTICE_REQUESTS) {
    if (!/^REQ-\d{3}$/.test(request.id) || seen.has(request.id)) {
      throw new Error('CHAT_INVALID_CATALOG_ID');
    }
    seen.add(request.id);
    for (const text of [request.title, request.status, request.nextStep]) {
      if (typeof text !== 'string' || text.length === 0 || text.length > 160 ||
          /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(text)) {
        throw new Error('CHAT_INVALID_CATALOG_TEXT');
      }
    }
  }
  return PRACTICE_REQUESTS.find((request) => request.id === id);
}

/**
 * Build a fresh response from source data, never by trusting the clicked card's text.
 * @param {PracticeRequest} request A checked fictional record.
 * @param {RequestView} view An explicit desired view, not a toggle instruction.
 * @param {boolean} update Whether this is a BOT message's CARD_CLICKED response.
 * @returns {ChatReply}
 */
function requestReply_(request, view, update) {
  /** @type {RequestWidget[]} */
  const widgets = [
    {textParagraph: {text: '<b>Request</b><br>' + escapeCardText_(request.title)}},
    {textParagraph: {text: '<b>Status</b><br>' + escapeCardText_(request.status)}}
  ];
  if (view === 'next') {
    widgets.push({textParagraph: {text: '<b>Suggested next step</b><br>' + escapeCardText_(request.nextStep)}});
  }
  widgets.push({textParagraph: {text: 'Fictional source data. Viewing this card does not carry out the next step.'}});
  widgets.push({buttonList: {buttons: [
    requestButton_(request.id, 'next', 'Show next step'),
    requestButton_(request.id, 'summary', 'Show summary')
  ]}});
  /** @type {ChatReply} */
  const reply = {
    fallbackText: request.id + ' fictional request. Open Chat to read its card.',
    cardsV2: [{
      cardId: 'practice-request',
      card: {
        header: {title: request.id, subtitle: 'Practice Desk'},
        sections: [{widgets: widgets}]
      }
    }]
  };
  if (update) reply.actionResponse = {type: 'UPDATE_MESSAGE'};
  return reply;
}

/**
 * @param {string} id Exact fictional request ID.
 * @param {RequestView} view Desired display state.
 * @param {string} label Fixed button label.
 * @returns {RequestButton}
 */
function requestButton_(id, view, label) {
  return {
    text: label,
    onClick: {action: {
      function: 'onCardClick',
      parameters: [
        {key: 'revision', value: '1'},
        {key: 'requestId', value: id},
        {key: 'view', value: view}
      ],
      loadIndicator: 'SPINNER'
    }}
  };
}

/**
 * Encode literal source strings for a card TextParagraph's HTML text subset.
 * @param {string} text Checked source text, not HTML markup.
 * @returns {string}
 */
function escapeCardText_(text) {
  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

/**
 * Validate the consumed envelope only; this is not cryptographic authentication.
 * @param {unknown} value Native event object supplied by the configured host.
 * @param {string} expected Required event type for this callback.
 * @returns {Record<string, unknown>}
 */
function requireChatEvent_(value, expected) {
  const event = requireChatRecord_(value);
  const space = requireChatRecord_(event.space);
  if (event.type !== expected || typeof space.name !== 'string' ||
      space.name.length > 200 || !/^spaces\/[^/]+$/.test(space.name) ||
      event.isDialogEvent === true) {
    throw new Error('CHAT_UNSUPPORTED_EVENT');
  }
  return event;
}

/**
 * @param {unknown} value A nested event field.
 * @returns {Record<string, unknown>}
 */
function requireChatRecord_(value) {
  if (value === null || typeof value !== 'object' || Array.isArray(value)) {
    throw new Error('CHAT_EXPECTED_EVENT_OBJECT');
  }
  return /** @type {Record<string, unknown>} */ (value);
}

Download Code.gs
appsscript.json
{
  "timeZone": "Etc/UTC",
  "dependencies": {},
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [],
  "chat": {}
}

Download appsscript.json
chat-configuration.json
{
  "purpose": "A manual console worksheet, not an importable Google API resource or an Apps Script manifest. Enter account-specific values only in the approved Google console; do not save live identifiers or addresses back into this example.",
  "cloudProject": "One new approved Google Cloud project dedicated to Practice Desk Dev, with Google Chat API enabled. Keep its project ID and number in private notes.",
  "googleAuthPlatform": {
    "App name": "Practice Desk Dev",
    "Audience": "Internal",
    "User support email": "Choose your approved account in Branding",
    "Contact Information": "Enter your approved project contact address",
    "Data Access": "No additional scopes for this synchronous-only app",
    "OAuth clients": "None to create for this example"
  },
  "appsScript": {
    "Project name": "Practice Desk Dev",
    "Starting template": "Chat App at script.google.com/home/start; replace its source with this complete Code.gs and manifest",
    "Associated Cloud project": "Keep the automatically associated default project for this owner-only head test, as allowed by the current Chat quickstart; record it separately from the standard Cloud project hosting Chat configuration",
    "Deploy > Test deployments": "Copy this project's Head deployment ID. This is not the Script ID, Cloud project ID/number, web-app URL or a secret. Do not create a web-app deployment"
  },
  "chatApiConfiguration": {
    "Build this Chat app as a Google Workspace add-on": false,
    "App name": "Practice Desk Dev",
    "Avatar URL": "https://developers.google.com/chat/images/quickstart-app-avatar.png",
    "Description": "Read three fictional workshop requests",
    "Enable interactive features": true,
    "Join spaces and group conversations": false,
    "Connection settings": "Apps Script",
    "Deployment ID": "Paste the exact Head deployment ID copied from this new Apps Script project",
    "Visibility": "Specific people and groups in your domain: enter only your approved tester account, not a group or the whole domain",
    "Commands": "None; status REQ-001 is ordinary message text, not a registered slash command",
    "Link previews": "None",
    "App home": "Disabled if offered; use direct messages and message cards only",
    "Logs": "Leave optional Chat API error logging off unless separately approved; use this script's Executions for the first diagnostic step"
  },
  "stopCondition": "If the documented native Apps Script option, internal audience, private visibility or required permissions are unavailable, stop. Do not substitute HTTP, enable Workspace add-on mode, grant organization-wide access, attach billing or publish to Marketplace as a workaround."
}

Download chat-configuration.json
practice-interactions.json
{
  "purpose": "Fictional reading fixtures for the native Chat interaction surface, not captured events, an HTTP payload to send, or a runtime test. Unused platform fields are omitted; all resource names below are fictional.",
  "added": {
    "type": "ADDED_TO_SPACE",
    "eventTime": "2026-01-15T10:00:00Z",
    "space": {"name": "spaces/FICTIONAL_DM"},
    "user": {"name": "users/FICTIONAL_TESTER", "type": "HUMAN"}
  },
  "message": {
    "type": "MESSAGE",
    "eventTime": "2026-01-15T10:01:00Z",
    "space": {"name": "spaces/FICTIONAL_DM"},
    "user": {"name": "users/FICTIONAL_TESTER", "type": "HUMAN"},
    "message": {
      "name": "spaces/FICTIONAL_DM/messages/FICTIONAL_QUESTION",
      "sender": {"name": "users/FICTIONAL_TESTER", "type": "HUMAN"},
      "text": "status REQ-001",
      "argumentText": "status REQ-001"
    }
  },
  "showNextStep": {
    "type": "CARD_CLICKED",
    "eventTime": "2026-01-15T10:02:00Z",
    "space": {"name": "spaces/FICTIONAL_DM"},
    "user": {"name": "users/FICTIONAL_TESTER", "type": "HUMAN"},
    "message": {
      "name": "spaces/FICTIONAL_DM/messages/FICTIONAL_CARD",
      "sender": {"name": "users/FICTIONAL_APP", "type": "BOT"}
    },
    "common": {
      "invokedFunction": "onCardClick",
      "parameters": {"revision": "1", "requestId": "REQ-001", "view": "next"}
    }
  },
  "removed": {
    "type": "REMOVED_FROM_SPACE",
    "eventTime": "2026-01-15T10:03:00Z",
    "space": {"name": "spaces/FICTIONAL_DM"},
    "user": {"name": "users/FICTIONAL_TESTER", "type": "HUMAN"}
  }
}

Download practice-interactions.json

Prepare one Cloud configuration project

  1. In the approved account, open the Google Cloud project selector. Create a new project named Practice Desk Dev in the approved organization and location. Record its project ID and project number.

  2. With that new project selected, open APIs & Services > Library, search for Google Chat API, and enable it. This makes the API available for app configuration. It does not grant an OAuth scope or install an app into Chat.

  3. Open Google Auth platform > Branding. If the platform is not configured, click Get Started. Set the app name to Practice Desk Dev, choose your support email, and click Next. Select Internal for Audience, then enter the project contact email under Contact Information. Review the policy acknowledgement and finish with Continue > Create.

  4. Do not add data-access scopes or create OAuth clients, service-account keys, or API keys. If Internal is unavailable, check that you are signed into a Google Workspace organization account.

These are the current quickstart's environment and consent-screen steps. Consent-screen registration, Chat visibility, and the script's scope list are three separate settings. Internal consent-screen registration alone does not restrict Chat installation to your account.

Create the Apps Script project and select its head deployment

  1. Open Apps Script's Getting Started page (script.google.com/home/start), select the Chat App template, and rename the new project Practice Desk Dev.

  2. Replace the template Code.gs with the file above so there is only one definition of each of the four Chat callbacks.

  3. In Project Settings, enable Show "appsscript.json" manifest file in editor. Return to Editor, replace the manifest with the supplied JSON, and save both files. Leave Services empty.

  4. Leave the script on its automatically associated default Cloud project. The Chat quickstart says a synchronous app like this one does not need to be associated with the standard Cloud project. The script project and the Cloud project you created stay separate even though both are named Practice Desk Dev.

  5. Choose Deploy > Test deployments, copy this project's Head deployment ID, and click Done. The Script ID in Project Settings is a different value. Do not use it.

A head deployment runs the most recently saved source. The general deployment guide says a head deployment requires at least read access to the script, and Chat's deployment guidance limits head deployments to development.

The manifest's empty chat object selects the native Chat project configuration. You set callback routing, visibility, and the deployment ID in the Chat API console, not in the manifest. The oauthScopes array and dependencies object are empty because the script uses only built-in JavaScript and two fixed log messages. See the manifest reference.

Connect the private Chat app

Return to the Cloud project you created in the first step, not the Apps Script default project, which can have a similar name. Open Google Chat API > Manage > Configuration and apply these settings before saving:

Console settingValue for this project
Build this Chat app as a Google Workspace add-onClear it, then confirm Disable in the model-change dialog
App namePractice Desk Dev
Avatar URLhttps://developers.google.com/chat/images/quickstart-app-avatar.png
DescriptionRead three fictional workshop requests
Enable interactive featuresOn
Join spaces and group conversationsOff. Direct messages still work
Connection settingsApps Script
Deployment IDThe exact Head deployment ID copied from this new script
VisibilitySpecific people and groups in your domain, containing only your approved tester account
Commands, Link previews, and app home if offeredNo commands, preview patterns, or app home
Optional Log errors to LoggingLeave off unless separately approved. Start with Apps Script Executions

Check the Cloud project, deployment ID, native app mode, and single-account visibility, then click Save. Configuring the Chat API requires a Cloud role such as Chat apps Owner. See Chat configuration and trusted tester access.

The avatar URL is the Google-hosted development image used by the quickstart. Chat loads it as the app's picture. The script never fetches it and puts no event data in its URL.

Open the actual conversation

Open Google Chat in the account listed under Visibility. Click New chat, enter Practice Desk Dev in Add 1 or more people, and select the app to open a direct message.

When the app is newly added, expect a welcome message that names the three request IDs and says that no request is changed. Reopening a conversation with an app that is already installed might not send another addition event. In the script's Executions, onAddToSpace logs CHAT_APP_ADDED. That log shows the handler ran. It does not show that Chat displayed the welcome message.

Test every callback from the direct message in Google Chat. Clicking Run in the Apps Script editor supplies no Chat event object, so each handler fails there. See Google's native quickstart.

Read the Chat event object

Chat calls the four functions below by name for the native events in the table. They are not spreadsheet triggers, scheduled triggers, or HTTP handlers, so you do not register anything with ScriptApp.newTrigger, write a doPost function, or set up a webhook URL.

InteractionEvent type and fieldsResponse in this app
Add the app, including opening a new direct messagetype: 'ADDED_TO_SPACE', space, interacting user (message only if applicable)Constant welcome text and an addition log
Send status REQ-001 to the apptype: 'MESSAGE', message, space, userA new card containing one matched source record
Click a button on the app's cardtype: 'CARD_CLICKED', the app's message, acting user, and common action dataAn updated version of that app message
Uninstall or remove the apptype: 'REMOVED_FROM_SPACE', space, userA removal log, with no message response

The EventType reference says the removal has already happened when REMOVED_FROM_SPACE arrives, so the app cannot send a farewell response. The same reference explains how direct messages differ from messages in a multi-person space. In a space, a native app receives only supported invocations such as mentions, not every message in the conversation. This project subscribes to no membership or history change events.

requireChatEvent_ checks that the event has the expected type, that space.name is a string of at most 200 characters in the form spaces/..., and that the event is not a dialog interaction. It ignores fields the app does not use, including fields Google may add later. requireChatRecord_ rejects a missing value, null, or an array before the code reads nested properties.

The @param, @returns, and @typedef lines are JSDoc comments for editor type checking. They check nothing at runtime. The checks inside the functions do that.

Interpret message text and identities

message.argumentText is the message text with Chat app mentions removed. onMessage reads it when present and reads message.text only when argumentText is absent. If the text is missing, is not a string, or is longer than 80 characters (as raw.length counts them), the app replies with the help text. The app never repeats the user's message back. The Message reference defines both text fields.

After the app trims outer whitespace, the command must be status, one space, and an ID shaped as REQ- followed by three digits. The match ignores letter case. help returns the fixed syntax reminder. status is ordinary message text, not a registered slash command, so entering /status does not run anything this project configured.

The regular expression's capture group supplies the request ID. findPracticeRequest_ compares it with the three source records and returns the match or undefined. If there is no match, the app replies that the ID is not in the catalog. Before searching, the same function checks that the catalog still contains exactly three records with unique IDs and nonempty single-line text fields of at most 160 characters.

These names and IDs refer to different things:

  • event.user is the person interacting with the app. On a card click, that is the person clicking, while event.message.sender is the app that sent the card.

  • space.name identifies the conversation. A direct message is also a Chat space. The code does not need its display name or email.

  • message.name identifies one Chat message. The user's request and the app's card are different messages. On a click, the event's message is the card, not the user's original question.

  • The Apps Script deployment ID selects the script deployment. The Cloud project ID and number identify the Cloud project.

  • cardId: 'practice-request' identifies a card within a message. Reusing that ID across messages does not merge or replace messages.

The native Event and Message resources describe these relationships. The sample events in practice-interactions.json show the fields this app reads, use fictional names, and omit unused platform fields.

Return a card, then update that message

requestReply_ returns a Message object with a cardsV2 array (the older cards field is deprecated). Each entry has a cardId and a card. This card has a header and one section whose widgets array holds text paragraphs and a button list. The message's fallbackText is the short description a client shows when it cannot display the card, for example in a notification. See the Message reference.

The summary view has four widgets: the request title, the status, a fictional-data reminder, and one button list holding both buttons. The next-step view adds one paragraph between the status and the reminder. The code builds a new card from the catalog on every call. It never reads request data back from an old card.

Paragraph text supports only a small set of HTML formatting tags. The labels use fixed <b> and <br> markup. escapeCardText_ encodes ampersands and angle brackets in the source record's values before they go into paragraph text. Encoding ampersands first prevents the helper from re-encoding the entities it creates. No user input, display name, email, URL, or mention goes into a card. The Cards v2 reference documents text formatting and supported widgets.

Tell each button which view to show

Both buttons set onClick.action.function to the callback name onCardClick. The action's parameters is an array of key and value string pairs: revision: '1', requestId: 'REQ-001', and either view: 'next' or view: 'summary'. When someone clicks the button, Chat delivers the function name in event.common.invokedFunction and turns the array of pairs into a string-keyed object in event.common.parameters.

common.parameters is a different field from commonEventObject.parameters in a Workspace add-on. Google's native card-action example routes through onCardClick and reads common.invokedFunction and common.parameters. The Action schema defines the outgoing fields.

onCardClick checks that invokedFunction is onCardClick and that exactly three string parameters arrived: revision '1', one of the two views, and a request ID in the catalog. It never calls a function by the submitted name.

A valid card click returns actionResponse: {type: 'UPDATE_MESSAGE'} with the rebuilt card. That response replaces the app's existing message instead of posting another card. The ActionResponse reference permits this only for CARD_CLICKED events on a message whose sender type is BOT, which the handler checks.

The reply to a status message omits actionResponse, so Chat posts it as a new message. If the action parameters are unsupported, the app replies with a separate text message that explains the problem, and the card stays unchanged.

Each button names the view it shows instead of toggling. A second Show next step click builds the same next-step view again, and a second Show summary click builds the summary again, so a duplicate click never flips the card back.

Replying to an event versus sending a new message

Google Chat calls the script through the Apps Script connection you configured and reads the function's return value. The script exposes no public URL. The checks in requireChatEvent_ confirm the event's shape. They do not authenticate the caller. The Apps Script connection between Google Chat and the script project does that.

A separately hosted HTTP Chat app must verify each request itself. Google's request-verification guide requires verifying the Google-issued bearer token, including its audience, issuer, and identity. A public Apps Script doPost(e) with an invented header getter is not the native connection and does not perform that verification. The Event reference notes that modern apps do not use the legacy event.token field.

A synchronous response needs no Chat API access token. The app returns a Message within the current interaction, and Chat posts or updates it in that interaction's space. A synchronous response does not let the app send to another space later. Google's authentication guide separates these responses from REST calls.

Sending a message outside an interaction, for example calling spaces.messages.create from a scheduled job, is a new API request. It needs supported authentication, the relevant scope, and access to the target space. App authentication usually uses a service account, whereas user authentication acts as the user who consented. The documented chat.bot scope is for app authentication only. It does not work with a user token or domain-wide delegation. Adding it to this script does not make ScriptApp.getOAuthToken() an app credential, so do not add it.

If you later extend an Apps Script Chat app to use user-authorized Google services, Apps Script handles that user authorization and its OAuth tokens, as the architecture guide describes. Those tokens are separate from event.user, the person interacting with the app, and from the message sender shown in Chat, which is the app itself.

This project requests no Google data scopes and reads no User Properties. Every account in the app's Visibility setting can look up all three catalog records. Filtering records per user would need a separate authorization check.

What a displayed card tells you

Chat requires a synchronous response within 30 seconds, in the space where the interaction occurred. This code looks up one of three in-memory records and builds at most one five-widget card. It does not fetch from the network, sleep, or write properties, so it finishes well inside that limit. See synchronous interaction responses.

If the card appears, Chat accepted and displayed the response for that interaction. When Show next step shows a suggestion, only the view has changed. Nobody carried out the suggestion or received another message, and no request changed in another system. A completed execution in Executions does not by itself mean that Chat displayed the returned card.

The interaction guide says Chat may deliver an interaction again after a transient failure, but it does not guarantee retries. Chat does not retry a successful delivery with an invalid message payload. Write handlers so that a repeated call for the same interaction does no harm.

This app changes no stored records, so a repeated call has nothing to duplicate. A repeated lookup computes the same answer, and a repeated view request builds the same view while the source is unchanged. Each resent status REQ-001 message is a new interaction and normally produces a new card. If you click both buttons quickly, either view can end up displayed. Clicking a button on an old card rebuilds it from the latest saved code.

If a response is missing, inspect this project's Executions and the conversation before sending again. A second send can produce a second card.

Exercise: look up and update a request

Keep the private direct message and the editor open. These steps assume the supplied source is saved and that you added the app in Open the actual conversation. Perform each send and click below once.

  1. Before sending anything, find REQ-001 in PRACTICE_REQUESTS. Predict Workshop map, Waiting for review, and no suggested-next-step paragraph. Send status REQ-001 once and wait. Compare the actual card with those predictions. Check the matching onMessage execution if no card appears.

  2. Predict that Show next step will update this message and add Ask the fictional workshop lead to review the map labels. Click it once. Expect the same request ID and status, now with that paragraph. Click Show next step again. It should stay on the next-step view. Click Show summary to remove the suggestion from the view.

  3. Send status REQ-999. Expect the fixed not-in-catalog message. Send help if you need the supported IDs.

  4. Edit the source data. In PRACTICE_REQUESTS, change only REQ-001's title from Workshop map to Workshop <draft> & map. Keep its ID, status, and next step unchanged. Save and wait for the editor to finish saving. The card already on screen does not change by itself. Click Show summary on that card to request a rebuild. Expect the title paragraph to show the angle brackets and ampersand as literal characters. The click runs the current saved source even though the button was created earlier.

  5. Restore the title to Workshop map, save, and click Show summary again. Expect the original title and status.

  6. When finished, follow the uninstall steps below. Expect a removal callback log if the platform delivers that event, but no farewell message. Confirm removal in the Chat UI. A missing reply does not show that the app was uninstalled.

Fix common problems

SymptomInspect before changing anything
The app is absent from New chatCheck the signed-in Workspace account, the Visibility setting, the selected Cloud project, the saved native configuration, and your organization's installation policy. Find an unpublished app through Chat search, because it has no Marketplace listing.
The app receives the wrong event shapeConfirm Build this Chat app as a Google Workspace add-on is disabled and the supplied native manifest is installed. Do not rename common to commonEventObject to hide a model mismatch.
CHAT_EXPECTED_EVENT_OBJECT or CHAT_UNSUPPORTED_EVENTInspect the callback and deployment. Running from the editor supplies no event. This project does not handle dialog, app home, or HTTP events.
CHAT_EXPECTED_APP_MESSAGEThe clicked card was not sent by this app. Send a new status message and click a button on the app's own card.
A help message instead of a cardSend ordinary status REQ-001 text with one space, a valid ID, and at most 80 characters before trimming. An attachment-only message has no supported command.
CHAT_EXPECTED_THREE_REQUESTS, CHAT_INVALID_CATALOG_ID, or CHAT_INVALID_CATALOG_TEXTRestore the three source records with distinct IDs and nonempty single-line text of at most 160 characters.
A card click reports an unsupported actionConfirm the action targets onCardClick and the three string parameters match this version. After you change the button parameters, send a new status message to get a fresh card. UPDATE_MESSAGE is not valid for a human's message.
Something went wrong, an absent card, or a timeoutCompare the source, manifest, and deployment ID, inspect Executions, and check the documented response shape.
Changes do not appearWait for the save to finish, then request a new view. Check that the Chat configuration points to this head deployment, not another script or a versioned deployment. A card already in the conversation does not update when you save the source. Click a button to rebuild it.
A prompt asks for mail, Drive files, Chat history, or external-request accessStop. The supplied source and empty scope list need none of those. Inspect leftover template or source files and the selected project. Do not grant more scopes.

The two log messages, CHAT_APP_ADDED and CHAT_APP_REMOVED, contain no actor, space, message, or content identifiers. Errors in the request catalog or the event shape appear as failed runs in Executions, never as a card.

Uninstall this private test without deleting its history

Restore the edited source title first and let pending interactions finish. Then, in Google Chat's left sidebar under Apps, open Practice Desk Dev. Click its name at the top and choose Uninstall. Confirm only this app. Google's Chat uninstall instructions distinguish uninstalling from hiding a conversation or removing an app from one space.

Inspect the script's Executions for onRemoveFromSpace and its CHAT_APP_REMOVED log. Do not wait for a farewell message. The callback returns nothing because Chat has already removed the app. The app has no timers, credentials, subscriptions, or per-space records, so there is nothing to delete.

After you see the removal log, return to the Cloud project, open Google Chat API > Manage > Configuration, turn Enable interactive features off, keep the single-account Visibility setting, and click Save. See the configuration guide.

Uninstalling the app and disabling interactive features stop new interactions. Existing Chat messages and execution logs remain.

For deployments beyond single-owner testing, use a versioned deployment associated with a standard Cloud project. See Chat's deployment guidance and the Apps Script deployment guide.

Next, read GitHub issues and Actions events.

Project files

Complete source files for this chapter's examples.

Read a fictional request through a private Google Chat app
Code.gs
/**
 * Native Google Chat interaction app, not a Workspace add-on or HTTP receiver.
 * Use only the private owner-test configuration described in the chapter.
 *
 * @typedef {{id: string, title: string, status: string, nextStep: string}} PracticeRequest
 * @typedef {'summary' | 'next'} RequestView
 * @typedef {{text: string, onClick: {action: {function: string, parameters: Array<{key: string, value: string}>, loadIndicator: string}}}} RequestButton
 * @typedef {{textParagraph: {text: string}} | {buttonList: {buttons: RequestButton[]}}} RequestWidget
 * @typedef {{cardId: string, card: {header: {title: string, subtitle: string}, sections: Array<{widgets: RequestWidget[]}>}}} RequestCard
 * @typedef {{text?: string, fallbackText?: string, cardsV2?: RequestCard[], actionResponse?: {type: 'UPDATE_MESSAGE'}}} ChatReply
 */

/** @type {PracticeRequest[]} */
const PRACTICE_REQUESTS = [
  {
    id: 'REQ-001',
    title: 'Workshop map',
    status: 'Waiting for review',
    nextStep: 'Ask the fictional workshop lead to review the map labels.'
  },
  {
    id: 'REQ-002',
    title: 'Room checklist',
    status: 'Ready',
    nextStep: 'Compare the fictional room checklist with the workshop plan.'
  },
  {
    id: 'REQ-003',
    title: 'Feedback summary',
    status: 'In progress',
    nextStep: 'Group the fictional feedback notes into two themes.'
  }
];

/**
 * Google Chat calls this for ADDED_TO_SPACE, including a new direct message.
 * @param {unknown} event The native Chat event supplied by Google, not doPost(e).
 * @returns {ChatReply}
 */
function onAddToSpace(event) {
  requireChatEvent_(event, 'ADDED_TO_SPACE');
  console.info('CHAT_APP_ADDED');
  return {
    text: 'Practice Desk reads three fictional requests. Send status REQ-001, ' +
      'status REQ-002, or status REQ-003. Use the card to show a next step. ' +
      'No request is changed and no other person is contacted.'
  };
}

/**
 * Google Chat calls this for a direct message or supported invocation in a space.
 * @param {unknown} event The native MESSAGE event.
 * @returns {ChatReply}
 */
function onMessage(event) {
  const input = requireChatEvent_(event, 'MESSAGE');
  const message = requireChatRecord_(input.message);
  // argumentText strips Chat app mentions. An absent field can use text in a DM.
  const raw = message.argumentText === undefined ? message.text : message.argumentText;
  if (typeof raw !== 'string' || raw.length > 80) return chatHelp_();
  const command = raw.trim();
  if (command === '' || command.toLowerCase() === 'help') return chatHelp_();
  const match = /^status (REQ-\d{3})$/i.exec(command);
  if (!match) return chatHelp_();
  const request = findPracticeRequest_(match[1].toUpperCase());
  if (!request) {
    return {text: 'That request is not in the fictional catalog. Send help for the three IDs.'};
  }
  return requestReply_(request, 'summary', false);
}

/**
 * The only card action target is onCardClick; its parameters select a view.
 * @param {unknown} event The native CARD_CLICKED event, with common.parameters.
 * @returns {ChatReply}
 */
function onCardClick(event) {
  const input = requireChatEvent_(event, 'CARD_CLICKED');
  const message = requireChatRecord_(input.message);
  const sender = requireChatRecord_(message.sender);
  if (sender.type !== 'BOT') {
    throw new Error('CHAT_EXPECTED_APP_MESSAGE');
  }
  const common = requireChatRecord_(input.common);
  const parameters = requireChatRecord_(common.parameters);
  if (common.invokedFunction !== 'onCardClick' ||
      Object.keys(parameters).length !== 3 || parameters.revision !== '1' ||
      typeof parameters.requestId !== 'string' ||
      !/^REQ-\d{3}$/.test(parameters.requestId) ||
      (parameters.view !== 'summary' && parameters.view !== 'next')) {
    return {text: 'This card action is not supported. Send status REQ-001 for a fresh card.'};
  }
  const request = findPracticeRequest_(parameters.requestId);
  if (!request) {
    return {text: 'That request is no longer in the fictional catalog. Send help.'};
  }
  return requestReply_(request, parameters.view, true);
}

/**
 * The app is already removed: there is no legal farewell message response.
 * There are no subscriptions, credentials or per-space records to delete.
 * @param {unknown} event The native REMOVED_FROM_SPACE event.
 * @returns {void}
 */
function onRemoveFromSpace(event) {
  requireChatEvent_(event, 'REMOVED_FROM_SPACE');
  console.info('CHAT_APP_REMOVED');
}

/** @returns {ChatReply} */
function chatHelp_() {
  return {
    text: 'Send status REQ-001, status REQ-002, or status REQ-003. ' +
      'Use one space between status and the ID. These are fictional read-only records.'
  };
}

/**
 * @param {string} id An exact catalog ID, never a remote resource identifier.
 * @returns {PracticeRequest | undefined}
 */
function findPracticeRequest_(id) {
  if (PRACTICE_REQUESTS.length !== 3) throw new Error('CHAT_EXPECTED_THREE_REQUESTS');
  /** @type {Set<string>} */
  const seen = new Set();
  for (const request of PRACTICE_REQUESTS) {
    if (!/^REQ-\d{3}$/.test(request.id) || seen.has(request.id)) {
      throw new Error('CHAT_INVALID_CATALOG_ID');
    }
    seen.add(request.id);
    for (const text of [request.title, request.status, request.nextStep]) {
      if (typeof text !== 'string' || text.length === 0 || text.length > 160 ||
          /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(text)) {
        throw new Error('CHAT_INVALID_CATALOG_TEXT');
      }
    }
  }
  return PRACTICE_REQUESTS.find((request) => request.id === id);
}

/**
 * Build a fresh response from source data, never by trusting the clicked card's text.
 * @param {PracticeRequest} request A checked fictional record.
 * @param {RequestView} view An explicit desired view, not a toggle instruction.
 * @param {boolean} update Whether this is a BOT message's CARD_CLICKED response.
 * @returns {ChatReply}
 */
function requestReply_(request, view, update) {
  /** @type {RequestWidget[]} */
  const widgets = [
    {textParagraph: {text: '<b>Request</b><br>' + escapeCardText_(request.title)}},
    {textParagraph: {text: '<b>Status</b><br>' + escapeCardText_(request.status)}}
  ];
  if (view === 'next') {
    widgets.push({textParagraph: {text: '<b>Suggested next step</b><br>' + escapeCardText_(request.nextStep)}});
  }
  widgets.push({textParagraph: {text: 'Fictional source data. Viewing this card does not carry out the next step.'}});
  widgets.push({buttonList: {buttons: [
    requestButton_(request.id, 'next', 'Show next step'),
    requestButton_(request.id, 'summary', 'Show summary')
  ]}});
  /** @type {ChatReply} */
  const reply = {
    fallbackText: request.id + ' fictional request. Open Chat to read its card.',
    cardsV2: [{
      cardId: 'practice-request',
      card: {
        header: {title: request.id, subtitle: 'Practice Desk'},
        sections: [{widgets: widgets}]
      }
    }]
  };
  if (update) reply.actionResponse = {type: 'UPDATE_MESSAGE'};
  return reply;
}

/**
 * @param {string} id Exact fictional request ID.
 * @param {RequestView} view Desired display state.
 * @param {string} label Fixed button label.
 * @returns {RequestButton}
 */
function requestButton_(id, view, label) {
  return {
    text: label,
    onClick: {action: {
      function: 'onCardClick',
      parameters: [
        {key: 'revision', value: '1'},
        {key: 'requestId', value: id},
        {key: 'view', value: view}
      ],
      loadIndicator: 'SPINNER'
    }}
  };
}

/**
 * Encode literal source strings for a card TextParagraph's HTML text subset.
 * @param {string} text Checked source text, not HTML markup.
 * @returns {string}
 */
function escapeCardText_(text) {
  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

/**
 * Validate the consumed envelope only; this is not cryptographic authentication.
 * @param {unknown} value Native event object supplied by the configured host.
 * @param {string} expected Required event type for this callback.
 * @returns {Record<string, unknown>}
 */
function requireChatEvent_(value, expected) {
  const event = requireChatRecord_(value);
  const space = requireChatRecord_(event.space);
  if (event.type !== expected || typeof space.name !== 'string' ||
      space.name.length > 200 || !/^spaces\/[^/]+$/.test(space.name) ||
      event.isDialogEvent === true) {
    throw new Error('CHAT_UNSUPPORTED_EVENT');
  }
  return event;
}

/**
 * @param {unknown} value A nested event field.
 * @returns {Record<string, unknown>}
 */
function requireChatRecord_(value) {
  if (value === null || typeof value !== 'object' || Array.isArray(value)) {
    throw new Error('CHAT_EXPECTED_EVENT_OBJECT');
  }
  return /** @type {Record<string, unknown>} */ (value);
}

appsscript.json

Download file
appsscript.json
{
  "timeZone": "Etc/UTC",
  "dependencies": {},
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [],
  "chat": {}
}

chat-configuration.json

Download file
chat-configuration.json
{
  "purpose": "A manual console worksheet, not an importable Google API resource or an Apps Script manifest. Enter account-specific values only in the approved Google console; do not save live identifiers or addresses back into this example.",
  "cloudProject": "One new approved Google Cloud project dedicated to Practice Desk Dev, with Google Chat API enabled. Keep its project ID and number in private notes.",
  "googleAuthPlatform": {
    "App name": "Practice Desk Dev",
    "Audience": "Internal",
    "User support email": "Choose your approved account in Branding",
    "Contact Information": "Enter your approved project contact address",
    "Data Access": "No additional scopes for this synchronous-only app",
    "OAuth clients": "None to create for this example"
  },
  "appsScript": {
    "Project name": "Practice Desk Dev",
    "Starting template": "Chat App at script.google.com/home/start; replace its source with this complete Code.gs and manifest",
    "Associated Cloud project": "Keep the automatically associated default project for this owner-only head test, as allowed by the current Chat quickstart; record it separately from the standard Cloud project hosting Chat configuration",
    "Deploy > Test deployments": "Copy this project's Head deployment ID. This is not the Script ID, Cloud project ID/number, web-app URL or a secret. Do not create a web-app deployment"
  },
  "chatApiConfiguration": {
    "Build this Chat app as a Google Workspace add-on": false,
    "App name": "Practice Desk Dev",
    "Avatar URL": "https://developers.google.com/chat/images/quickstart-app-avatar.png",
    "Description": "Read three fictional workshop requests",
    "Enable interactive features": true,
    "Join spaces and group conversations": false,
    "Connection settings": "Apps Script",
    "Deployment ID": "Paste the exact Head deployment ID copied from this new Apps Script project",
    "Visibility": "Specific people and groups in your domain: enter only your approved tester account, not a group or the whole domain",
    "Commands": "None; status REQ-001 is ordinary message text, not a registered slash command",
    "Link previews": "None",
    "App home": "Disabled if offered; use direct messages and message cards only",
    "Logs": "Leave optional Chat API error logging off unless separately approved; use this script's Executions for the first diagnostic step"
  },
  "stopCondition": "If the documented native Apps Script option, internal audience, private visibility or required permissions are unavailable, stop. Do not substitute HTTP, enable Workspace add-on mode, grant organization-wide access, attach billing or publish to Marketplace as a workaround."
}

practice-interactions.json

Download file
practice-interactions.json
{
  "purpose": "Fictional reading fixtures for the native Chat interaction surface, not captured events, an HTTP payload to send, or a runtime test. Unused platform fields are omitted; all resource names below are fictional.",
  "added": {
    "type": "ADDED_TO_SPACE",
    "eventTime": "2026-01-15T10:00:00Z",
    "space": {"name": "spaces/FICTIONAL_DM"},
    "user": {"name": "users/FICTIONAL_TESTER", "type": "HUMAN"}
  },
  "message": {
    "type": "MESSAGE",
    "eventTime": "2026-01-15T10:01:00Z",
    "space": {"name": "spaces/FICTIONAL_DM"},
    "user": {"name": "users/FICTIONAL_TESTER", "type": "HUMAN"},
    "message": {
      "name": "spaces/FICTIONAL_DM/messages/FICTIONAL_QUESTION",
      "sender": {"name": "users/FICTIONAL_TESTER", "type": "HUMAN"},
      "text": "status REQ-001",
      "argumentText": "status REQ-001"
    }
  },
  "showNextStep": {
    "type": "CARD_CLICKED",
    "eventTime": "2026-01-15T10:02:00Z",
    "space": {"name": "spaces/FICTIONAL_DM"},
    "user": {"name": "users/FICTIONAL_TESTER", "type": "HUMAN"},
    "message": {
      "name": "spaces/FICTIONAL_DM/messages/FICTIONAL_CARD",
      "sender": {"name": "users/FICTIONAL_APP", "type": "BOT"}
    },
    "common": {
      "invokedFunction": "onCardClick",
      "parameters": {"revision": "1", "requestId": "REQ-001", "view": "next"}
    }
  },
  "removed": {
    "type": "REMOVED_FROM_SPACE",
    "eventTime": "2026-01-15T10:03:00Z",
    "space": {"name": "spaces/FICTIONAL_DM"},
    "user": {"name": "users/FICTIONAL_TESTER", "type": "HUMAN"}
  }
}