⚙️ [a2ui-blog-host]: ui/initialize ...
📡 [public-edge-worker]: InitializeResult (200 OK)
✅ [status]: STATUS_LIVE_ARTIFACT // NO_SERVER_COMPUTE_ENGAGED
Blog › Rich UI in Google Chat: a real, self-hostable bot built on the print-channel trick
using a2ui in Google Chat · Vol. 05 LIVE_ARTIFACT

Rich UI in Google Chat: a real, self-hostable bot built on the print-channel trick

2026-07-26 9 min read series using a2ui in Google Chat · Vol 5 build 7214d4b

Who this is for: developers who want a real, self-hostable Google Chat app with server-rendered visuals — no Gemini Enterprise license needed, no custom widget set to fight, just a Cloud Run service you can clone and deploy yourself.

Live weather card, server-rendered and posted into a Google Chat space via a plain image URL

weather gif — 3-day Toulouse forecast

Live Google Workspace service status card, server-rendered and posted into a Google Chat space via a plain image URL

workspace stats gif — live Workspace service status

The bot this piece builds: a real Chat message, a real image URL, real data — no custom widget, no Gemini Enterprise, just Cloud Run and Chat's own API. Same pipeline, two completely different atoms.

The same wall, a different surface

A companion series on this blog, "using a2ui in Gemini Enterprise", covers the same underlying idea applied to a completely different host: Part 1 tested all 18 of Gemini Enterprise's standard A2UI primitives against a live agent, and Part 2 built a print-channel workaround for everything those 18 primitives can't express — render the atom to a real image server-side, deliver it through the one primitive that always works.

Google Chat hits the identical wall by a completely different route: a fixed cardsV2 widget set instead of a fixed A2UI catalog, but the same shape of ceiling — no custom layout, no charts beyond what Google ships. The fix carries over unchanged. This piece covers it end to end for Chat specifically: no A2UI protocol, no GE agent registration, just a Cloud Run service and Google's own Chat API — a real bundle you can clone and deploy today, in the cloud-run-renderer folder of this same repo:

a2uicatalog/a2ui on GitHub

The problem, restated for Chat

Chat's image widget will display anything with a URL. It has no idea what a gauge_sla or a weather_now atom is, and it never will — Chat isn't getting a custom-widget SDK. So the fix from Part 2 carries over unchanged: render the atom to a PNG or GIF using the exact same headless- Chromium pipeline every other surface in this catalogue uses, and hand Chat a plain image URL. Chat's own client fetches it and displays it — full visual fidelity, zero cooperation required from Chat's widget set.

One wrinkle is Chat-specific and worth naming up front: Chat's image fetch is anonymous — no bearer token, no signed-in identity, nothing. That single constraint decides almost every deployment choice below.

Tracing one request end to end

Before the how-to, here's exactly what happens when someone types weather gif into a space where this bot is installed:

Sequence diagram tracing one "weather gif" request from user, through Google Chat, to cloud-run-renderer and Open-Meteo, and back

(Rendered through the catalogue's own sequence_diagram atom, via the same headless-Chromium pipeline the rest of this piece is about — the diagram is a live example of the print channel, not a separate tool.)

Two separate fetches, not one: Chat's webhook POST to /chat returns a cardsV2 payload containing an imageUrl — not the image itself. Chat's own client then makes a second, completely separate, unauthenticated GET against that URL to actually retrieve the pixels. Missing that second hop is the single most common reason a first attempt at this pattern shows a broken-image icon instead of a picture: the card renders, the webhook response is valid JSON, and the image 404s or 401s anyway, because whatever served /chat wasn't also reachable anonymously for the GET.

Building the bundle: one service, not two

The natural first instinct is to gate the whole service with Cloud Run IAM — no public surface, no consequence. That doesn't survive contact with the constraint above: Cloud Run IAM is all-or-nothing per service. Gate the service and Chat's own webhook call still gets through (it presents its own service identity), but the anonymous image GET is now blocked along with everyone else's — the exact fetch the whole card depends on.

The version of this shipped in cloud-run-renderer folds both jobs into one Cloud Run service: POST /render is the core primitive (one atom block in, one PNG out), GET /render.png and GET /render.gif are the same rendering exposed as a plain URL Chat's client can fetch directly, and POST /chat is the actual webhook handler. All three share one render pipeline — nothing here talks to a second, separate service, and nothing here talks to any third-party API beyond two free, keyless public feeds (Google's own Workspace status feed, and Open-Meteo).

Deploy it

Prerequisites: a Google Cloud project with billing enabled, and the gcloud CLI authenticated. No API keys.

--allow-unauthenticated is the direct consequence of the anonymous-fetch constraint above — there's no secret this service holds, but every render is a real headless-Chromium launch, and a public URL means anyone can trigger one. Worth being precise about what "worst case" actually means before deploying, not after: Cloud Run Gen2 pricing is public ($0.000024/vCPU-second, $0.0000025/GiB-second), so three instances pinned at 100% CPU running unattended for a month is a real, calculable number — on the order of $200, not "unknown." The controls below turn that into a number you actually choose, not one an attacker does.

One-time setup — a signing key for render tokens (see below for why):

bash
gcloud secrets create render-signing-key \
  --project=YOUR_PROJECT --data-file=<(openssl rand -hex 32)
bash
gcloud run deploy YOUR_SERVICE_NAME \
  --source . \
  --project YOUR_PROJECT \
  --region YOUR_REGION \
  --allow-unauthenticated \
  --max-instances 3 \
  --concurrency 4 \
  --set-secrets RENDER_SIGNING_KEY=render-signing-key:latest

The endpoint has to be public. The expensive work doesn't have to run for just anyone who asks. /render.png and /render.gif are HMAC-signed: every token this service generates for its own /chat replies carries a signature, and a forged or guessed one gets an instant 403 before Chromium ever launches — only URLs this service itself produced are honored. That's the actual answer to "isn't a public renderer a DoS target": the reachability is unavoidable, but the cost isn't, because a stranger's traffic never reaches the renderer at all.

Three more bounds sit underneath that, cheap insurance against the signature check ever being bypassed or a signed URL leaking: width above 2000px and a deck above 12 blocks are rejected outright; a single block's own JSON is capped at 50KB, closing the gap those two don't (an in-bounds width and block count with one field — a chart's data array, say — padded enormous); and a flat cap of 30 renders per minute turns "cost" from an open-ended function of attack duration into a fixed, calculable ceiling regardless of how long anyone tries. None of this is a substitute for a real budget alert — it's what makes that alert a backstop instead of the only line of defense:

bash
gcloud billing budgets create \
  --billing-account=YOUR_BILLING_ACCOUNT_ID \
  --display-name="cloud-run-renderer budget" \
  --budget-amount=10USD \
  --threshold-rule=percent=0.5 \
  --threshold-rule=percent=1.0

These signatures don't expire, and on this surface that's the right call. The usual advice for a signed URL is to put a short exp timestamp inside the signed payload so a leaked link stops working after a few minutes. That advice assumes the URL is transient. A chat message isn't: a card posted today is still sitting in the space next month, and Chat's client re-fetches its image every time someone scrolls back to it. Put a fifteen-minute expiry on the token and every card older than fifteen minutes turns into a broken-image icon in the history. So the tokens here are unbounded in time, and what bounds the damage instead is that the render is deterministic: the URL carries its own gzipped payload, so a leaked link discloses nothing the leaked message didn't already show, and re-fetching it costs one render against the same 30-per-minute ceiling everyone else shares. If your surface isn't durable this way — a transient dashboard, a notification that ages out — add an exp field to the signed payload and check it before the decode. It's a few lines, and it's the right default there. It just isn't here.

Client caching is normally where this pattern bites, and the URL shape defuses it. Chat's client caches images, and the classic failure is a card that refuses to update: you click Refresh, the webhook round-trips correctly, and the picture doesn't change because the client already has that URL. That can't happen here, because the URL is the payload — the token is a gzip of the exact block being rendered, so a card built from new weather data is a different token, and therefore a different URL, and therefore a fetch the cache has never seen. Two renders share a URL only when they would produce identical pixels, which is precisely when you want the cache to answer. So responses go out with Cache-Control: public, max-age=300 rather than no-cache: the same card opened by ten people in a space costs one render, not ten. Reach for no-cache only if you break the content-addressing — a URL with a stable ?id= that re-renders live data server-side needs it, and pays for every view.

Registering it as a Google Chat app

Two gotchas here are worth more than the rest of the setup combined, because both cost real time before you find the actual right page — verified against Google's current documentation, not assumed from an older tutorial:

You can end up building the wrong kind of thing entirely. Searching "build a Google Chat app" surfaces Apps-Script-hosted tutorials under two different Google docs product lines — plain "Google Chat" quickstarts and the separate "Google Workspace add-ons" track. Both have you writing Apps Script functions in an appsscript.json-manifested project. Neither one ever touches a Cloud Run URL. If a tutorial opens the Apps Script editor, close it — this bundle needs the other kind of Chat app.

Even on the correct page, one dropdown has four options and three of them are wrong for this. On the Chat API's own Configuration page, Connection settings offers HTTP endpoint URL, Apps Script project (a Deployment ID), Cloud Pub/Sub Topic Name, and Dialogflow agent. This bundle needs HTTP endpoint URL, pointed at <your-service-url>/chat.

The rest of the page is straightforward: fill in an app name, avatar, and description; enable "Receive 1:1 messages" and/or "Join spaces and group conversations" depending on where you want it usable; leave Authentication Audience at its default (Google's own documentation states plainly that Cloud Run handles token verification automatically once chat@system.gserviceaccount.com is granted roles/run.invoker — only relevant at all if you gate the service, which the deploy above doesn't); set Visibility to whoever should see it; save.

Try it

text
sla 82
workspace stats
weather
weather gif

Screen-recorded live, unedited — weather gif sent to the real, deployed bot, card renders instantly, the baked image itself lands a moment later (a real network fetch, not instant), then cycles between the two frames:

Live screen recording of the weather gif demo in Google Chat

sla 82 is a single baked-image card — the fastest way to confirm the round trip works end to end. workspace stats and bare weather, on the other hand, both render as real native Chat widgets by default (decoratedText), not images at all — live data from Google's own status feed and Open-Meteo, composed directly from Chat's own primitives. That's deliberate: it's the direct "before" comparison for what comes next. Add gif to either command and the exact same data now renders as one animated image instead, through /render.gif — the print channel doing the part native widgets structurally can't (a full multi-card dashboard, custom typography, real charts), at the cost of losing the native widgets' live interactivity.

Key takeaways

The print-channel pattern itself isn't new information at this point — the Gemini Enterprise series already proved it. What's new here is that Chat and GE hit the identical wall by two completely different routes (a fixed A2UI primitive catalog vs. a fixed cardsV2 widget set) and both get solved the same way: render server-side, deliver a plain image URL, compose it with whatever native widgets the surface does have for the interactivity the picture can't carry.

Nothing about that fix is specific to A2UI, either. Swap "atom" for whatever an agent is trying to express and the pattern is the same one any agentic system needs the moment it wants richer visual output than its host surface's native widget set allows: headless-render the real HTML/CSS server-side, hand back a plain image URL, and let the host's native primitives carry the interactivity the picture can't. A2UI just happens to already have a clean atom-to-HTML pipeline sitting there to reuse — an agent hand-rolling its own HTML/CSS on the fly hits the exact same wall, and the exact same fix applies, on Chat, on Slack, on email, on anything that can display an image and nothing more exotic than that.