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 using Cards V2 for rich text display, explaining a method to convert Markdown to a restricted HTML subset using a mistune custom renderer. Also introduces 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. In Part 3, I integrated knowledge base search using Vertex AI RAG Engine.

With the RAG pipeline up and running, 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 included 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 render as bold, and - list item would just appear as hyphenated text.

Wondering "Does Cards V2 not support styling?", I started investigating — and that's what this article is about.

HTML Tags Supported by Cards V2 textParagraph

Upon investigation, I found that Google Chat's textParagraph widget supports a restricted HTML subset, not Markdown.

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)

  • <h1> through <h6> — No headings
  • <strong>, <em> — Must use <b>, <i> instead
  • <img> — No image embedding
  • <table> — No tables
  • <blockquote> — No blockquotes
  • <div>, <span> — No generic 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 have to use <b>. Quite a quirky set of constraints.

Conversion Strategy: LLM → Markdown → Restricted HTML

This is where a design decision became necessary.

Option A: Have the LLM output restricted HTML directly
Option B: Have the LLM output Markdown and convert it to restricted HTML using a conversion engine

Option A seems simple at first glance, but has problems. Having the LLM directly generate <b> and <font color> tags is token-inefficient, and requires putting the list of supported tags in the system prompt, which wastes context that should be used for 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 been trained on large amounts of Markdown
  • Conversion logic can be separated — The LLM's prompt focuses 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 adopted mistune 3.x for the conversion engine. With mistune, you can fully customize rendering simply by subclassing HTMLRenderer, which was a perfect fit for the requirement of "converting to a restricted HTML subset rather than standard HTML."

Tag Mapping

First, map each Markdown element to Cards V2-compatible tags.

class _ChatHTMLRenderer(mistune.HTMLRenderer):
    # Headings → <b> (since <h1>–<h6> are unsupported)
    def heading(self, text, level, **attrs):
        return f"{_BLOCK_SEP}<b>{text}</b>\n"

    # Emphasis → <i> (<em> is unsupported)
    def emphasis(self, text):
        return f"<i>{text}</i>"

    # Bold → <b> (<strong> is unsupported)
    def strong(self, text):
        return f"<b>{text}</b>"

    # Image → <a> link (<img> is unsupported)
    def image(self, text, url, title=None):
        label = text or "image"
        return f'<a href="{url}">{label}</a>'

    # Blockquote → <i> (<blockquote> is unsupported)
    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> + a visual indicator instead of <blockquote>. Not perfect, but far more readable than plain text.

Block Splitting: The \x00 Sentinel Pattern

The aspect of the conversion engine design I thought about most was block splitting.

In Cards V2, the response text is split across multiple textParagraph widgets. Previously I was splitting with answer.split("\n\n"), but this caused lists and code blocks to be broken apart mid-way.

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

As a solution, I adopted a pattern of inserting a \x00 (NULL character) sentinel at 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 the start of each paragraph

    def list(self, text, ordered, **attrs):
        tag = "ol" if ordered else "ul"
        inner = text.replace(_BLOCK_SEP, "")  # Remove sentinels from nested lists
        return f"{_BLOCK_SEP}<{tag}>\n{inner}</{tag}>\n"  # One sentinel for the entire list

    def block_code(self, code, info=None):
        escaped = mistune.escape(code)
        return f"{_BLOCK_SEP}<pre><code>{escaped}</code></pre>\n"  # One sentinel for the 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, an entire <ul> fits into a single widget, and code blocks aren't 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 include 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 also escaped similarly. Inputs like <script>alert('xss')</script> are converted to &lt;script&gt; and displayed safely.

Controlling LLM Output via System Prompt

The conversion engine alone isn't enough. If the LLM uses Markdown syntax unsupported by Cards V2, the converted output will look bad.

For example, if the LLM uses ## Heading, the converted output becomes <b>Heading</b>. This works on its own, but layout can break due to missing line breaks after headings. Table syntax | A | B | isn't handled by the conversion engine, so the pipe characters will be displayed as-is.

So I added a ## Response Formatting section to the system prompt to control LLM output.

## Response Formatting
- Answer in Markdown
- Use numbered lists (1. 2. 3.) when explaining steps
- Use bullet points (- ) when listing multiple items in parallel
- Bold (**bold**) important keywords, button names, and menu names
- Wrap commands and paths in `inline code` (but not URLs)
- Write URLs as Markdown links `[display text](URL)`, not `inline code`
- Don't use headings (##); use bold (**heading**) instead
- Don't use table syntax (use bullet points instead)
- Structure responses as: short intro → body (list/steps) → supplementary notes

The key is the prohibition rules. Headings and tables are banned, and alternatives are explicitly stated. Just telling the LLM "don't use this" tends to cause other problems, so the trick is to also specify "use this instead."

Color-Coded Status UI: Using <font color>

While implementing the conversion engine, I noticed that <font color> tags are supported. Taking advantage of this, I added color-coded status line display to the progressive card.

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

The colors are chosen from Google's Material Design palette. The combination of <font color> + <b> + emoji makes the status line instantly distinguishable from plain text.

Testing: Detecting Leaked Unsupported Tags

In addition to individual conversion tests, I wrote a test to verify that no unsupported tags 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"

This converts a document containing every type of Markdown element and confirms that not a single unsupported tag appears in the output. This test also serves as a safety net when adding support for new Markdown syntax.

Pitfalls Discovered in Production

After starting production operation, two issues were found that hadn't been caught during testing. Neither caused errors — they only caused display corruption — so they were slow to be discovered.

Pitfall 1: Nested Lists Breaking Widget Boundaries

There were cases where the bot returned a numbered list like this (with some sub-lists) in response to a user's question.

1. Click the **Google Drive icon**.
   - If you can't find the icon, click the "∧" mark
2. Click the **gear icon** (Settings).
3. Click **Quit** to close the app.

The expected display was for the numbered list to render as a single block, but in practice, items 2 and 3 broke out of the list and appeared as unnumbered plain text.

The cause was that _BLOCK_SEP from the sentinel pattern was being inserted inside nested lists too. Tracing mistune's rendering flow:

  1. For item 1's sub-list - If you can't find..., the inner list() is called
  2. The inner list() also inserts _BLOCK_SEP at the beginning
  3. The outer list() joins all list_items and wraps them in <ol>
  4. When split(_BLOCK_SEP) is finally applied, the outer list is split in the middle
Segment 0: <ol><li>Item 1 text...    ← <ol> is not closed
Segment 1: <ul><li>Sub-item</li></ul></li><li>Item 2</li><li>Item 3</li></ol>
                                            ↑ Falls outside the <ol>

Since each segment becomes a separate textParagraph widget, segment 1 loses its <ol> context, and items 2 and 3 are displayed without numbers.

The fix follows the same pattern as block_quote — simply remove nested sentinels inside list().

def list(self, text, ordered, **attrs):
    tag = "ol" if ordered else "ul"
    inner = text.replace(_BLOCK_SEP, "")  # Remove sentinels from nested lists
    return f"{_BLOCK_SEP}<{tag}>\n{inner}</{tag}>\n"

I had already included this processing in block_quote() from the start, but overlooked it in list(). The rule should be: always remove inner _BLOCK_SEPs in any method where block-level elements can be nested.

When knowledge base articles contained URLs, the bot's responses would sometimes include URLs. However, the displayed URLs appeared as non-clickable code blocks.

There were two causes.

Cause A: The system prompt instructions were ambiguous

- Wrap commands and paths in `inline code`

Receiving this instruction, the LLM interpreted URLs as "paths" and wrapped them in backticks like `https://example.com/...`. The conversion engine faithfully converted this to <code>https://example.com/...</code>, which appeared as a non-clickable code block.

As a fix, I explicitly excluded URLs and added an instruction to use Markdown link syntax.

- Wrap commands and paths in `inline code` (but not URLs)
- Write URLs as Markdown links `[display text](URL)`, not `inline code`

Cause B: Bare URLs were not auto-linked

Even after fixing the prompt, there were cases where the LLM output bare URLs without using Markdown link syntax. By default, mistune outputs bare URLs as plain text, making them non-clickable.

By enabling mistune's url plugin, bare URLs are automatically wrapped in <a> tags.

_markdown = mistune.create_markdown(
    renderer=_ChatHTMLRenderer(),
    plugins=["strikethrough", "url"],  # Added the url plugin
)

With the double measure of prompt correction (fixing LLM output) and auto-linking (conversion engine fallback), URLs now reliably appear as clickable links.

Summary

Although Google Chat Cards V2's textParagraph has the constraint of a limited set of supported HTML tags, with some ingenuity you can achieve quite rich displays.

To summarize what I learned:

  1. Cards V2 uses restricted HTML, not Markdown — It's practical to understand the supported tags and insert a conversion layer
  2. LLM → Markdown → restricted HTML pipeline — Have the LLM output token-efficient Markdown, and delegate display responsibility to the conversion engine
  3. Use the sentinel pattern for block splittingsplit("\n\n") breaks lists and code blocks apart. Explicitly marking block boundaries inside the renderer is reliable
  4. Remove inner sentinels in nestable block elements — In methods like list() and block_quote() that can contain block elements, removing _BLOCK_SEP is essential. Forgetting this causes widgets to be split
  5. Prohibit unsupported syntax in the system prompt — Don't just say "don't use this"; also say "use this instead." For cases like URLs that "look like paths but are different," exclude them explicitly
  6. <font color> is surprisingly useful — The visibility of status displays improves dramatically
  7. Display corruption doesn't cause errors — No errors appear in logs; you won't notice unless you actually look at the screen. It's important to periodically visually inspect response displays in production

The HTML constraints of Cards V2 may seem strict at first, but on the flip side, having fewer supported tags means the conversion engine logic can be kept simple. About 80 lines with mistune's custom renderer, under 200 lines including tests. A high return on investment.

References

Share this article