tessl install github:jezweb/claude-skills --skill google-chat-apigithub.com/jezweb/claude-skills
Build Google Chat bots and webhooks with Cards v2, interactive forms, and Cloudflare Workers. Covers Spaces/Members/Reactions APIs, bearer token verification, and dialog patterns. Use when: creating Chat bots, workflow automation, interactive forms. Troubleshoot: bearer token 401, rate limit 429, card schema validation, webhook failures.
Review Score
87%
Validation Score
12/16
Implementation Score
77%
Activation Score
100%
Status: Production Ready Last Updated: 2026-01-09 (Added: Spaces API, Members API, Reactions API, Rate Limits) Dependencies: Cloudflare Workers (recommended), Web Crypto API for token verification Latest Versions: Google Chat API v1 (stable), Cards v2 (Cards v1 deprecated), wrangler@4.54.0
# No code needed - just configure in Google Chat
# 1. Go to Google Cloud Console
# 2. Create new project or select existing
# 3. Enable Google Chat API
# 4. Configure Chat app with webhook URLWebhook URL: https://your-worker.workers.dev/webhook
Why this matters:
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const event = await request.json()
// Respond with a card
return Response.json({
text: "Hello from bot!",
cardsV2: [{
cardId: "unique-card-1",
card: {
header: { title: "Welcome" },
sections: [{
widgets: [{
textParagraph: { text: "Click the button below" }
}, {
buttonList: {
buttons: [{
text: "Click me",
onClick: {
action: {
function: "handleClick",
parameters: [{ key: "data", value: "test" }]
}
}
}]
}
}]
}]
}
}]
})
}
}CRITICAL:
cardsV2 arrayasync function verifyToken(token: string): Promise<boolean> {
// Verify token is signed by chat@system.gserviceaccount.com
// See templates/bearer-token-verify.ts for full implementation
return true
}Why this matters:
Option A: Incoming Webhook (Notifications Only)
Best for:
Setup:
No code required - just HTTP POST:
curl -X POST 'https://chat.googleapis.com/v1/spaces/.../messages?key=...' \
-H 'Content-Type: application/json' \
-d '{"text": "Hello from webhook!"}'Option B: HTTP Endpoint Bot (Interactive)
Best for:
Setup:
Requires code - see templates/interactive-bot.ts
IMPORTANT: Use Cards v2 only. Cards v1 was deprecated in 2025. Cards v2 matches Material Design on web (faster rendering, better aesthetics).
Cards v2 structure:
{
"cardsV2": [{
"cardId": "unique-id",
"card": {
"header": {
"title": "Card Title",
"subtitle": "Optional subtitle",
"imageUrl": "https://..."
},
"sections": [{
"header": "Section 1",
"widgets": [
{ "textParagraph": { "text": "Some text" } },
{ "buttonList": { "buttons": [...] } }
]
}]
}
}]
}Widget Types:
textParagraph - Text contentbuttonList - Buttons (text or icon)textInput - Text input fieldselectionInput - Dropdowns, checkboxes, switchesdateTimePicker - Date/time selectiondivider - Horizontal lineimage - ImagesdecoratedText - Text with icon/buttonText Formatting (NEW: Sept 2025 - GA):
Cards v2 supports both HTML and Markdown formatting:
// HTML formatting (traditional)
{
textParagraph: {
text: "This is <b>bold</b> and <i>italic</i> text with <font color='#ea9999'>color</font>"
}
}
// Markdown formatting (NEW - better for AI agents)
{
textParagraph: {
text: "This is **bold** and *italic* text\n\n- Bullet list\n- Second item\n\n```\ncode block\n```"
}
}Supported Markdown (text messages and cards):
**bold** or *italic*`code` for inline code- list item or 1. ordered for lists```code block``` for multi-line code~strikethrough~Supported HTML (cards only):
<b>bold</b>, <i>italic</i>, <u>underline</u><font color="#FF0000">colored</font><a href="url">link</a>Why Markdown matters: LLMs naturally output Markdown. Before Sept 2025, you had to convert MarkdownโHTML. Now you can pass Markdown directly to Chat.
CRITICAL:
When user clicks button or submits form:
export default {
async fetch(request: Request): Promise<Response> {
const event = await request.json()
// Check event type
if (event.type === 'MESSAGE') {
// User sent message
return handleMessage(event)
}
if (event.type === 'CARD_CLICKED') {
// User clicked button
const action = event.action.actionMethodName
const params = event.action.parameters
if (action === 'submitForm') {
return handleFormSubmission(event)
}
}
return Response.json({ text: "Unknown event" })
}
}Event Types:
ADDED_TO_SPACE - Bot added to spaceREMOVED_FROM_SPACE - Bot removedMESSAGE - User sent messageCARD_CLICKED - User clicked button/submitted formโ
Return valid JSON with cardsV2 array structure
โ
Set unique cardId for each card
โ
Verify bearer tokens for HTTP endpoints (production)
โ
Handle all event types (MESSAGE, CARD_CLICKED, etc.)
โ
Keep widget count under 100 per card
โ
Validate form inputs server-side
โ Store secrets in code (use Cloudflare Workers secrets) โ Exceed 100 widgets per card (silently fails) โ Return malformed JSON (breaks entire message) โ Skip bearer token verification (security risk) โ Trust client-side validation only (validate server-side) โ Use synchronous blocking operations (timeout risk)
This skill prevents 6 documented issues:
Error: "Unauthorized" or "Invalid credentials" Source: Google Chat API Documentation Why It Happens: Token not verified or wrong verification method Prevention: Template includes Web Crypto API verification (Cloudflare Workers compatible)
Error: "Invalid JSON payload" or "Unknown field"
Source: Cards v2 API Reference
Why It Happens: Typo in field name, wrong nesting, or extra fields
Prevention: Use google-chat-cards library or templates with exact schema
Error: No error - widgets beyond 100 simply don't render Source: Google Chat API Limits Why It Happens: Adding too many widgets to single card Prevention: Skill documents 100 widget limit + pagination patterns
Error: Form doesn't show validation errors to user Source: Interactive Cards Documentation Why It Happens: Wrong error response format Prevention: Templates include correct error format:
{
"actionResponse": {
"type": "DIALOG",
"dialogAction": {
"actionStatus": {
"statusCode": "INVALID_ARGUMENT",
"userFacingMessage": "Email is required"
}
}
}
}Error: Chat shows "Unable to connect to bot" Source: Webhook Setup Guide Why It Happens: URL not publicly accessible, timeout, or wrong response format Prevention: Skill includes timeout handling + response format validation
Error: "RESOURCE_EXHAUSTED" or 429 status code Source: Google Chat API Quotas Why It Happens: Exceeding per-project, per-space, or per-user request limits Prevention: Skill documents rate limits + exponential backoff pattern
{
"name": "google-chat-bot",
"main": "src/index.ts",
"compatibility_date": "2026-01-03",
"compatibility_flags": ["nodejs_compat"],
// Secrets (set with: wrangler secret put CHAT_BOT_TOKEN)
"vars": {
"ALLOWED_SPACES": "spaces/SPACE_ID_1,spaces/SPACE_ID_2"
}
}Why these settings:
nodejs_compat - Required for Web Crypto API (token verification)// External service sends notification to Chat
async function sendNotification(webhookUrl: string, message: string) {
await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: message,
cardsV2: [{
cardId: `notif-${Date.now()}`,
card: {
header: { title: "Alert" },
sections: [{
widgets: [{
textParagraph: { text: message }
}]
}]
}
}]
})
})
}When to use: CI/CD alerts, monitoring notifications, event triggers
// Show form to collect data
function showForm() {
return {
cardsV2: [{
cardId: "form-card",
card: {
header: { title: "Enter Details" },
sections: [{
widgets: [
{
textInput: {
name: "email",
label: "Email",
type: "SINGLE_LINE",
hintText: "user@example.com"
}
},
{
selectionInput: {
name: "priority",
label: "Priority",
type: "DROPDOWN",
items: [
{ text: "Low", value: "low" },
{ text: "High", value: "high" }
]
}
},
{
buttonList: {
buttons: [{
text: "Submit",
onClick: {
action: {
function: "submitForm",
parameters: [{
key: "formId",
value: "contact-form"
}]
}
}
}]
}
}
]
}]
}
}]
}
}When to use: Data collection, approval workflows, ticket creation
// Open modal dialog
function openDialog() {
return {
actionResponse: {
type: "DIALOG",
dialogAction: {
dialog: {
body: {
sections: [{
header: "Confirm Action",
widgets: [{
textParagraph: { text: "Are you sure?" }
}, {
buttonList: {
buttons: [
{
text: "Confirm",
onClick: {
action: { function: "confirm" }
}
},
{
text: "Cancel",
onClick: {
action: { function: "cancel" }
}
}
]
}
}]
}]
}
}
}
}
}
}When to use: Confirmations, multi-step workflows, focused data entry
No executable scripts for this skill.
Required for all projects:
templates/webhook-handler.ts - Basic webhook receivertemplates/wrangler.jsonc - Cloudflare Workers configOptional based on needs:
templates/interactive-bot.ts - HTTP endpoint with event handlingtemplates/card-builder-examples.ts - Common card patternstemplates/form-validation.ts - Input validation with error responsestemplates/bearer-token-verify.ts - Token verification utilityWhen to load these: Claude should reference templates when user asks to:
references/google-chat-docs.md - Key documentation linksreferences/cards-v2-schema.md - Complete card structure referencereferences/common-errors.md - Error troubleshooting guideWhen Claude should load these: Troubleshooting errors, designing cards, understanding API
Register slash commands for quick actions:
// User types: /create-ticket Bug in login
if (event.message?.slashCommand?.commandName === 'create-ticket') {
const text = event.message.argumentText
return Response.json({
text: `Creating ticket: ${text}`,
cardsV2: [/* ticket confirmation card */]
})
}Use cases: Quick actions, shortcuts, power user features
Reply in existing thread:
return Response.json({
text: "Reply in thread",
thread: {
name: event.message.thread.name // Use existing thread
}
})Use cases: Conversations, follow-ups, grouped discussions
Programmatically manage Google Chat spaces (rooms). Requires Chat Admin or App permissions.
| Method | Description | Scope Required |
|---|---|---|
spaces.create | Create new space | chat.spaces.create |
spaces.delete | Delete a space | chat.delete |
spaces.get | Get space details | chat.spaces.readonly |
spaces.list | List spaces bot is in | chat.spaces.readonly |
spaces.patch | Update space settings | chat.spaces |
spaces.search | Search spaces by criteria | chat.spaces.readonly |
spaces.setup | Create space and add members | chat.spaces.create |
spaces.findDirectMessage | Find DM with specific user | chat.spaces.readonly |
async function createSpace(accessToken: string) {
const response = await fetch('https://chat.googleapis.com/v1/spaces', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
spaceType: 'SPACE', // or 'GROUP_CHAT', 'DIRECT_MESSAGE'
displayName: 'Project Team',
singleUserBotDm: false,
spaceDetails: {
description: 'Team collaboration space',
guidelines: 'Be respectful and on-topic'
}
})
})
return response.json()
}async function listSpaces(accessToken: string) {
const response = await fetch(
'https://chat.googleapis.com/v1/spaces?pageSize=100',
{
headers: { 'Authorization': `Bearer ${accessToken}` }
}
)
const data = await response.json()
// Returns: { spaces: [...], nextPageToken: '...' }
return data.spaces
}async function searchSpaces(accessToken: string, query: string) {
const params = new URLSearchParams({
query: query, // e.g., 'displayName:Project'
pageSize: '50'
})
const response = await fetch(
`https://chat.googleapis.com/v1/spaces:search?${params}`,
{
headers: { 'Authorization': `Bearer ${accessToken}` }
}
)
return response.json()
}Search Query Syntax:
displayName:Project - Name contains "Project"spaceType:SPACE - Only spaces (not DMs)createTime>2025-01-01 - Created after dateAND/OR operatorsManage space membership programmatically. Requires User or App authorization.
| Method | Description | Scope Required |
|---|---|---|
spaces.members.create | Add member to space | chat.memberships |
spaces.members.delete | Remove member | chat.memberships |
spaces.members.get | Get member details | chat.memberships.readonly |
spaces.members.list | List all members | chat.memberships.readonly |
spaces.members.patch | Update member role | chat.memberships |
async function addMember(accessToken: string, spaceName: string, userEmail: string) {
const response = await fetch(
`https://chat.googleapis.com/v1/${spaceName}/members`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
member: {
name: `users/${userEmail}`,
type: 'HUMAN' // or 'BOT'
},
role: 'ROLE_MEMBER' // or 'ROLE_MANAGER'
})
}
)
return response.json()
}async function listMembers(accessToken: string, spaceName: string) {
const response = await fetch(
`https://chat.googleapis.com/v1/${spaceName}/members?pageSize=100`,
{
headers: { 'Authorization': `Bearer ${accessToken}` }
}
)
return response.json()
// Returns: { memberships: [...], nextPageToken: '...' }
}async function updateMemberRole(
accessToken: string,
memberName: string, // e.g., 'spaces/ABC/members/DEF'
newRole: 'ROLE_MEMBER' | 'ROLE_MANAGER'
) {
const response = await fetch(
`https://chat.googleapis.com/v1/${memberName}?updateMask=role`,
{
method: 'PATCH',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ role: newRole })
}
)
return response.json()
}Member Roles:
ROLE_MEMBER - Standard member (read/write messages)ROLE_MANAGER - Can manage space settings and membersAdd emoji reactions to messages. Added in 2025, supports custom workspace emojis.
| Method | Description |
|---|---|
spaces.messages.reactions.create | Add reaction to message |
spaces.messages.reactions.delete | Remove reaction |
spaces.messages.reactions.list | List reactions on message |
async function addReaction(
accessToken: string,
messageName: string, // e.g., 'spaces/ABC/messages/XYZ'
emoji: string
) {
const response = await fetch(
`https://chat.googleapis.com/v1/${messageName}/reactions`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
emoji: {
unicode: emoji // e.g., '๐' or custom emoji code
}
})
}
)
return response.json()
}async function listReactions(accessToken: string, messageName: string) {
const response = await fetch(
`https://chat.googleapis.com/v1/${messageName}/reactions?pageSize=100`,
{
headers: { 'Authorization': `Bearer ${accessToken}` }
}
)
return response.json()
// Returns: { reactions: [...], nextPageToken: '...' }
}Custom Emoji: Workspace administrators can upload custom emoji. Use the emoji's customEmoji.uid instead of unicode.
Google Chat API enforces strict quotas to prevent abuse. Understanding these limits is critical for production apps.
| Operation | Limit | Notes |
|---|---|---|
| Read operations | 3,000/min | spaces.get, members.list, messages.list |
| Membership writes | 300/min | members.create, members.delete |
| Space writes | 60/min | spaces.create, spaces.patch |
| Message operations | 600/min | messages.create, reactions.create |
| Reactions | 600/min | Shared with message operations |
| Operation | Limit |
|---|---|
| Read operations | 15/sec |
| Write operations | 1/sec |
User-authenticated requests are also throttled per user:
async function withRetry<T>(
fn: () => Promise<T>,
maxRetries = 3
): Promise<T> {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn()
} catch (error: any) {
if (error.status === 429) {
// Rate limited - wait with exponential backoff
const waitMs = Math.pow(2, i) * 1000 + Math.random() * 1000
await new Promise(r => setTimeout(r, waitMs))
continue
}
throw error
}
}
throw new Error('Max retries exceeded')
}
// Usage
const spaces = await withRetry(() => listSpaces(accessToken))Best Practices:
Required:
Optional:
google-chat-cards@1.0.3 - Type-safe card builder (unofficial){
"dependencies": {
"google-chat-cards": "^1.0.3"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260109.0",
"wrangler": "^4.58.0"
}
}Note: No official Google Chat npm package - use fetch API directly.
This skill is based on real-world implementations:
Token Savings: ~65-70% (8k โ 2.5k tokens) Errors Prevented: 6/6 documented issues Validation: โ Webhook handlers, โ Card builders, โ Token verification, โ Form validation, โ Rate limit handling
Solution: Implement bearer token verification (see templates/bearer-token-verify.ts)
Solution: Validate card JSON against Cards v2 schema, ensure exact field names
Solution: Split into multiple cards or use pagination
Solution: Return correct error format with actionResponse.dialogAction.actionStatus
Solution: Ensure URL is publicly accessible, responds within timeout, returns valid JSON
Use this checklist to verify your setup:
Questions? Issues?
references/common-errors.md for troubleshooting