Rich UI in Gemini Enterprise: A Field Report, Part 2 — The Print-Rendering Workaround for Custom Catalogs
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.
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.
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.

Card. Pixel-exact, but a frozen picture — no live re-bind on this element.a2uicatalog.ai mark is baked into the pixels — the tell that this is a real catalogue render, not a GE-native widget.Text, composed directly below the baked image in the same Column.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:

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.
The same compose-both-channels pattern with more interaction. A fresh, live capture taken while writing this piece (Paris, 2026-07-25).

weather_now + weather_outlook atoms, delivered as one GIF cycling current conditions and the 3-day outlook. Pixel-exact, but frozen — not live.a2uicatalog.ai · weather_now is baked into the image — confirms the picture is a real catalogue atom render.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 that submits the field's bound value. Same caveat as the field above on the live submit round-trip.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:

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 TextField → Get 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.