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.
/plugin marketplace add jezweb/claude-skills/plugin install jezweb-tooling-skills@jezweb/claude-skillsThis skill inherits all available tools. When active, it can use any tool Claude has access to.
README.mdreferences/cards-v2-schema.mdreferences/common-errors.mdreferences/google-chat-docs.mdtemplates/bearer-token-verify.tstemplates/card-builder-examples.tstemplates/form-validation.tstemplates/interactive-bot.tstemplates/webhook-handler.tstemplates/wrangler.jsoncStatus: 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 URL
Webhook 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 troubleshootingThis skill should be used when the user asks to "create a slash command", "add a command", "write a custom command", "define command arguments", "use command frontmatter", "organize commands", "create command with file references", "interactive command", "use AskUserQuestion in command", or needs guidance on slash command structure, YAML frontmatter fields, dynamic arguments, bash execution in commands, user interaction patterns, or command development best practices for Claude Code.
This skill should be used when the user asks to "create an agent", "add an agent", "write a subagent", "agent frontmatter", "when to use description", "agent examples", "agent tools", "agent colors", "autonomous agent", or needs guidance on agent structure, system prompts, triggering conditions, or agent development best practices for Claude Code plugins.
This skill should be used when the user asks to "create a hook", "add a PreToolUse/PostToolUse/Stop hook", "validate tool use", "implement prompt-based hooks", "use ${CLAUDE_PLUGIN_ROOT}", "set up event-driven automation", "block dangerous commands", or mentions hook events (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, UserPromptSubmit, PreCompact, Notification). Provides comprehensive guidance for creating and implementing Claude Code plugin hooks with focus on advanced prompt-based hooks API.