Skip to content

feat(gateway): add native Google Chat platform adapter#14965

Open
ArnarValur wants to merge 4 commits into
NousResearch:mainfrom
ArnarValur:feat/google-chat-adapter
Open

feat(gateway): add native Google Chat platform adapter#14965
ArnarValur wants to merge 4 commits into
NousResearch:mainfrom
ArnarValur:feat/google-chat-adapter

Conversation

@ArnarValur

Copy link
Copy Markdown

Summary

Adds a native Google Chat platform adapter to the Hermes Agent gateway. The adapter connects via Cloud Pub/Sub (pull mode) for inbound events and the Chat REST API (v1) for outbound messages — no webhook endpoint, relay server, or public URL required.

Motivation

Google Chat is the built-in messaging platform for Google Workspace, used by millions of organizations. Adding native support lets Hermes users deploy their agent directly into their existing Workspace environment with minimal infrastructure — just a GCP project, a Pub/Sub subscription, and a service account.

I've been running this adapter in a staging environment on my own Workspace for several weeks via a custom overlay, and this PR is a clean upstream port following the established platform patterns (modeled after the Mattermost adapter).

Architecture

Google Chat  →  Pub/Sub topic  →  GoogleChatAdapter  →  Hermes Agent
Google Chat  ←  Chat REST API  ←  GoogleChatAdapter  ←  Hermes Agent
  • Inbound: A streaming-pull subscriber runs in a background thread (managed by google-cloud-pubsub), bridges events to asyncio via loop.call_soon_threadsafe, and dispatches to handle_message().
  • Outbound: Replies are sent through the Chat REST API with exponential backoff (3 attempts, 1s → 8s delay, jitter).
  • Event formats: Three inbound formats are auto-detected — Workspace Add-ons, native Chat API Pub/Sub, and relay/flat format.

Changes

New files (3)

File Description
gateway/platforms/google_chat.py Core adapter (649 LOC)
tests/gateway/test_google_chat.py 40 unit tests
website/docs/user-guide/messaging/google_chat.md Docusaurus setup guide

Modified files (9)

File Change
gateway/config.py GOOGLE_CHAT enum + env var loading
gateway/run.py Adapter factory, auth maps, allowed platforms
agent/prompt_builder.py Platform hint for formatting capabilities
toolsets.py hermes-google-chat toolset + gateway composite
cron/scheduler.py Cron delivery mapping
tools/send_message_tool.py Direct-send routing + _send_google_chat()
hermes_cli/status.py Status display registration
hermes_cli/gateway.py Setup wizard entry
hermes_cli/config.py Env var registry

Total: +1,524 lines, -2 lines across 12 files.

Key design decisions

  • Dependency isolation: All Google Cloud imports (google-cloud-pubsub, google-auth, google-api-python-client) are guarded with try/except ImportError. The gateway starts cleanly even without these packages installed.
  • argumentText preference: The parser prefers argumentText over text, automatically stripping @mention prefixes.
  • Auto-chunking: Messages exceeding 4,096 characters are split and sent sequentially.
  • Dual auth: Supports both Service Account JSON files (GOOGLE_CHAT_CREDENTIALS) and Application Default Credentials (ADC) for Cloud Run / GCE deployments.

Testing

40 tests, all passing:

======================== 40 passed, 1 warning in 2.21s =========================

Coverage includes config loading, all 3 event format parsers, Pub/Sub callback ack/nack behavior, message formatting and truncation, send method (success, empty, missing service), requirements check, retry error classification, and integration point verification across all wired modules.

Existing Mattermost tests remain green (83 total: 40 Google Chat + 43 Mattermost).

Environment variables

Variable Required Description
GOOGLE_CHAT_GCP_PROJECT GCP project ID
GOOGLE_CHAT_PUBSUB_SUBSCRIPTION Subscription name (default: hermes-chat-inbound-sub)
GOOGLE_CHAT_CREDENTIALS Path to SA JSON key (falls back to ADC)
GOOGLE_CHAT_ALLOWED_USERS Comma-separated allowed emails
GOOGLE_CHAT_HOME_CHANNEL Space name for cron/notification delivery

Setup (quick version)

pip install google-cloud-pubsub google-auth google-api-python-client

export GOOGLE_CHAT_GCP_PROJECT=my-project
export GOOGLE_CHAT_PUBSUB_SUBSCRIPTION=hermes-chat-inbound-sub
export GOOGLE_CHAT_CREDENTIALS=/path/to/sa-key.json
export GOOGLE_CHAT_ALLOWED_USERS=you@example.com

hermes gateway

Full setup guide included in the docs at website/docs/user-guide/messaging/google_chat.md.

Maintainer availability

I'm running this adapter in a staging environment on my own Google Workspace and I'm happy to serve as an active maintainer/contributor for this feature going forward — bug fixes, reviews, documentation updates, whatever is needed.

This is one of my first public contributions of this scale, so I'm very open to constructive feedback on code style, architecture, or anything else. Feel free to be direct — I'm here to learn and to make this as solid as possible for the project. Available for any follow-up discussion or iteration needed to get this merged right.

Transparency note

This PR was developed with significant AI assistance (code generation, testing, and documentation), with a human in the steering seat throughout — reviewing design decisions, validating against a real Workspace environment, and directing the overall architecture. Wanted to be upfront about the workflow.

On behalf of Arnar ~ Claude Opus 4.6 via Antigravity

@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins labels Apr 24, 2026
@ArnarValur

Copy link
Copy Markdown
Author

Hello.
I will admit that this is semi-automated contribution and maybe with hopes that it will serve this awesome harness.
Here is a screenshot with a reply from Hermes, there might be some adjustments needed and Im looking into his replies and how tool-callings and other (presumably TUI) related things get streamed along.

Anything I can assist with going forward Im more than happy to lend my keyboard and my agent team. :)

image

Add a Google Chat adapter that connects via Cloud Pub/Sub for inbound
events and the Chat REST API (v1) for outbound messages. No external
relay server or webhook endpoint is needed — the adapter subscribes
directly to a Pub/Sub subscription in pull mode.

Core adapter (gateway/platforms/google_chat.py):
- Streaming-pull Pub/Sub subscriber with asyncio bridge
- Three inbound event formats: Workspace Add-ons, native Chat API
  Pub/Sub, and relay/flat format
- Outbound via Chat REST API with exponential backoff (3 attempts)
- Auto-chunking for the 4,096-char message limit
- Service Account JSON or Application Default Credentials (ADC)
- Strict dependency isolation (google-cloud-pubsub, google-auth,
  google-api-python-client)

Integration points (12 files):
- Platform enum + env var loading in gateway/config.py
- Adapter factory + auth maps in gateway/run.py
- Platform hints in agent/prompt_builder.py
- hermes-google-chat toolset in toolsets.py
- Cron delivery in cron/scheduler.py
- Direct-send routing in tools/send_message_tool.py
- Status display, setup wizard, and env var registry in hermes_cli/

Tests (40 passing):
- Config loading, event parsing (all 3 formats), Pub/Sub callback,
  message formatting/truncation, send method, requirements check,
  retry error classification, integration point verification

Documentation:
- Full Docusaurus setup guide at website/docs/user-guide/messaging/

Environment variables:
  GOOGLE_CHAT_GCP_PROJECT           GCP project ID (required)
  GOOGLE_CHAT_PUBSUB_SUBSCRIPTION   Subscription name
  GOOGLE_CHAT_CREDENTIALS           SA JSON key path (or use ADC)
  GOOGLE_CHAT_ALLOWED_USERS         Comma-separated allowed emails
  GOOGLE_CHAT_HOME_CHANNEL          Space name for cron delivery
@ArnarValur ArnarValur force-pushed the feat/google-chat-adapter branch from f628153 to efc65ef Compare April 24, 2026 07:59
@ArnarValur

Copy link
Copy Markdown
Author

Cron job delivered to the Chat platform. 👍

image

SubscriberClient() was instantiated without credentials, silently
falling back to Application Default Credentials (ADC) which expire
hourly.  The Chat API client already used the SA key from
GOOGLE_CHAT_CREDENTIALS — this extracts _build_credentials() and
shares the same SA across both Pub/Sub and Chat API clients.

When no SA key is configured, falls back to ADC with an explicit
warning log.
@ArnarValur

Copy link
Copy Markdown
Author

🔐 Fix: Share SA credentials with Pub/Sub subscriber

Problem: SubscriberClient() was instantiated without credentials, silently falling back to Application Default Credentials (ADC). ADC tokens expire every ~1 hour, requiring repeated gcloud auth application-default login cycles — impractical for headless/server deployments.

Meanwhile, the Chat API client (_build_chat_service) already correctly used the service-account key from GOOGLE_CHAT_CREDENTIALS.

Fix: Extracted a shared _build_credentials() method that:

  1. Prefers the explicit SA JSON key at GOOGLE_CHAT_CREDENTIALS (permanent, no expiry)
  2. Falls back to ADC with an explicit warning log when no SA key is configured
  3. Includes both chat.bot and pubsub scopes

Both SubscriberClient and build("chat", ...) now receive the same pre-built credentials object.

Impact: Zero breaking changes. Users without GOOGLE_CHAT_CREDENTIALS get the same ADC behavior (plus a helpful warning). Users with the SA key get permanent auth for both inbound (Pub/Sub) and outbound (Chat API) paths.

Work-in-progress: Markdown-to-Chat syntax conversion (bold, headers,
links) and invisible Unicode stripping (Variation Selectors, ZWJ, etc.)
to eliminate tofu rendering artifacts. Still has edge cases with bold
text wrapping — will iterate after PR feedback.

Also updates platform prompt hint to guide LLM toward Chat-native
formatting syntax.
@cro

cro commented Apr 26, 2026

Copy link
Copy Markdown

Very interested in this!

@cro

cro commented Apr 27, 2026

Copy link
Copy Markdown

Greetings @ArnarValur . Thanks so much for this PR, I'm really looking forward to getting it working. May I suggest some more detailed documentation around setup? There doesn't appear to be a "Chat Bot" role when creating the service account, though I admit I might be missing it. I can find "Chat Apps Viewer", "Chat Apps Owner", "Chat Viewer", "Chat Owner". LLMs suggest creating a custom role, but I can't find the permissions the LLM is suggesting. The Google documentation here https://developers.google.com/workspace/chat/authenticate-authorize-chat-app seems to say things quite different from this integration's docs. Any help appreciated.

@ArnarValur

Copy link
Copy Markdown
Author

@cro greetings! Yes I can look into that tonight. This was an automated pr that the AG agent (opus 4.6) suggested the other day and of course this surely need refinement and polish.

I wanted to experiment with using Google Chat instead of Discord, Slack, WhatApp, etc so Org communication and try to get Hermes-Agent to work with that chat interface, have all in one ecosystem sorta speak.

I'll look into this tonight. 👍

- Remove fabricated 'Chat Bots' IAM role (does not exist in GCP)
- Clarify that chat.bot OAuth scope is self-granted at runtime
- Add missing Pub/Sub Subscriber role for SA on subscription
- Add missing Chat API config steps (add-on checkbox, visibility, functionality)
- Fix Workspace tier: requires Business or Enterprise (not personal)
- Document GOOGLE_CHAT_ALLOW_ALL_USERS escape hatch
- Add OAuth scopes section in Authentication

@ArnarValur ArnarValur left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the issues


1. Go to **Pub/Sub** in the Cloud Console.
2. Create a **topic** (e.g., `hermes-chat-inbound`).
3. Grant the Chat API's internal service account (`chat-api-push@system.gserviceaccount.com`) the **Pub/Sub Publisher** role on this topic. This allows Google Chat to publish events to your topic.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure chat-api-push@system.gserviceaccount.com is the right service account name, if this wasn't intended as a placeholder or example. That didn't work for me; I used the service account name in the Chat API Configuration screen under "Connection Settings" (just below the radio buttons for HTTP endpoint URL / Apps Script / Cloud Pub/Sub / Dialogflow). I think I defined that when I enabled the Chat API. It ends in @gcp-sa-gsuiteaddons.iam.gserviceaccount.com.

@cro

cro commented Apr 27, 2026

Copy link
Copy Markdown

Thank you! I almost have it working. I checked out a branch from the PR and set things up. I'm getting this traceback when I message the bot:

ERROR gateway.run: Agent error in session agent:main:google_chat:dm:spaces/h-vqkyAAAAE:spaces/h-vqkyAAAAE/threads/r0QcUuSdzvk
Traceback (most recent call last):
  File "/home/krell/.hermes/hermes-agent/gateway/run.py", line 4525, in _handle_message_with_agent
    agent_result = await self._run_agent(
                   ^^^^^^^^^^^^^^^^^^^^^^
    ...<9 lines>...
    )
    ^
  File "/home/krell/.hermes/hermes-agent/gateway/run.py", line 9355, in _run_agent
    enabled_toolsets = sorted(_get_platform_tools(user_config, platform_key))
                              ~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/krell/.hermes/hermes-agent/hermes_cli/tools_config.py", line 558, in _get_platform_tools
    default_ts = PLATFORMS[platform]["default_toolset"]
                 ~~~~~~~~~^^^^^^^^^^
KeyError: 'google_chat'

@cro

cro commented Apr 27, 2026

Copy link
Copy Markdown

Ok, I found that problem,

index 05507eace..9f2ae8724 100644
--- a/hermes_cli/platforms.py
+++ b/hermes_cli/platforms.py
@@ -24,9 +24,11 @@ PLATFORMS: OrderedDict[str, PlatformInfo] = OrderedDict([
     ("discord",        PlatformInfo(label="💬 Discord",         default_toolset="hermes-discord")),
     ("slack",          PlatformInfo(label="💼 Slack",           default_toolset="hermes-slack")),
     ("whatsapp",       PlatformInfo(label="📱 WhatsApp",        default_toolset="hermes-whatsapp")),
+    ("google_chat",    PlatformInfo(label="👁️ Google Chat",     default_toolset="hermes-google-chat")),
     ("signal",         PlatformInfo(label="📡 Signal",          default_toolset="hermes-signal")),
     ("bluebubbles",    PlatformInfo(label="💙 BlueBubbles",     default_toolset="hermes-bluebubbles")),
     ("email",          PlatformInfo(label="📧 Email",           default_toolset="hermes-email")),
     ("homeassistant",  PlatformInfo(label="🏠 Home Assistant",  default_toolset="hermes-homeassistant")),
     ("mattermost",     PlatformInfo(label="💬 Mattermost",      default_toolset="hermes-mattermost")),
     ("matrix",         PlatformInfo(label="💬 Matrix",          default_toolset="hermes-matrix")),```

Maybe hermes_cli/platforms.py got left out of the PR?

Now my problem is that the Chat App only responds to the first message I send it. I don't see anything in the logs indicating dropped messages. I also use https://github.com/nesquena/hermes-webui as a web interface to Hermes, and while the first thing I type in the chat and the agent's response show in the session, nothing happens after that.

@netpenthe

Copy link
Copy Markdown

tried for a few hours to get this working without any luck.. logs say it is connected, but it never receives messages

donramon77 added a commit to donramon77/hermes-agent that referenced this pull request May 4, 2026
The Chat API publishes events with one of three envelope shapes
depending on how the bot is configured:

1. Workspace Add-ons (canonical, ce-type-driven):
   {"chat": {"messagePayload": {"message": {...}, "space": {...}}}}

2. Native Chat API Pub/Sub (bot configured without the Workspace
   Add-ons wrapper — events arrive directly from the publisher):
   {"type": "MESSAGE", "message": {...}, "space": {...}}

3. Relay / flat (custom Cloud Run relay that flattens Chat events into
   top-level fields so the bot can run without GCP credentials):
   {"event_type": "MESSAGE", "sender_email": ..., "text": ...,
    "space_name": ..., "thread_name": ..., "message_name": ...}

Previously only format 1 was parsed — format 2 and 3 fell through to
the messagePayload-missing branch and dropped silently. _on_pubsub_message
now delegates to a new _extract_message_payload helper that returns
(message, space, format_name) for any of the three, synthesizing a
Chat-API-shaped message dict for format 3 so downstream code
(_dispatch_message → _build_message_event) reads all three identically.

The relay-format synthesis includes a deterministic ``users/relay-…``
sender name surrogate so dedup keys stay stable across at-least-once
redelivery (Pub/Sub may replay the same logical event).

Tests: 4 new under TestExtractMessagePayload — native format extraction,
non-MESSAGE filtering, relay synthesis assertions, unrecognized
envelope handling. 130 prior tests still pass (134 total).

Patterns adapted from PR NousResearch#17828's NousResearch#14965 baseline by @ArnarValur,
who maintained a Workspace install for several weeks and identified the
parser coverage gap.

Co-Authored-By: Solmundur <168342312+ArnarValur@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
donramon77 added a commit to donramon77/hermes-agent that referenced this pull request May 4, 2026
…stry

Three reliability/operability improvements pulled from PR NousResearch#14965:

1. Outbound retry with exponential backoff. Wraps `_create_message`'s
   `messages.create().execute()` in `_call_with_retry`, a reusable
   helper that retries 3x on 429/5xx/timeout/connection errors with 1s
   → 8s base + 30% jitter. Permanent errors (4xx other than 429, auth,
   programmer errors) bubble up on the first attempt — no point
   masking misconfiguration with retries.

   Without this wrapper, a single 503 from Google's Chat REST API
   drops the user-visible reply silently.

2. Application Default Credentials fallback. `_load_sa_credentials`
   now tries `google.auth.default()` when no SA JSON is configured,
   so deploys on Cloud Run / GCE / GKE with workload identity work
   without managing key files. Local users keep using the explicit SA
   JSON path; only the unconfigured case changes (used to error,
   now ADCs).

3. Env-var registry entries for `GOOGLE_CHAT_PROJECT_ID`,
   `_SUBSCRIPTION_NAME`, `_SERVICE_ACCOUNT_JSON`, `_ALLOWED_USERS`,
   `_HOME_CHANNEL` in `hermes_cli/config.py`. Makes the env vars
   discoverable in `hermes config` UI under the "messaging" category,
   matching how Slack/Telegram/Mattermost are exposed.

Tests: 6 new (3 retry, 1 retryable-classifier, 2 ADC). 134 prior
tests still pass (140 total).

Patterns adapted from PR NousResearch#17828's NousResearch#14965 baseline by @ArnarValur.

Co-Authored-By: Solmundur <168342312+ArnarValur@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
donramon77 added a commit to donramon77/hermes-agent that referenced this pull request May 4, 2026
…strip

Two outbound polish improvements that fire on every reply:

1. `format_message`: convert standard Markdown emitted by the LLM to
   Google Chat's formatting dialect before the message hits the wire.
   Without this, **bold**, [text](url), and # headers render as
   literal asterisks/brackets/hashes — Chat's renderer ignores them.
   Conversions:
     **bold**       → *bold*       (Chat's bold uses single asterisks)
     ***x***        → *_x_*        (compound bold-italic)
     [txt](url)     → <url|txt>    (Slack-style anglebracket links)
     # Header       → *Header*     (Chat has no header support)

   Code blocks (fenced AND inline) are protected via placeholder
   substitution so backtick-wrapped content with literal asterisks or
   brackets stays verbatim — matters because the LLM regularly emits
   Python (`x = 2 ** 10`) and shell snippets that would otherwise get
   mangled.

2. Invisible Unicode strip: ZWJ, ZWNJ, ZWS, BOM, LTR/RTL marks, and
   Variation Selectors get removed before send. These render as tofu
   (□) in Chat's restricted font stack, especially in composite emoji
   like 👨‍👩‍👧 (family) and ZWJ flag sequences. Stripping leaves the
   base codepoints intact, so the user sees the closest-renderable
   form instead of square boxes.

Hooked into `send()` BEFORE chunking so the 4000-char limit applies
to the rendered form, not the source markdown. `send_image_file` and
other paths that build their own message bodies are NOT yet covered;
they emit pre-formatted text from internal callers and don't need the
LLM-output normalization. Can extend later if needed.

Tests: 12 new under TestFormatMessage covering all conversions, code
block protection (fenced + inline), edge cases (URLs with parens,
unmatched syntax, mid-line hashes, mixed bold+italic), Unicode
stripping (ZWJ in composite emoji, BOM, bidi marks), empty/None
safety. Two assertions document KNOWN limitations: URLs with parens
truncate at the first `)`, and pure-whitespace input collapses to one
space. 140 prior tests still pass (152 total).

Pattern lifted from PR NousResearch#14965 by @ArnarValur.

Co-Authored-By: Solmundur <168342312+ArnarValur@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
donramon77 added a commit to donramon77/hermes-agent that referenced this pull request May 4, 2026
Identity convention swap to match Teams' canonical pattern, eliminating
one of our 4 core touches in the process:

Before:
  source.user_id     = "users/{id}"      (Chat resource name)
  source.user_id_alt = sender_email
  + a google_chat-specific block in gateway/run.py:_is_user_authorized
    that injected user_id_alt into the allowlist match set so
    GOOGLE_CHAT_ALLOWED_USERS=email@x.com would match

After:
  source.user_id     = sender_email or sender_name (email when present)
  source.user_id_alt = sender_name (the "users/{id}" resource name)
  + the bridge block in gateway/run.py is REMOVED — the generic
    allowlist match in _is_user_authorized now finds the email
    naturally because it IS the canonical user_id

Why:
- Operators configure GOOGLE_CHAT_ALLOWED_USERS with email addresses
  (the value Google Chat surfaces in its UI), so the email is the
  natural canonical id. Less indirection for operators.
- Removes one of our 4 core touches in gateway/run.py — net diff
  reduction in the upstream PR.
- Matches Teams' identity convention (Teams uses the AAD object ID
  directly as user_id without a side channel).
- Falls back to the resource name when sender has no email (rare —
  bot-to-bot or system events) so allowlists keyed by users/{id}
  still work for that path.

Migration risk: theoretical only. Existing group sessions that hashed
user_id from the resource name would get new session keys after the
swap. In production: zero google_chat group sessions exist (only DMs,
which key off chat_id). The PR has not shipped, so no upstream
operator has stable group-session state to lose.

Tests: 1 updated (TestBuildMessageEvent: user_id == email,
user_id_alt == "users/12345"), 1 renamed + 1 added in
TestAuthorizationEmailMatch (canonical email-match case + fallback
to resource name when no email is available). 152 prior tests still
pass (153 total).

Pattern lifted from PR NousResearch#14965 by @ArnarValur.

Co-Authored-By: Solmundur <168342312+ArnarValur@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants