services/google-chat-unofficial-api.md

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 the batchexecute RPC jfcZG (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 of GET /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_topics also needs header x-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.mjs under plain node, then retries once. Seamless by design: if the personal window 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=new plus 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-keychain and 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 with chromium.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 as list_topics body[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 while ws[5]===true and a token exists, and the next response's res[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". So gchat read --since just 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, ...]. cursor is topic[1] (sort ts micros, equals topic[14]) of the oldest topic on the previous page. Topics come back newest first (reverse them), res[0][4] true = start of history. limit counts 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) with reachedStart true. A date-only --before can overlap the newer page by up to a day, so chain with the exact olderCursor instead.

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]=1 add or 2 remove. Response [["dfe.m.ur",[ts,ts]]].

  • A root message's id can differ from its topic id in the last char (0__0-NYRrub vs 0__0-NYRruY), so take both from read output.

  • 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]. ref is m[0][1] of the message, which for a root equals the thread id (and can differ from m[13], the id react uses in its last char). Response dfe.m.em echoes the message.

  • delete_message: [0] same as edit, nothing else. Response dfe.m.dm. The message remains in list_topics as a tombstone: no m[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> with x-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 header x-goog-upload-url; then PUT the bytes there with x-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. T is a client-made 11-char id.
  • Send: create_topic or create_message with [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 (no 0_ 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=d gives a 302 to a cookieless lh3.googleusercontent.com/chat_attachment/... URL. sz=d returns the original bytes (sha256 matched); other sizes return a re-encoded thumbnail. An origin header 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/plain mime 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; header x-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 body f.req=[[["jfcZG","<json args>",null,"generic"]]]&at=<SNlM0e>&. The three tokens are in the GET /app/home HTML (send a user-agent header or it 302s). Response is rt=c chunked; the wrb.fr line 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 calls lEfKt for 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 batchexecute TBt4vb with source-path=/room/<spaceId>; the UI batches it with A9tNK ([], returns an own-ish user id, not needed) in one request, then calls R0kgTc (per-file access check, not needed). TBt4vb works on its own via batch().
    • 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.
  • 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 via gog tasks, with links[].type = chat_message, https://mail.google.com/chat/#chat/dm/<id>/<topic>); I deleted it with gog 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 ~default titled "My Tasks" inside the embed (sub-frame with an empty URL, so search page.frames() for "Add task to space"). gog tasks cannot see or create them: gog tasks lists shows 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, form f.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 at token and f.sid have 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.
  • Thread (spool icon): followed-threads feed with "Mentions me" / "Following". Fires list_topics with a followed filter (body[3]=[null,null,null,null,null,null,1], body[4]=[3,1,4], spaceId list in [7]) and mark_group_readstate ([[["<spaceId>"]],null,<lastReadMicros>,...], response dfe.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_board body [[["<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 the list_topics message 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 is conv.idForm: [null,null,["<id>"]] for a DM, [["<id>"]] for a space. Wired as read-only gchat 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" calls remove_message_label (body [[...same ref...],[2]], response ["dfe.ml.rml",null,[[2]],[...]]). Label 2 = pinned. The UI then re-fetches get_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.
  • Every tab open also fires uQwtvc [1,[spaceRef]] (a view ping, returns a micros timestamp) and the page keeps POST /u/0/api/heartbeat going.

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_group decoded (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) that paginated_world lists as a space. Joining a space was not captured.

  • Role meaning (4 vs 3) in list_members is 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.