⚙️ [a2ui-blog-host]: ui/initialize ...
📡 [public-edge-worker]: InitializeResult (200 OK)
✅ [status]: STATUS_LIVE_ARTIFACT // NO_SERVER_COMPUTE_ENGAGED
Blog › Rich UI in Gemini Enterprise: A Field Report, Part 2 — The Print-Rendering Workaround for Custom Catalogs
using a2ui in Gemini Enterprise · Vol. 03 LIVE_ARTIFACT

Rich UI in Gemini Enterprise: A Field Report, Part 2 — The Print-Rendering Workaround for Custom Catalogs

2026-07-26 9 min read series using a2ui in Gemini Enterprise · Vol 3 build 4747130

Who this is for: A2A agent authors who want the visual richness of a custom A2UI atom catalog inside Gemini Enterprise (or Google Chat), given that neither surface renders custom catalogIds natively.

Where part 1 left off

Part 1 tested all 18 standard-catalog A2UI v0.8 primitives against a live Gemini Enterprise (GE) agent and built two working demos from them. It closed on one hard wall: GE renders only the standard v0.8 catalog — point it at your own catalogId and you get a flat Unknown element, the same rejection any unrecognised component gets. This piece is about what you do next when 18 primitives isn't enough visual vocabulary.

The problem: the catalogue's own atoms aren't in GE's vocabulary

The a2uicatalog project maintains hundreds of custom atom types — charts, gauges, sparklines, geo maps, dashboards — well beyond the standard catalog. None of them can be sent to GE as native A2UI DataParts. The deployed agent behind these demos states the constraint plainly in its own source:

GE's renderer only understands the v0.8 standard catalog (Card/Column/Text/Image/Button...). It cannot natively render a2uicatalog's hundreds of custom atom types — so real atoms are delivered as a rendered Image inside a standard-catalog card, and everything else (workspace status, weather, itsm) is composed live from standard-catalog primitives with real data.

This is a negotiated fact, not a guess: GE advertises supportedCatalogIds = the v0.8 standard catalog and nothing else. It is also version-pinned, and by a2ui.org's own reckoning, not just an informal age gap: the spec's version table marks v0.8 Legacy, v0.9 Stable, v0.9.1 Current, and v1.0 a release Candidate — GE is pinned to the one tier officially retired. The mimeType itself reverses one version above GE's, at v0.9, from application/json+a2ui to application/a2ui+json — the spelling both the Current v0.9.1 release and the Candidate v1.0 already share. So there is no near-term "just register your catalog" escape hatch on this surface.

The workaround: print the atom, deliver it as an image

There are two ways to move a rich visual into a conversational surface, and it is worth naming both, because they are genuinely different channels:

  • The native channel — send real components the client renders itself. Structure-preserving (live data, real clicks, two-way binding), but it only works where the receiver speaks the protocol and only for the vocabulary that receiver knows. On GE that ceiling is the 18 primitives.
  • The print channel — render the atom to pixels and ship the picture. It is driverless (the surface needs no custom vocabulary, it just displays an image), universal (it plugs into anything that shows an image), and lossy in exactly one dimension: it drops the structured layer — interactivity, data-binding, liveness — while keeping the visual pixel-exact.

The print channel is the workaround. Concretely: the agent runs a real, in-process headless-Chromium render of the same renderer code a2uicatalog itself uses, producing a PNG (or an animated GIF for a multi-atom deck), and embeds it in a standard Card through the one primitive that never drifts — the standard Image primitive.

One non-obvious detail makes or breaks this. You cannot render the atom, hand the PNG bytes back to the agent's own model context, and expect GE to show it — that was tested, and the agent simply described the image in prose ("a gauge_sla atom with value 72 ... was rendered to a PNG image") while the bytes died in the response channel. The picture only appears when it is delivered as an A2UI Image component — either as a fetchable URL or, as these demos do, as an inline base64 data URI, which sidesteps a second unauthenticated fetch entirely. (One further gotcha, learned the hard way: surfaceId is a stable handle, not a label — reuse the same one across turns and GE silently degrades the re-render, so every card mints a fresh uuid-suffixed id.)

The diagram below traces one such turn end to end.

chat
chat
User
Gemini Enterprise
Agent
UserGemini Enterprise  "workspace status"
Gemini EnterpriseAgent  A2A message (SSE)
Agent runs a2uicatalog's own renderer in headless Chromium → PNG/GIF → inline base64 data URI
AgentGemini Enterprise  beginRendering + surfaceUpdate: Card ⊃ [Image(data URI) + Text + Button], fresh surfaceId
Gemini EnterpriseUser  renders the baked image + native Text + Refresh button
The Image is a frozen picture; the Text and Button are native, live standard-catalog primitives
optUser refreshes
UserGemini Enterprise  click "Refresh"
Gemini EnterpriseAgent  userAction DataPart
AgentGemini Enterprise  beginRendering + surfaceUpdate, NEW surfaceId — a freshly-baked card
Gemini EnterpriseUser  re-rendered card

Everything from here carries a Chat Cards v2 equivalence badge — works the same way, ~ works with a caveat, no equivalent — because the same workaround, as the final section shows, applies to Google Chat too.

Demo 1: workspace status — native primitives and a baked image in one card

The a2uicatalog-printer agent (the same deployed agent behind part 1's Wall Builder — it has capability beyond wall-building) answers "workspace status" with a single card that composes both channels at once: a server-baked dashboard image sitting directly above native standard-catalog primitives. The image is a real a2uicatalog dashboard deck — a service-status board, incident log and 30-day pulse — rendered server-side and delivered as one animated GIF; below it, a native Text linking the live status page and a native Button that re-runs the query.

Interested in the demos? See full details — including the source for both agents — in Part 1.

Workspace status — one card, both channels

live demochat Chat Cards v2 ~GitHub ↗

A real a2uicatalog-printer response in Gemini Enterprise: a server-baked custom-catalog dashboard image composed in the same Card as native standard-catalog primitives. Captured live, 2026-07-22.

A Gemini Enterprise card showing a baked Google Workspace service-status dashboard image above a native Text link and a Refresh button.
i
ii
iii
iv
v
Image — largeFeatureclient onlyi. A server-side headless-Chromium render of the catalogue's dashboard deck (service-status board, incident log, 30-day pulse), delivered as one animated GIF inside a standard Card. Pixel-exact, but a frozen picture — no live re-bind on this element.
baked renderer signatureii. The a2uicatalog.ai mark is baked into the pixels — the tell that this is a real catalogue render, not a GE-native widget.
letterboxingiii. A black band from fitting a multi-atom deck into one fixed-ratio image — cosmetic, noted not load-bearing (see the wishlist).
Text — bodyiv. A native standard-catalog Text, composed directly below the baked image in the same Column.
Button — Refreshround tripv. A native standard-catalog Button. A click dispatches a real userAction the agent answers with a freshly-baked card — a confirmed interactivity round-trip.

Screen-recorded live, unedited — the full query-to-card interaction inside Gemini Enterprise:

Live screen recording of the workspace status demo in Gemini Enterprise

The Refresh button is not decoration: a click dispatches a real userAction DataPart back to the agent, which replies with a freshly-baked card — the interactive round-trip confirmed live in part 1's testing and re-confirmed for Button/userAction specifically (correct name, sourceComponentId and surfaceId all reaching the agent). So this one card already answers the interesting question — but hold that thought until after the second demo.

Demo 2: weather — a text field, buttons and a baked forecast in one card

Same pattern, more interaction. Asking for a city's weather returns a baked forecast image (the catalogue's weather_now / weather_outlook atoms, again a GIF deck cycling the two) above a native TextField, a Get weather submit Button, and a row of Buttons for Gemini-suggested alternative cities. The capture below is a fresh, live render taken while writing this piece.

Weather — a text field, buttons and a baked forecast

live demochat Chat Cards v2 ~GitHub ↗

The same compose-both-channels pattern with more interaction. A fresh, live capture taken while writing this piece (Paris, 2026-07-25).

A Gemini Enterprise card showing a baked Paris weather forecast image above a native Type-a-city text field, a Get weather button, and a row of city buttons.
i
ii
iii
iv
v
Image — largeFeatureclient onlyi. A server-side render of the catalogue's weather_now + weather_outlook atoms, delivered as one GIF cycling current conditions and the 3-day outlook. Pixel-exact, but frozen — not live.
baked atom signatureii. a2uicatalog.ai · weather_now is baked into the image — confirms the picture is a real catalogue atom render.
TextField — Type a cityiii. A native standard-catalog TextField, two-way bound to a data-model path. Renders reliably; its free-text submit round-trip is schema-confirmed but not separately reverified this pass.
Button — Get weatheriv. A native Button that submits the field's bound value. Same caveat as the field above on the live submit round-trip.
Button row — city picksround tripv. Native Buttons. Each dispatches a real userAction the agent answers with a fresh forecast — the confirmed round-trip. The three suggestions are Gemini-generated per request.

Screen-recorded live, unedited — the full query-to-card interaction inside Gemini Enterprise:

Live screen recording of the weather demo in Gemini Enterprise

The three "Weather in ..." buttons are the confirmed re-query path — each is a native Button whose click round-trips to the agent for a fresh forecast, and the suggestions themselves are generated per request (New York, Sydney and Mumbai on this run). The free-text TextFieldGet weather submit is built to the same documented two-way-binding pattern and renders reliably, but its live submit round-trip is schema-confirmed, not separately reverified in this pass — flagged here rather than asserted, in keeping with part 1's confirmed-versus-inferred discipline.

The combined approach: rich visuals and live interactivity, one surface

The question worth answering directly is: given that custom catalogs (catalogId) aren't natively supported, can you still mix rich custom visuals with real interactive round-trips in one place?

Yes — and Demo 1 is the receipt. That single card holds a baked custom-catalog dashboard image and native standard-catalog primitives (Text, Button) in the same surface, with the Button driving a genuine server round-trip. The print channel supplies the visual richness the 18 primitives can't express; the native primitives supply the interactivity the baked image can't. They compose in one Card — you don't have to choose.

The same technique also powers the agent's live BigQuery-grounded charts and SLA gauges — a future piece.

The same trick works for Google Chat

The reason this matters beyond GE: the print channel is host-agnostic by construction, and Google Chat has the identical shape of limitation. Chat's native cardsV2 is a fixed widget set — decoratedText, textParagraph, image, buttonList, Google's own materialIcon set — with, in the words of the Chat app's own intro copy, "no custom layout, no real typography, no charts beyond what Google ships." Custom atoms are as unrenderable there as they are in GE.

And the fix is the same fix. A separate, more heavily-iterated Cloud Run service exposes a generic /render.png (and /render.gif) HTTP bridge — any atom block, gzipped into a query string, comes back as a real rendered image — and a Google Chat app answers workspace stats / weather with a cardsV2 message whose image widget imageUrl points straight at that bridge. Chat's own renderer fetches the URL and displays it. Same print channel, different host.

There is one delivery wrinkle that differs between the two surfaces, and it is a real one. Chat cannot accept an inline base64 data URI — its image widget needs a fetchable URL — and it cannot have the app upload the bytes as an attachment either: attachments:upload categorically rejects app/service-account authentication — confirmed live, a 403 PERMISSION_DENIED with Google's own message, verbatim: "This method doesn't support app authentication with a service account. Authenticate with a user account." It isn't a space-membership issue — the endpoint simply has no path for a server-side bot identity at all, member or not. Pointing imageUrl at the render bridge sidesteps it entirely. GE, by contrast, happily accepts the inline data URI. The picture is identical on both surfaces; only the pipe that carries it differs.

Capability Gemini Enterprise Google Chat
Native UI vocabulary 18 A2UI v0.8 standard-catalog primitives cardsV2 fixed widget set
Render your own catalog's atoms natively No — Unknown element No — no custom widgets
Recover full atom richness Yes — server-print, deliver via Image Yes — server-print, deliver via image widget
Image delivery Inline base64 data URI or fetchable URL Fetchable URL only (inline base64 rejected; attachments:upload categorically rejects app/service-account auth)
Interactive round-trip on native controls Yes — Button/userAction confirmed live openLink buttons yes; action buttons (CARD_CLICKED) experimental
Animated multi-atom deck Yes — GIF via Image Yes — GIF via image widget
Status Confirmed live 2026-07-26 (this piece) Last confirmed 2026-07-19 to 07-21; not reverified this pass

The honest liveness note matters: the GE side was re-verified live while writing this (Demo 2 is a fresh capture); the Chat app was last confirmed working in the 2026-07-19 to 07-21 window and has not been reverified in this pass, so its column is dated rather than asserted in the present tense.

Key takeaways and wishlist

The load-bearing point: with custom catalogs off the table on both surfaces, you don't have to fall back to plain text. Render the atom server-side and deliver the picture through the primitive that always works — the Image component in GE, the image widget in Chat — and compose it with native primitives for the interactivity the picture can't carry. Full visual fidelity, real round-trips, one card. The one cost is honest and bounded: the baked element is a frozen picture, lossy in exactly one dimension — interactivity and live data-binding — which the surrounding native primitives are there to restore.

What this piece surfaced that is worth Google's attention:

Move GE off the Legacy v0.8 renderer onto a non-Legacy spec version — worth stating explicitly, not just implied: custom catalog support isn't a hypothetical feature request, it shipped in v0.9 (Stable), one tier above where GE is pinned today. The entire print-channel workaround in this piece exists only because GE (and Chat, on its own fixed widget set) won't render a registered custom catalog — native support on at least a v0.9-or-later renderer would turn these frozen charts back into live, data-bound, interactive ones instead of pixels, and would fold in everything else that shipped since Legacy along the way (the canonical application/a2ui+json mimeType, relaxed surfaceId constraints, modular schemas) rather than requiring a second migration later.

A documented image-delivery contract, shared across surfaces — today GE takes an inline data URI, Chat demands a fetchable URL and 403s on attachment upload, and the only reliable cross-surface answer is "stand up your own render endpoint." A single documented way to hand a surface an image the agent produced would retire a lot of bespoke plumbing.

Confirmed TextField submit semantics — the free-text submit binding is under-specified enough that its live round-trip couldn't be reverified with confidence this pass, even though the control renders fine. A documented, testable submit contract would close the gap.

Sizing the Image element to its content — baking a multi-atom deck into one fixed-ratio image letterboxes it (visible as the black band in Demo 1). A way to size the delivered image to the rendered content, rather than a fixed aspect, would remove the only cosmetic wart in the whole approach.