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

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

Google Chat Bot RAG answers in Cards V2 rich text display, converting Markdown to a restricted HTML subset using a mistune custom renderer. This article explains the technique, along with block splitting using sentinel patterns and output control via system prompts.
2026.06.19

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

google-chat-bot-cardsv2-rich-text-formatting-pipeline

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.

google-chat-bot-cardsv2-rich-text-formatting-block-split

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 &lt;script&gt; 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")
¥`¥`¥`

![img](http://example.com/img.png)

---

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:

  1. Cards V2 uses restricted HTML, not Markdown — Understanding the supported tags and inserting a conversion layer is the practical approach
  2. LLM → Markdown → restricted HTML pipeline — Have the LLM output token-efficient Markdown, and separate the display responsibility to the conversion engine
  3. Use sentinel pattern for block splittingsplit("\n\n") breaks lists/code blocks. Explicitly marking block boundaries within the renderer is reliable
  4. Prohibit unsupported syntax in the system prompt — Not just "don't use this" but also "use this instead"
  5. <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

Share this article