Answer a request in Google Chat
Build a private Apps Script Chat app that reads fictional requests, returns a useful card, and handles its message and removal lifecycle.
Configure one private development Chat app, ask for a fictional request, update its card to show a next step, and explain the event, permission, and delivery boundaries.
Ask Practice Desk Dev for status REQ-001. It looks up a fictional workshop request and returns a card showing Workshop map and Waiting for review. Click Show next step to display a suggestion in that same message. The app does not assign work, change a request, or contact the suggested person.
The three records live in Code.gs, not in a production ticket system. That keeps the first Chat integration focused on receiving an event, interpreting a message, returning the right response, and connecting the deployment to a private test audience. You need the objects, arrays, callbacks, and runtime distinctions from the prerequisites. Read the Workspace add-on chapter as a conceptual comparison; do not install or modify its Drive project for this lesson.
Choose the native interaction-event surface
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 shown in Google's current Apps Script Chat quickstart. Its configuration clears Build this Chat app as a Google Workspace add-on. That choice determines the event and response formats throughout the lesson.
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, adopt its manifest, event, and response contracts together rather than combining its configuration with this project's callbacks.
| Surface | What reaches the code | What this lesson does with it |
|---|---|---|
| Native Apps Script Chat app | Top-level Chat type, message, space, user, and card common fields | Implements onAddToSpace, onMessage, onCardClick, and onRemoveFromSpace |
| Workspace add-on | Its host-specific event and commonEventObject; the Drive prerequisite returns CardService cards/action responses | Comparison only; no Drive homepage or add-on navigation stack here |
| HTTP Chat app | A request delivered to a separately hosted HTTP endpoint | Not used; such an endpoint must verify Google's bearer token |
| Incoming Chat webhook | An outbound message sent to a particular space's webhook URL | Not used; it cannot receive this interactive conversation |
The native Event reference distinguishes its event from a Chat app built as a Workspace add-on. Similar card widgets do not make their event envelopes interchangeable. This app returns JavaScript objects shaped as Chat Message resources; it does not return HtmlOutput, a JSON string, or a Drive CardService.ActionResponse.
Keep the private test private
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.
Even with an empty OAuth scope list, interaction events deliver message and sender metadata to the handler, and Google applies the organization's Chat retention policies; see how Chat apps access interaction information.
Install the complete source
The runnable project consists of Code.gs and appsscript.json. chat-configuration.json records the console settings for reference, and practice-interactions.json shows sample event payloads.
/**
* 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, '&').replace(/</g, '<').replace(/>/g, '>');
}
/**
* 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);
}
{
"timeZone": "Etc/UTC",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"oauthScopes": [],
"chat": {}
}
{
"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."
}
{
"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"}
}
}
Prepare one Cloud configuration project
In the approved account, open the Google Cloud project selector. Create a new project named Practice Desk Dev in the approved organization/location. Record its project ID and project number.
With that new project selected, open APIs & Services > Library, search for Google Chat API, and enable it. This enables the API for app configuration; it does not grant an OAuth scope or install an app into Chat.
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.
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
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.Replace the template
Code.gswith the file above so there is only one definition of each of the four Chat callbacks.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.
Keep this script's automatically associated default Cloud project for this owner-only head test. The current Chat quickstart notes that associating the script with the standard Cloud project is unnecessary for its synchronous example. The script project and the Cloud project remain separate even when they share the same display name.
Choose Deploy > Test deployments, copy this project's Head deployment ID, and click Done. Do not copy the Script ID from Project Settings instead.
A head deployment follows the most recently saved source. The general deployment guide requires at least read access to the script for a head deployment, while Chat's deployment guidance limits head deployments to development.
The manifest's empty chat object selects the native Chat project configuration; callback routing, visibility, and deployment IDs are configured in the Chat API console. Its oauthScopes array and dependencies object are empty because the script uses only built-in JavaScript and constant lifecycle logging. See the manifest reference.
Connect the private Chat app
Return to the new Cloud configuration project, not its similarly named Apps Script default project. Open Google Chat API > Manage > Configuration and apply these settings before saving:
| Console setting | Value for this project |
|---|---|
| Build this Chat app as a Google Workspace add-on | Clear it; confirm Disable in the model-change dialog |
| 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 | On |
| Join spaces and group conversations | Off; direct messaging remains available |
| Connection settings | Apps Script |
| Deployment ID | The exact Head deployment ID copied from this new script |
| Visibility | Specific people and groups in your domain, containing only your approved tester account |
| Commands, Link previews, and app home if offered | No commands or preview patterns; no app home |
| Optional Log errors to Logging | Leave off unless separately approved; start with Apps Script Executions |
Verify the Cloud project, deployment ID, native app mode, and single-account visibility, then click Save. Configuring the Chat API requires the applicable Cloud role, such as Chat apps Owner; see Chat configuration and trusted tester access.
The avatar is the Google-hosted development image used by the quickstart. Chat loads it as app branding; the script does not fetch it and places 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.
For a newly added app, expect a welcome message explaining the three request IDs and the fact that no request is changed. Reopening an already installed conversation need not produce another addition event. In the script's Executions, an addition callback logs CHAT_APP_ADDED, which records handler execution rather than client rendering.
Test the callbacks from the direct message in Google Chat rather than clicking Run in the Apps Script editor, which supplies no Chat event object; see Google's native quickstart.
Follow the event rather than guessing its fields
Chat invokes the four named functions for the native events below. They are not simple spreadsheet triggers, scheduled triggers, or HTTP handlers; there is no ScriptApp.newTrigger, doPost, or webhook URL to register.
| Interaction | Event type and fields | Response in this app |
|---|---|---|
| Add the app, including opening a new direct message | type: 'ADDED_TO_SPACE', space, interacting user; message only if applicable | Constant welcome text and an addition log |
Send status REQ-001 to the app | type: 'MESSAGE', message, space, user | A new card containing one matched source record |
| Click a button on the app's card | type: 'CARD_CLICKED', the app's message, acting user, and common action data | An updated version of that app message |
| Uninstall/remove the app | type: 'REMOVED_FROM_SPACE', space, user | A removal log, with no message response |
The EventType contract says removal has already happened, so the app cannot send a farewell response to that event. It also distinguishes direct messages from messages in a multi-person space: a native app receives supported invocations such as mentions, not arbitrary access to every conversation message. No event subscription for general membership/history changes is configured here.
requireChatEvent_ checks the expected type, a bounded space.name with the resource shape spaces/..., and the absence of a dialog interaction. It does not require every optional Google field or reject unrelated future metadata. requireChatRecord_ rejects a missing object, null, or an array before nested property access.
The @param, @returns, and @typedef lines are JSDoc comments for editor type checking; the runtime checks inside the functions enforce object, string, and allowed-value requirements.
Interpret message text and identities
message.argumentText is the message text with Chat app mentions stripped out. The source uses it when present, falling back to message.text only when that field is absent. A missing/non-string text value or more than 80 JavaScript string units produces help; the program never echoes arbitrary message content. The Message reference defines both text fields.
After trimming outer whitespace, the command accepts status, one space, and an ID shaped as REQ- followed by three digits. Letter case is ignored for this message command. help returns the fixed syntax reminder. This is ordinary message text, not a registered slash command; entering /status would not invoke a command this project configured.
The regular expression's capture supplies the request ID. find then compares it with the three source records and returns the match or undefined. The code reports an unknown ID rather than selecting the first row. It also checks that the catalog still contains three unique IDs and bounded, nonempty text fields.
Keep these identities distinct:
event.useris the actor interacting with the app. On a card click, that is the person clicking, whileevent.message.senderis the app that sent the card.space.nameidentifies the conversation. A direct message is still represented as a Chat space; the source need not guess its display name or email.message.nameidentifies a Chat message. The user's request message and the app's card are different messages. On a click, the event's message is the card message, not the original human question.The Apps Script deployment ID selects the script deployment, while 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 deduplicate messages.
The native Event and Message resources describe these relationships. The fixtures show the consumed shape with fictional names and omit unused platform fields.
Return a card, then update that message
requestReply_ returns a Message object with cardsV2. The older cards field is deprecated. Each entry contains a cardId and card; this card has a header and one section whose widgets array contains paragraphs and a button list. Its fallbackText supplies a short description when a client cannot display the card, such as in a notification. See the message/card contract.
A summary uses four widgets: request, status, a fictional-data reminder, and two buttons in one button-list widget. The next-step view adds one paragraph. The source constructs a new card from the catalog each time; it does not treat text in an old client card as authoritative request data.
The paragraph text supports a limited HTML formatting vocabulary, not arbitrary browser HTML. Labels use fixed <b> and <br> markup. escapeCardText_ encodes ampersands and angle brackets in the source record's values before inserting them as paragraph text. Encoding ampersands first avoids escaping the entities the helper creates. No user input, display name, email, URL, or mention is interpolated into a card. The Cards v2 reference documents text formatting and supported widgets.
Carry an explicit desired view with the button
Both buttons set onClick.action.function to the real callback name onCardClick. The action's parameters is an array of string key/value pairs. It supplies revision: '1', requestId: 'REQ-001', and either view: 'next' or view: 'summary'. A click reaches the native handler with the function name in event.common.invokedFunction and the parameters as a string-keyed map in event.common.parameters.
An outgoing array of pairs becomes the incoming parameters object, distinct 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.
The handler checks that invokedFunction matches onCardClick and requires the three string parameters: revision '1', one of the two views, and an existing catalog ID. It never evaluates a submitted function name dynamically.
A valid card click returns actionResponse: {type: 'UPDATE_MESSAGE'} with the rebuilt card. That response updates the Chat app's existing message rather than posting another card. The ActionResponse contract permits this only for CARD_CLICKED where the message sender type is BOT, which the handler checks. A normal message response omits actionResponse, so Chat treats it as a new message. Unsupported action parameters return a separate explanatory text response, not an apparent successful update.
The buttons name a desired result instead of toggling state. Repeated Show next step requests build the same next-step view from unchanged source, and repeated Show summary requests build the summary, so a duplicate click does not flip the result back.
Separate host delivery from outbound authority
For this deployment, Google Chat sends the interaction through its configured Apps Script connection and receives the function's return value. The application does not expose a public HTTP receiver. Shape checks validate the event payload structure; authentication relies on the configured Apps Script connection between Google Chat and the script project.
For a separately hosted HTTP Chat app, the boundary changes. Google's request-verification guide requires verification of the Google-issued bearer token, including the configured audience and issuer/identity rules. A public Apps Script doPost(e) with an invented header getter is not the native connection and does not implement that verification. The Event reference notes that modern apps do not use the legacy event.token field.
Synchronous responses do not require the app to obtain a Chat API access token. The app returns a Message within the current interaction, and Chat posts or updates it in that interaction's space. That is not permission to send to an arbitrary space later. Google's authentication guide separates these responses from REST calls.
An outbound operation such as calling spaces.messages.create after a scheduled job is a new API request. It needs supported authentication, the relevant scope, and access to the target resource. App authentication typically uses a service account; user authentication acts with the consenting user's authority. For example, the documented chat.bot scope supports app authentication, not a user token or domain-wide delegation. Do not add it to this script and assume that ScriptApp.getOAuthToken() has become an app credential.
Apps Script handles user authorization and OAuth tokens when an Apps Script Chat app is extended to use user-authorized Google services, as the architecture guide describes. That is distinct from the actor's identity in event data and from the Chat app identity displayed as the message sender. This project requests no Google data scopes and reads no User Properties. Every user included in the Chat app's visibility setting can query all three records in the catalog; per-user record filtering would require a separate authorization check.
Know what a displayed response proves
Chat requires a synchronous response within 30 seconds, in the space where the interaction occurred. This source performs a bounded in-memory lookup and builds at most one five-widget card, with no network fetch, sleep, or property write. See synchronous interaction responses.
A card displayed in Chat establishes that Chat accepted and displayed a response for that interaction. Show next step displaying a suggestion establishes a view update, not that anyone carried out the suggestion, received another message, or changed a request in another system. A completed Apps Script execution alone does not establish that the client rendered its returned card.
The interaction guide documents possible repeated HTTP delivery after transient failure, without guaranteed retries; a successful HTTP request with an invalid message payload is not retried under that rule. Design handlers to tolerate repeated invocations rather than assuming exactly-once delivery.
This app has no mutable business record to duplicate. Repeated lookups calculate again; repeated identical view requests produce the same view when the source is unchanged. Each resent status REQ-001 message is a new interaction and normally requests a new card. Two different view actions can race, and an old card can be rebuilt using newly saved head source.
If a response is missing, wait and inspect this project's Executions and the conversation before trying again, since sending again may produce another card.
Predict, change, observe, and restore
Keep the private direct message and the approved script editor open. These steps assume the supplied source is saved and the app was added in the previous section. Perform each send and click below once.
Predict the lookup. Before sending anything, find
REQ-001inPRACTICE_REQUESTS. Predict Workshop map, Waiting for review, and no suggested-next-step paragraph. Sendstatus REQ-001once and wait. Compare the actual card with those predictions. Check the matchingonMessageexecution if no card appears.Follow the action. 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 remain the next-step view, not toggle back or carry out the suggestion. Click Show summary to remove the suggestion from the view.
Distinguish a failed lookup. Send
status REQ-999. Expect the fixed not-in-catalog message, not a blank card, the first record, or a newly created request. Sendhelpif you need the supported IDs. The app does not echo the invalid command.Change source, not business state. In
PRACTICE_REQUESTS, change onlyREQ-001's title fromWorkshop maptoWorkshop <draft> & map. Keep its ID, status, and next step unchanged. Save and wait for the editor to finish saving. Predict that the card already on screen will not change by itself. Click Show summary on that card to request a rebuild. Expect the literal angle brackets and ampersand in its title paragraph, not removed markup or an HTML page. The action re-reads current head source even though its button was created earlier.Restore. Restore the title to
Workshop map, save, and click Show summary again. Expect the original title and status.Observe removal. 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 rather than treating absence of a reply as proof of uninstall.
Diagnose the boundary that failed
| Symptom | Inspect before changing anything |
|---|---|
| The app is absent from New chat | Check the signed-in Workspace account, single-account visibility, selected Cloud project, saved native configuration, and organizational installation policy. Unpublished apps are found through Chat search, not a public Marketplace listing. |
| The app receives the wrong event shape | Confirm 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_EVENT | Inspect the callback and deployment. Editor Run supplies no event; dialog/app-home/HTTP events are not this project's entry contract. These failures do not produce a success card. |
| A help message instead of a card | Use ordinary status REQ-001 text, one internal space, a valid ID, and at most 80 string units before trimming. An attachment-only message has no supported command. |
CHAT_EXPECTED_THREE_REQUESTS, CHAT_INVALID_CATALOG_ID, or CHAT_INVALID_CATALOG_TEXT | Restore the three source records, distinct IDs, and nonempty single-line text of at most 160 string units. |
| A card click fails or reports an unsupported action | Confirm the clicked message was sent by this app, the action targets onCardClick, and the three string parameters match this version. Obtain a fresh card after a protocol change. UPDATE_MESSAGE is not valid for an arbitrary human message. |
| Something went wrong, an absent card, or a timeout | Compare the source, manifest, and deployment ID, inspect Executions, and check the documented response shape. Successful handler completion does not prove valid card rendering. |
| Changes do not appear | Wait for source saving, then request a new view. Check that the Chat configuration points to this head deployment, not another script or a versioned deployment. Existing messages are not live bindings to source. |
| A prompt asks for mail, Drive files, Chat history, or external-request access | Stop. The supplied source and empty scope list need none of those operations. Inspect leftover template/source files and the selected project rather than granting more scope. |
Keep diagnostics minimal. The two lifecycle log messages contain no actor, space, message, or content identifiers. The script otherwise lets fixed-code structural/catalog errors fail visibly in execution history.
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 this exact Practice Desk Dev app. Click its name at the top and choose Uninstall. Confirm only this app. Google's Chat uninstall instructions distinguish this from hiding a conversation or removing an app from only one space.
Inspect the script's Executions for onRemoveFromSpace and the fixed CHAT_APP_REMOVED log, without requiring a farewell message. The callback returns nothing because Chat has already removed the app. It has no timers, credentials, subscriptions, or per-space records to delete, so it performs no data deletion.
After observing the uninstall log, return to the Cloud project, open Google Chat API > Manage > Configuration, turn Enable interactive features off while keeping the single-account visibility setting, and click Save; see the configuration guide.
Uninstalling the app and disabling interactive features stops new interactions without erasing existing Chat messages or execution logs.
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 a private GitHub issue and receive signed GitHub Actions receipts.
Project files
Complete source files for this chapter’s examples.
Read a fictional request through a private Google Chat app
Code.gs
Download file/**
* 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, '&').replace(/</g, '<').replace(/>/g, '>');
}
/**
* 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{
"timeZone": "Etc/UTC",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"oauthScopes": [],
"chat": {}
}
chat-configuration.json
Download file{
"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{
"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"}
}
}