
When I tried to convert Google Chat Bot responses to rich text, I ended up having to deal with the HTML restrictions of Cards V2
This page has been translated by machine translation. View original
Introduction
In Part 1, I built a Google Chat Bot with Cloud Functions + Python + uv, in Part 2 I implemented a progressive update UX with cardsV2, and in Part 3 I integrated knowledge base search using Vertex AI RAG Engine.
The RAG pipeline was up and running, and the bot was able to return reasonably accurate answers to user questions. However, there was one thing that bothered me.
All responses were plain text and hard to read.
The answers generated by Gemini contained bullet points and bold text, but when passed directly to the textParagraph widget in Cards V2, the Markdown symbols were displayed as-is. **bold** wouldn't become bold, and - list item would just be text with a hyphen.
Wondering "Does Cards V2 not support styling...?" and starting to investigate is what sparked this article.
HTML Tags Supported by Cards V2 textParagraph
Upon investigation, I found that Google Chat's textParagraph widget supports not Markdown, but a restricted HTML subset.
Supported Tags
| Tag | Purpose |
|---|---|
<b> |
Bold |
<i> |
Italic |
<u> |
Underline |
<s> |
Strikethrough |
<font color="..."> |
Text color |
<a href="..."> |
Link |
<br> |
Line break |
<code> |
Inline code |
<pre> |
Code block |
<ul>, <ol>, <li> |
Lists |
<time> |
Time display |
Unsupported Tags (displayed as literal strings if used)
<h1>~<h6>— No headings<strong>,<em>— Must use<b>,<i>instead<img>— Cannot embed images<table>— No tables<blockquote>— No quote blocks<div>,<span>— No general-purpose containers- CSS — Cannot be used at all
In other words, HTML can be used, but the available tags are quite limited. No headings, no tables. Even <strong> doesn't work — you need <b>. These are quite quirky constraints.
Conversion Strategy: LLM → Markdown → Restricted HTML
This required a design decision.
Option A: Have the LLM directly output restricted HTML
Option B: Have the LLM output Markdown, then convert to restricted HTML with a conversion engine
Option A seems simple at first glance, but has problems. Having the LLM directly generate <b> and <font color> is token-inefficient, and requires putting the list of supported tags in the system prompt, which consumes context that should be used for actual answer quality.
I chose Option B. Reasons:
- Markdown is token-efficient —
**bold**is shorter than<b>bold</b> - LLMs are good at Markdown — They've learned from large amounts of Markdown during pretraining
- Conversion logic can be separated — The LLM prompt can focus on "what to answer," while "how to display it" is absorbed by the conversion layer

Conversion Engine Implementation: mistune Custom Renderer
I chose mistune 3.x for the conversion engine. mistune allows complete customization of rendering simply by subclassing HTMLRenderer, making it perfect for this requirement of "converting to restricted HTML subset rather than standard HTML."
Tag Mapping
First, map each Markdown element to a Cards V2 compatible tag.
class _ChatHTMLRenderer(mistune.HTMLRenderer):
# Headings → <b> (since <h1>~<h6> are not supported)
def heading(self, text, level, **attrs):
return f"{_BLOCK_SEP}<b>{text}</b>\n"
# Emphasis → <i> (<em> is not supported)
def emphasis(self, text):
return f"<i>{text}</i>"
# Bold → <b> (<strong> is not supported)
def strong(self, text):
return f"<b>{text}</b>"
# Images → <a> link (<img> is not supported)
def image(self, text, url, title=None):
label = text or "image"
return f'<a href="{url}">{label}</a>'
# Quotes → <i> (<blockquote> is not supported)
def block_quote(self, text):
inner = text.replace(_BLOCK_SEP, "").strip()
return f"{_BLOCK_SEP}<i>▎ {inner}</i>\n"
The key point is substituting unsupported Cards V2 tags with supported ones. <b> instead of <h1>, <i> + visual indicator ▎ instead of <blockquote>. Not perfect, but far more readable than plain text.
Block Splitting: \x00 Sentinel Pattern
The aspect I thought about most in designing the conversion engine was block splitting.
In Cards V2, the answer text is split into multiple textParagraph widgets and arranged. Previously I was splitting with answer.split("\n\n"), but this caused problems where lists and code blocks would be broken in the middle.

As a solution, I adopted a pattern of inserting a \x00 (NULL character) sentinel into the output of each block-level element in the renderer, then splitting on this sentinel at the end.
_BLOCK_SEP = "\x00" # Does not appear in actual content
class _ChatHTMLRenderer(mistune.HTMLRenderer):
def paragraph(self, text):
return f"{_BLOCK_SEP}{text}\n" # Sentinel at start of paragraph
def list(self, text, ordered, **attrs):
tag = "ol" if ordered else "ul"
return f"{_BLOCK_SEP}<{tag}>\n{text}</{tag}>\n" # Single sentinel for entire list
def block_code(self, code, info=None):
escaped = mistune.escape(code)
return f"{_BLOCK_SEP}<pre><code>{escaped}</code></pre>\n" # Single sentinel for entire code block
def markdown_to_chat_html(text: str) -> list[str]:
raw: str = _markdown(text)
return [s.strip() for s in raw.split(_BLOCK_SEP) if s.strip()]
This way, a list fits entirely within one widget as a <ul>, and code blocks are not split either. Paragraphs are naturally divided.
Security: Escaping Raw HTML
mistune's default renderer outputs raw HTML as-is in block_html and inline_html. Since LLM output may directly contain user input, I added processing to escape these.
def block_html(self, html):
return f"{_BLOCK_SEP}{mistune.escape(html)}\n"
def inline_html(self, html):
return mistune.escape(html)
HTML inside code blocks is similarly escaped. Input like <script>alert('xss')</script> is converted to <script> and displayed safely.
Controlling LLM Output with System Prompt
The conversion engine alone is not enough. If the LLM uses Markdown syntax not supported by Cards V2, the result after conversion will look poor.
For example, if the LLM uses ## Heading, after conversion it becomes <b>Heading</b>. This works in itself, but the layout can break, such as no line break after the heading. Table syntax | A | B | is not supported by the conversion engine, so the pipe characters would be displayed as-is.
So I added a ## Answer Format section to the system prompt to control the LLM's output.
## Answer Format
- Answer in Markdown
- Use numbered lists (1. 2. 3.) when explaining steps
- Use bullet points (- ) when listing multiple items in parallel
- Make important keywords, button names, and menu names **bold**
- Wrap commands and paths in `inline code`
- Don't use headings (##), use bold (**heading**) instead
- Don't use table syntax (use bullet points instead)
- Structure answers as: short introduction → body (list/steps) → supplementary notes
The important part is the prohibition rules. Headings and tables are prohibited, and alternatives are explicitly stated. Just telling the LLM "don't use this" can cause other problems, so the trick is to also instruct "use this instead."
Status UI Color Coding: Utilizing <font color>
While implementing the conversion engine, I noticed that <font color> tags can be used. Taking advantage of this, I made the status line in the progressive card display color-coded.
status_label = state.current_step_description
if state.status == PipelineStatus.COMPLETED:
status_text = f'<font color="#188038"><b>✅ {status_label}</b></font>'
elif state.status == PipelineStatus.FAILED:
status_text = f'<font color="#d93025"><b>❌ {status_label}</b></font>'
else:
status_text = f'<font color="#1a73e8"><b>⏳ {status_label}</b></font>'
| Status | Color | Display |
|---|---|---|
| Processing | Blue #1a73e8 |
⏳ Generating answer |
| Completed | Green #188038 |
✅ 4 steps completed |
| Failed | Red #d93025 |
❌ An error occurred |
Colors are chosen from Google's Material Design palette. The combination of <font color> + <b> + emoji makes status lines instantly distinguishable from plain text ones.
Testing: Detecting Leaked Unsupported Tags
In testing the conversion engine, in addition to individual conversion tests, I wrote tests to verify that unsupported tags do not leak into the output.
class TestNoUnsupportedTags:
def test_full_document(self):
md = """# Title
Some **bold** and *italic* text.
## Section
1. First step
2. Second step
- Bullet one
- Bullet two
> A quote
¥`¥`¥`python
print("hello")
¥`¥`¥`

---
End.
"""
result = markdown_to_chat_html(md)
full = " ".join(result)
for tag in [
"<h1", "<h2", "<h3", "<h4", "<h5", "<h6",
"<p>", "<p ", "<strong>", "<em>", "<del>",
"<blockquote>", "<img", "<hr", "<table",
"<div", "<span",
]:
assert tag not in full, f"Unsupported tag {tag} found in output"
A document containing every variety of Markdown element is converted, confirming that not a single Cards V2 unsupported tag appears in the output. This test also serves as a safety net when adding support for new Markdown syntax.
Summary
Google Chat Cards V2's textParagraph has the constraint of limited available HTML tags, but with some ingenuity, quite rich displays can be achieved.
To summarize what I learned:
- Cards V2 uses restricted HTML, not Markdown — Understanding the supported tags and inserting a conversion layer is the practical approach
- LLM → Markdown → restricted HTML pipeline — Have the LLM output token-efficient Markdown, and separate the display responsibility to the conversion engine
- Use sentinel pattern for block splitting —
split("\n\n")breaks lists/code blocks. Explicitly marking block boundaries within the renderer is reliable - Prohibit unsupported syntax in the system prompt — Not just "don't use this" but also "use this instead"
<font color>is surprisingly useful — Significantly improves visibility of status displays
The HTML constraints of Cards V2 look strict at first glance, but conversely, with fewer supported tags, the conversion engine logic can be kept simple. About 80 lines with mistune's custom renderer, under 200 lines including tests. I think the return on investment is high.
References
- Google Chat cards v2 — Text formatting | Google for Developers
- mistune — A fast yet powerful Python Markdown parser
- Part 1: Building a Google Chat Bot with a minimal configuration using Cloud Functions + Python + uv
- Part 2: The story of hitting wall after wall when implementing progressive UX with cardsV2 in Google Chat Bot
- Part 3: The story of connecting Google Chat Bot to Vertex AI RAG Engine and implementing knowledge base search