← All Pull Requests

chat send: stamp clientAssignedMessageId so agent-sent messages are detectable #1

Merged opened by Kyle Bergstedt 2026-08-20

Problem

Google Chat shows an "Adom Google" attribution badge next to the sender when a message is posted through the Chat API with user credentials — which is exactly what adom-google chat send does. That badge is the only way to tell "Drew's agent wrote this" from "Drew typed this."

That attribution is not retrievable through any API. Verified 2026-08-20, four independent ways:

  1. Chat v1 discovery doc — no app/attribution field on Message, User, or Annotation; no app filter in SearchMessagesRequest.
  2. A known-badged message — reports sender.type: HUMAN, identical to a human message.
  3. A 756-message scan across 51 DM/group spaces — zero field-shape variation between agent-sent and hand-typed.
  4. A controlled send through adom-google chat send (which does render the badge), read back: field-for-field identical to a human message.

Also ruled out: the Workspace Events payload and the Admin SDK chat audit log — neither records the posting app.

Google's docs describe the badge as a rendering behavior ("Chat attributes the Chat app to the message by displaying its name next to the user's name"); it never reaches the REST resource.

The change

spaces.messages.create accepts a messageId query param (returned on read as clientAssignedMessageId, must begin client-, ≤63 chars, lowercase alphanumeric + hyphens, unique within a space). It is invisible in the Chat UI.

chat send now stamps every outgoing message with client-agent-<20 hex>, so the receiving side gets an exact, machine-readable signal instead of guessing from prose style. --no-agent-marker opts out.

if not getattr(a, "no_agent_marker", False):
    params["messageId"] = "client-agent-" + secrets.token_hex(10)

secrets was already imported; no new dependency. The params dict changed from None to {} so the thread option and the marker can coexist, with params or None preserving the original call shape when both are absent.

Why it matters

Without this, anything that wants to react to agent-sent messages has to infer it from register — length, capitalisation, formality. I tuned such a heuristic against 1,725 real inbound DMs and the best operating point still carried false positives on long, formal human messages. The marker makes it exact and costs one query param.

Tests

Ran against a live throwaway space (created and deleted per run), 8/8 passing:

Case Result
Default send stamps a marker client-agent-<20 hex> present on read
--no-agent-marker no marker
3 sends in the same space 3 distinct markers, no uniqueness collision
--thread reply both messages in the same thread, reply also stamped
No thread, no marker works — the params or None path
--attach attachment sent, marker intact
Detector on a stamped short message exact hit where register analysis scores 0
ensure-style re-apply idempotent

Regression sweep: chat spaces, chat dm, chat read unchanged; file compiles clean; chat send --help lists --no-agent-marker alongside the existing flags.

Tests 3 and 4 are the load-bearing ones — messageId must be unique per space, so a weak generator would 409 on the second message in a thread, and the params merge is where a careless edit silently drops either the thread option or the marker.

Compatibility

Purely additive. Existing calls behave identically apart from now carrying a clientAssignedMessageId. Messages sent before this ships are unaffected, so detection coverage grows as the release propagates.

VERSION is left at 0.8.19 — happy to bump and cut a release if you'd rather that ride along with this.

Note on the docs change

SKILL.md gains a short block under the chat verbs recording the marker, how to read it back, and the API-surface finding above — mostly so the next person doesn't re-derive "the badge isn't in the API" from scratch.

Diff Skip to comments

--- a/bin/adom-google+++ b/bin/adom-google@@ -1,1989 +1,1998 @@⋯ 788 unchanged lines ⋯     attachments = [_chat_upload(cfg, space, p) for p in (getattr(a, "attach", None) or [])]     if attachments:         body["attachment"] = [{"attachmentDataRef": ref} for ref in attachments]-    params = None+    params = {}     if a.thread:         body["thread"] = {"threadKey": a.thread}-        params = {"messageReplyOption": "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"}-    msg = _chat(cfg, f"{space}/messages", method="POST", body=body, params=params)+        params["messageReplyOption"] = "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"+    # Stamp agent-sent messages so the RECEIVING side can detect them exactly.+    # Google Chat renders an app-attribution badge ("Adom Google") in the UI for+    # anything posted through the API with user credentials, but exposes that+    # attribution NOWHERE in the REST API. A custom clientAssignedMessageId is+    # the only durable, machine-readable signal, and it is invisible in the UI.+    # Opt out with --no-agent-marker.+    if not getattr(a, "no_agent_marker", False):+        params["messageId"] = "client-agent-" + secrets.token_hex(10)+    msg = _chat(cfg, f"{space}/messages", method="POST", body=body, params=params or None)     uri = sp.get("spaceUri", "")     who = (msg.get("sender") or {}).get("displayName") or "you"     print(msg.get("name", "") + (f"  ·  {uri}" if uri else ""))⋯ 1137 unchanged lines ⋯     ch = sub.add_parser("chat"); chx = ch.add_subparsers(dest="sub")     x = chx.add_parser("spaces"); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_chat_spaces)     x = chx.add_parser("dm"); x.add_argument("email"); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_chat_dm)-    x = chx.add_parser("send"); x.add_argument("--to", required=True, help="spaces/XXX id OR an email (auto-resolves the DM)"); x.add_argument("text", nargs="?", default="", help="message text, or '-' for stdin, or '@file' (optional when --attach is used)"); x.add_argument("--thread", help="threadKey for a threaded reply"); x.add_argument("--attach", action="append", metavar="PATH", help="attach a local file (image/video/PDF/any); repeatable"); x.set_defaults(fn=cmd_chat_send)+    x = chx.add_parser("send"); x.add_argument("--to", required=True, help="spaces/XXX id OR an email (auto-resolves the DM)"); x.add_argument("text", nargs="?", default="", help="message text, or '-' for stdin, or '@file' (optional when --attach is used)"); x.add_argument("--thread", help="threadKey for a threaded reply"); x.add_argument("--attach", action="append", metavar="PATH", help="attach a local file (image/video/PDF/any); repeatable"); x.add_argument("--no-agent-marker", action="store_true", help="don't stamp clientAssignedMessageId=client-agent-*"); x.set_defaults(fn=cmd_chat_send)     x = chx.add_parser("read"); x.add_argument("--from", dest="frm", required=True, help="spaces/XXX id OR an email"); x.add_argument("--limit", type=int, default=20); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_chat_read)      # Calendar (issue #662)⋯ 43 unchanged lines ⋯  if __name__ == "__main__":     main()+--- a/SKILL.md+++ b/SKILL.md@@ -1,276 +1,285 @@⋯ 192 unchanged lines ⋯ #   attaches it. Repeatable for several files. Text is optional when attaching. Same #   chat.messages scope as a text post, so no re-consent. adom-google chat read --from <space-id|email> [--limit N] [--json]   # recent messages+#   Every post is stamped clientAssignedMessageId=client-agent-<20 hex> so the RECEIVING+#   side can tell an agent-sent message from a hand-typed one. Chat shows an "Adom Google"+#   attribution badge in the UI for anything posted through the API with user credentials,+#   but exposes that attribution NOWHERE in the REST API — not on Message/User/Annotation,+#   not in searchMessages filters, not in the Workspace Events payload, not in the Admin SDK+#   chat audit log. The marker is the only durable signal, and it is invisible in the UI.+#   Read it back with: adom-google api <message-url> | jq -r .clientAssignedMessageId+#   Suppress it with --no-agent-marker.  adom-google auth --admin   # ALSO request the Workspace ADMIN bundle in the SAME consent: #   admin.directory.group + .group.member + .user.readonly + apps.groups.settings.⋯ 75 unchanged lines ⋯ See [MIGRATION.md](MIGRATION.md). Short version: `adom-gmail` is now a forwarding shim; its old `~/.config/adom-gmail/config.json` is auto-imported on first `adom-google` run (gmail.readonly only, run `adom-google auth` once to add the `contacts` scope).+

Comments

No comments yet.

Log in to comment.