services/google-chat-unofficial-api.md
Table of Contents
Google Chat (personal Gmail) via the web app's internal API
The official Chat API and gog chat only work for Workspace accounts. Personal Gmail is rejected. The Chat web app talks to an internal API that works with the browser's cookies, so the google-chat skill (~/.agents/skills/google-chat, CLI gchat) drives that directly. Commands: list, read, members, spaces, download, and send/reply/react/edit/delete (writes are dry runs unless --yes).
How it works
- Endpoint:
POST https://chat.google.com/u/0/api/<method>?c=<n>. Bodies are positional protobuf-as-JSON arrays. Responses start with)]}'and then JSON. - Methods used:
paginated_world(conversation list),list_topics(messages),list_members(members) and thebatchexecuteRPCjfcZG(Browse spaces). Others seen:get_group,get_members,catch_up_*,heartbeat,get_smart_replies. - Auth is not a JWT and not SAPISIDHASH. It is the full Google cookie jar from the browser (httpOnly ones included, a subset fails with a 302 to login) plus header
x-framework-xsrf-token. The token is"SMqcke":"..."in the raw HTML ofGET /app/home. A 302 or no match means the cookies are stale. SIDCC cookies rotate through Set-Cookie, so the client updates its jar. list_topicsalso needs headerx-goog-chat-space-id: <id>.- Cookie storage:
~/.agents/skills/google-chat/state/session.json(0600, gitignored). - Failure workflow: on a stale session the CLI runs
lib/refresh.mjsunder plainnode, then retries once. Seamless by design: if thepersonalwindow is up (port 9222) it attaches over CDP, uses a short-lived tab and closes it; if not, it spawns the raw Chromium binary on the profile with--headless=newplus a CDP port, exports, and SIGTERMs it (SIGKILL after 8s). Only when the profile is signed out does it open a visible window (browser open https://chat.google.com/app/home), exit 2, and the CLI exits 3 (LOGIN_REQUIRED) so Dom signs in. Test knobs:GCHAT_BROWSER_MODE=headless|attach,GCHAT_PROFILE_DIR. - Gotcha (macOS): never open the real profile with Playwright
launchPersistentContext. Playwright launches Chromium with--use-mock-keychainand its own flags, so the profile's encrypted cookies cannot be decrypted, Chromium treats them as invalid and deletes the rows, and the profile lands on the sign-in page. Use the raw binary with--headless=new --remote-debugging-port=<p> --user-data-dir=<profile>and attach withchromium.connectOverCDP. Never run it while the headed window holds the profile lock.
Field maps
paginated_world (res[0][4][n], unsorted, sort client-side by e[1][0] desc):
e[0]group id wrapper.[["id"]]for spaces and group DMs,[null,null,["id"]]for 1:1 DMs. Pass it back unchanged aslist_topicsbody[7].e[1][0]last activity (micros),e[2]sort ts,e[4]space name (null for DMs),e[12]last message,e[31]members,e[35][0][1]type (SPACE,GROUP_DM, null for a 1:1 DM),e[3][0][0][0]self user id.- User record:
[[id],"Name",avatar,"email","First","Last",...]. - Spaces ("groups") come from this same call:
e[35][0][1]=="SPACE"(gchat list --type space). All 8 were on page 1. - Paging: each response section
res[0][1][n]is["dfe.w.ws",null,null,ts,filter,hasMore,token]. The first request asks for 33 sections. Next page:body[1]=[[30,null,null,<ws[4]>,null,<ws[6]>]],body[3]=[4,2,5,6,7,3],body[9]=null; follow whilews[5]===trueand a token exists, and the next response'sres[0][1][0]is the next section state. Only section 15 (DMs and group DMs) pages; other sections have a token but hasMore null and return 0 entries; section 29 has hasMore true but no token (skip). Page 1 gave 39 conversations, all pages 77 (dm 34, space 8, group_dm 35, oldest Founders 2024-06-08).
list_topics request is a 100-element array: [1] limit, [3]=[null,null,null,null,[sortTs]], [4]=[3,1,4] (id in [[id]] form) or [3,4] ([null,null,[id]] form), [5]=1000, [6]=20, [7]=idForm, [8]=[now_micros], [9]=[lastActivityTs], [10]=2, [99] the locale/flags array copied from paginated_world.
list_topics response: res[0][1] is the topic list, oldest first for the initial load (newest first for scroll-up pages, see Pagination). topic[0][1] thread id, topic[6] messages (root first, then replies). Message [9] text, [1][0][0] author id, [2] ts (micros), [13] id. res[0][4] true means the start of history was reached. res[0][5] is always true, so it is not a has-older flag.
Pagination
- The limit is not clamped. Limit 3000 returned 3121 messages for a big space, and 500+ returned a small space's full history.
- Setting
body[3][4]to an older ts shifts the window, but it is anchor based and overlapping (a few messages before and after the anchor), not a strict "before". Sogchat read --sincejust grows the limit and filters client-side. - Scroll-up paging (captured from the UI by scrolling a long space, used by
gchat read --before):body = [null, pageSize(30), null, [cursor], [3,1,4], 1000, 20, [[convId]], [nowMicros], [lastActivityTs], 2, null, ...].cursoristopic[1](sort ts micros, equalstopic[14]) of the oldest topic on the previous page. Topics come back newest first (reverse them),res[0][4]true = start of history.limitcounts topics, not messages, so a page can hold more messages (a topic has several when it has replies). Verified on a 3000+ topic space: chained cursor pages have zero overlap, and--limit 3000 --before <date>followed by one more hop reaches the first message (2017) withreachedStarttrue. A date-only--beforecan overlap the newer page by up to a day, so chain with the exactolderCursorinstead.
Write calls
Captured by driving the web app in the playwright personal browser (page.on("response") on /u/0/api/) against a test DM with an alt account, then verified from the CLI (gchat send|reply|react, dry run unless --yes). All need x-goog-chat-space-id: <conv id> and a 100-element body whose [99] is the flags template with [99][0] set to a random signed int64 string (the template's 0 is fine for reads and reactions, send/reply were only tested with a random one). Sending origin and accept-language as the browser does costs nothing.
-
create_topic(new message):[1]text,[4]idForm,[5]=[1],[6]="0_"+9 random alnum chars,[7]=1,[8]=[1]. The client id length matters: 11 chars returned HTTP 500[["er",null,null,null,null,500,null,null,null,13]], 9 works. Replaying the same body again is idempotent (same topic id returns the existing message). -
create_message(thread reply):[0]=[null,null,null,[null,threadId,idForm]],[1]text,[5]11-char client id (server-style ids contain-/_),[6]=[1],[7]=[1]. -
update_reaction:[0]=[[null,null,null,[null,threadId,idForm]],messageId],[1]=[emoji],[2]=1add or2remove. Response[["dfe.m.ur",[ts,ts]]]. -
A root message's id can differ from its topic id in the last char (
0__0-NYRrubvs0__0-NYRruY), so take both fromreadoutput. -
UI side calls not needed:
update_frecent_emojis_v2,mark_topic_readstate,get_message_preview_metadata,heartbeat. -
Debug trick: replay the captured body verbatim through the client, then change one field at a time. That isolated the bad client id length in a few runs.
-
edit_message:[0]=[[null,null,null,[null,threadId,idForm]],ref],[1]new text,[4]=[1].refism[0][1]of the message, which for a root equals the thread id (and can differ fromm[13], the idreactuses in its last char). Responsedfe.m.emechoes the message. -
delete_message:[0]same as edit, nothing else. Responsedfe.m.dm. The message remains inlist_topicsas a tombstone: nom[1], empty text,m[10]=[[28,...]],m[7]deletion ts. The hover toolbar has Edit / Reply in thread / More actions; Delete is the last More item, followed by a Cancel/Delete dialog.
Message fields beyond text
- Reactions:
m[20]=[[[emoji],count,reactedBySelf,ts],...], absent when none. - Attachments and cards:
m[10]array. Type 13 = file,a[9]=[token,null,name,mime,[w,h]]; type 17 = Meet call card (a[15][5][0][0][1]holds the meet URL,m[14]carries the "Call missed" card); type 28 = deleted marker.
Attachments (upload and download, verified for png and txt)
- Upload is Scotty resumable:
POST https://chat.google.com/uploads?group_id=<conv>&topic_id=<T>&message_id=<T>&otr=false&transcoded_video=false&upload_type=ATTACHMENT&original_content_type=<mime>withx-goog-upload-protocol: resumable,x-goog-upload-command: start,x-goog-upload-content-length,x-goog-upload-file-name,x-goog-upload-header-content-type,chat-filename, empty body. Response headerx-goog-upload-url; thenPUTthe bytes there withx-goog-upload-command: upload, finalize,x-goog-upload-offset: 0,x-goog-upload-entity-md5: <hex>. Body is base64 protobuf: 1 token, 3 name, 4 mime, 5 {1:w,2:h} (images only), 24 mime, 25 sha256 hex, 26 md5 hex.Tis a client-made 11-char id. - Send:
create_topicorcreate_messagewith[2]=[entry,...], entry =[13,0,0,null,null,null,null,null,name+size+"0",[token,null,name,mime,[w,h]|null,null x6,[2],null x11,mime,b64(sha256hex),b64(md5hex)]].create_topic[6]is the 11-char upload id (no0_prefix) when a file is attached. Plain[8]=[1]is accepted (the UI sends a longer form). Several files go in the same[2]array. - Download:
GET /u/0/api/get_attachment_url?url_type=FIFE_URL&content_type=<mime>&attachment_token=<token>&allow_caching=true&sz=dgives a 302 to a cookielesslh3.googleusercontent.com/chat_attachment/...URL.sz=dreturns the original bytes (sha256 matched); other sizes return a re-encoded thumbnail. Anoriginheader on this GET makes it return 204. The served filename is "unnamed.<ext>", so save under the attachment's own name. - txt vs image: same flow. Only differences are
text/plainmime and no[w,h]. Mime is picked from the file extension client-side. - Gotcha: files staged in the web UI composer persist across navigation, so a recon run can send an extra copy of an earlier file.
Members (list_members, gchat members)
- Body:
[1]=[4,5],[4]=[["<spaceId>"]],[6]=1,[99]flags template; headerx-goog-chat-space-id. Response[["dfe.mem.lm",[entries],...]], entry[[[[userId]],null,[[spaceId]]], "<joinMicros>", 2, null, role]. - Role 4 shows up once per space (probably creator/manager; matched the people who made Tesla Phamily and the "Dom & Sarah / TL & Phuong" space), role 3 is a regular member. Not confirmed in the UI.
- 1:1 DMs return HTTP 404, so the CLI reads the two participants from
paginated_world(e[31]) instead. Group DMs and spaces work. Names and emails come from user records in the world list plus the response.
Browse spaces (jfcZG batchexecute, gchat spaces)
- Not on
/u/0/api/.POST /_/DynamiteWebUi/data/batchexecute?rpcids=jfcZG&source-path=/app/browse&f.sid=<FdrFJe>&bl=<cfb2h>&hl=en&soc-app=1&soc-platform=1&soc-device=1&_reqid=<n>&rt=c, form bodyf.req=[[["jfcZG","<json args>",null,"generic"]]]&at=<SNlM0e>&. The three tokens are in theGET /app/homeHTML (send auser-agentheader or it 302s). Response isrt=cchunked; thewrb.frline carries the result as a JSON string. - Args
[null,null,null,1,"","",null,[<filter>,0],0]. Filters:[]all,[[[1,0]]]joined,[[[3,2],[2,0]]]not joined (the dropdown's "All spaces", "Spaces I have joined", "Spaces I haven't joined"). - Result
["<nextToken>",[entries],"","",false,false]. Entry[[id],name,desc?,..,[5]=1 not joined/2 joined,[6]=avatar,[7]=false,[8]=inviter email (not joined only),[9]=[[[memberCount,1,2],...]]]. The UI also callslEfKtfor member previews, not needed. - Dom's account: 1 unjoined ("Thank you for Mila's presents", invited by TLca365@gmail.com, 2 members); 7 joined.
Space header buttons (Shared, Tasks, Thread, Board)
In a space the four top-right buttons open one side panel with four tabs, in the same order. Mapped against Bros (room id AAAAWDhNUSs).
- Shared (folder icon,
gchat shared): "Files" and "Links" tables. Legacy batchexecuteTBt4vbwithsource-path=/room/<spaceId>; the UI batches it withA9tNK([], returns an own-ish user id, not needed) in one request, then callsR0kgTc(per-file access check, not needed).TBt4vbworks on its own viabatch().- Args
[["space/<id>","<id>",2],null,1,6,null,null,[6,3,5]];[6,3,5]= section ids to return. - Result
[null,[[5],[6,[files]],[3,[links]]]]. Section 6 files, 3 links, 5 empty (probably media). - Entry
[content,[1],[topicId,null,[topicId,null,spaceRef]],sharedMicros,null,modifiedMicros,[uid,"human/uid",0]].entry[2][0]is the message id. - File content
[2|3,0,0,[driveId,name,null,mime,...,[25]=driveUrl],...,trailing [[null,null,url]]]. Native Google Docs have no[25], the url is the trailing one. - Link content
[1,0,0,null,null,null,[title,desc,thumb,w,h,null,[null,null,url],domain,...,[null,null,redirectUrl],...],...]. - Bros: 2 files and 6 links, sharers resolve through the world user map.
- Args
- Tasks (task_alt icon): "Active Tasks" plus "Add task to space". Shown only in spaces, not 1:1 DMs. It is the Google Tasks embed, not Chat's API:
POST https://tasks.google.com/_/TasksWebEmbedUi/data/batchexecute?rpcids=SOV0tb&source-path=/embed/...&bl=boq_taskswebuiserver_...(create sends a batch of task ops, the task carries[null,"<title>",...,[null,...,["spaces/<id>/messages/<topic>.<msg>"]]]as its chat link). The message menu "Add to Tasks" in a DM created a real task in Dom's Google Tasks Work list (visible viagog tasks, withlinks[].type = chat_message,https://mail.google.com/chat/#chat/dm/<id>/<topic>); I deleted it withgog tasks delete. Gotcha: testing this touches the real account, not the alt, so clean up.- Space-scoped tasks are a different store (mapped 2026-10-12 with a throwaway space): the header Tasks tab holds a room-scoped list
~defaulttitled "My Tasks" inside the embed (sub-frame with an empty URL, so searchpage.frames()for "Add task to space").gog taskscannot see or create them:gog tasks listsshows only Personal and Work and the space task is in neither, even with--show-assigned --show-completed --show-hidden. Creating one posts "Created a task (via Tasks)" in the space. - Create call:
POST https://tasks.google.com/_/TasksWebEmbedUi/data/batchexecute?rpcids=SOV0tb&source-path=/embed/room/<spaceId>/list/~default&f.sid=<sid>&bl=boq_taskswebuiserver_20261007.07_p0&hl=en&soc-app=617&soc-platform=1&soc-device=1&_reqid=<n>&rt=c, formf.req=[[["SOV0tb","<json>",null,"generic"]]]&at=<xsrf>:<ts>.<json>=[[null,[[sec,nanos],null,[ops...],[]]],null,[["<spaceId>"]],null,[reqId,[null,4,bl,"prod-00-us",3,4,2,248,1]]]. Ops use client-generated 16-char ids; the create op carries the title at[[null,[6,1,2,5,13]],[null,"<title>"]], others position the task and attach it to~default. Response: list state["~default",["My Tasks",null,"user"],...]and the task[id,[null,title],[ts],"~default",...,["<spaceId>"],["<userId>"]]. - Not wired: the
attoken andf.sidhave to be scraped from the embed page, and the read call (tab open) did not show up as a separate tasks.google.com request in the main-frame capture. Delete/complete not mapped; the test task went away with its test space.
- Space-scoped tasks are a different store (mapped 2026-10-12 with a throwaway space): the header Tasks tab holds a room-scoped list
- Thread (spool icon): followed-threads feed with "Mentions me" / "Following". Fires
list_topicswith a followed filter (body[3]=[null,null,null,null,null,null,1],body[4]=[3,1,4], spaceId list in[7]) andmark_group_readstate([[["<spaceId>"]],null,<lastReadMicros>,...], responsedfe.rs.mgrs). Side effect: opening the tab marks the space read. Not wired in the CLI. - Board (pin icon): "Pinned Messages" and "Pinned Resources".
POST /u/0/api/get_chat_boardbody[[["<spaceId>"]],null,[null,null,null,[5,2,1],[3,1]],...,[99]=flags]; response[["dfe.ge.gcb",[[[[["<id>"]],"<sectionId>"],1,[[false]],"0",null,[]],[[...],3,...]]]]with section type 1 = pinned messages, 3 = pinned resources. Populated (captured in the alt DM):["dfe.ge.gcb",[sections],null,null,[[message],...],[]],res[0][4]= pinned messages, each in thelist_topicsmessage layout (m[0][1]topic,m[1]author,m[2]micros,m[9]text,m[10]attachments,m[13]id). The body's first element isconv.idForm:[null,null,["<id>"]]for a DM,[["<id>"]]for a space. Wired as read-onlygchat board. Pinned resources ("Upload from Drive") not populated or decoded; pin time and pinner are not in the response.- Pin / unpin are message labels: message menu "Pin to board" calls
apply_message_label(body[[[null,null,null,[null,"<topic>",idForm]],"<msg>"],[2,null,0],...,[99]=flags], response["dfe.ml.aml",null,[[2,null,"0",["<uid>"]]],[micros,micros]]), "Unpin" callsremove_message_label(body[[...same ref...],[2]], response["dfe.ml.rml",null,[[2]],[...]]). Label 2 = pinned. The UI then re-fetchesget_chat_board. Not wired as CLI writes; tested only in the alt DM and unpinned afterwards. Playwright gotcha: once the Board panel is open, the message text also appears in the panel, so use.first()to hover the chat copy.
- Pin / unpin are message labels: message menu "Pin to board" calls
- Every tab open also fires
uQwtvc[1,[spaceRef]](a view ping, returns a micros timestamp) and the page keepsPOST /u/0/api/heartbeatgoing.
Create a space (create_group)
UI: New chat, then "Create a space", dialog with one input (aria-label "Enter space name.", 128 chars) and Create. POST /u/0/api/create_group, body [[name,null,null,null,null,null,null,null,[[1]],[],null,null,4,null,0,null,0],null,"<11-char client id>",0,null,9,null,[null,16,null,null,null,1000,20,[]],...nulls...,[99]=flags]. Response [["dfe.g.cg",[[["<spaceId>"]],"<name>",null,[["<creator userId>"]],.... The UI then fires get_group_scoped_capabilities, search_integration_actions, get_group, get_smart_replies. The space is created with the creator as sole member and a welcome card. Tested with a throwaway space on 2026-10-12 and deleted via the UI.
Open gaps
-
Space "Active Tasks" standalone read call (see Tasks above), pinned resources shape, Thread filter variants ("Mentions me" vs "Following"). Space tasks are NOT in
gog tasks; only the message "Add to Tasks" action lands in Dom's real lists. -
Space lifecycle:
create_groupdecoded (below), but delete's request name was not captured (UI: space name dropdown, Delete, confirm; it did not show as a/u/0/api/POST). Neither is wired in the CLI. -
Browse spaces: paging unverified (the
<nextToken>was always empty), and Browse "joined" omits Shortcut-MENTIONS (bot-created) thatpaginated_worldlists as a space. Joining a space was not captured. -
Role meaning (4 vs 3) in
list_membersis inferred, not confirmed in the UI. -
Anything that breaks is most likely Google changing the positional layout. Re-capture with the playwright-browser skill and re-map the indexes above.