Adom Google
Public Made by Adomby adom
Drive your whole Google Workspace from the terminal, run by your AI. Read Gmail, write Google Docs, build Google Sheets and Google Slides, search Google Drive, manage Google Calendar and Google Tasks, and post to Google Chat, plus any Google API and a zero-setup personal Gmail.
chat send: stamp clientAssignedMessageId so agent-sent messages are detectable #1
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:
- Chat v1 discovery doc — no app/attribution field on
Message,User, orAnnotation; no app filter inSearchMessagesRequest. - A known-badged message — reports
sender.type: HUMAN, identical to a human message. - A 756-message scan across 51 DM/group spaces — zero field-shape variation between agent-sent and hand-typed.
- 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
@@ -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()+@@ -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
Log in to comment.
No comments yet.