# NousResearch/hermes-agent · Discord markdown table rendering fix proposals

## Open now

- Nothing open on GitHub.

A bug report and multiple proposed fixes for how markdown tables render in Discord, with approaches ranging from wrapping tables in code fences to converting them into bullet groups. The members reflect ongoing discussion about the best solution, not a single agreed-upon fix.

## TL;DR

- 7 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 5 PR(s) closed without merging.
- A fix was merged to main in PR #53284. Verify it addresses your case.
- **Recommended:** read PR #53284 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-53284`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.29`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #21168: \[Bug\]: Discord markdown tables render as garbage — need auto code-fence wrapping

## Prior & competing PRs

- **#16903: fix(discord): convert markdown tables to code blocks for proper rendering**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `cf7bf5bdc900078cc18a2055a6bcaf1abfb1e7a0`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#21170: fix(discord): auto-wrap markdown tables in code fences**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#16903`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#53284: fix(discord): auto-convert markdown tables to bullet groups**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/platforms/helpers.py`, `plugins/platforms/discord/adapter.py`, `plugins/platforms/telegram/adapter.py`, `tests/gateway/test_discord_format.py`, `tests/gateway/test_table_helpers.py`
  - fix hunks:
  - `gateway/platforms/helpers.py`

    ```diff
    @@ -276,3 +276,128 @@ def redact_phone(phone: str) -> str:
         if len(phone) <= 8:
             return phone[:2] + "****" + phone[-2:] if len(phone) > 4 else "****"
         return phone[:4] + "****" + phone[-4:]
    +
    +
    +# ─── GFM Markdown Table → Bullet Conversion ─────────────────────────────────
    +# Shared by Discord and Telegram adapters.  Discord calls
    +# convert_table_to_bullets() directly; Telegram imports the primitives
    +# but keeps its own MarkdownV2-aware renderer.
    +
    +
    +# Matches a GFM table delimiter row: optional outer pipes, cells of dashes
    +# (with optional alignment colons) separated by '|'.
    +# Requires at least one internal '|' so lone '---' rules are NOT matched.
    +TABLE_SEPARATOR_RE = re.compile(
    +    r'^\s*\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*){1,}\|?\s*$'
    +)
    +
    +
    +def is_table_row(line: str) -> bool:
    +    """Return True if *line* could plausibly be a table data row."""
    +    stripped = line.strip()
    +    return bool(stripped) and '|' in stripped
    +
    +
    +def split_markdown_table_row(line: str) -> list[str]:
    +    """Split a GFM table row into stripped cell values."""
    +    stripped = line.strip()
    +    if stripped.startswith("|"):
    +        stripped = stripped[1:]
    +    if stripped.endswith("|"):
    +        stripped = stripped[:-1]
    +    return [cell.strip() for cell in stripped.split("|")]
    +
    +
    +def _render_table_block(table_block: list[str]) -> str:
    +    """Render a detected GFM table as bold-heading + bullet groups.
    +
    +    Uses the same alignment logic as Telegram's renderer: for non-row-label
    +    tables, ``data_cells = cells`` (the full row) and the bullet whose value
    +    duplicates the heading is skipped.  This keeps header→value alignment
    +    correct.
    +    """
    +    if len(table_block) < 3:
    +        return "\n".join(table_block)
    +
    +    headers = split_markdown_table_row(table_block[0])
    +    if len(headers) < 2:
    +        return "\n".join(table_block)
    +
    +    first_data_row = (
    +        split_markdown_table_row(table_block[2])
    +        if len(table_block) > 2
    +        else []
    +    )
    +    has_row_label_col = len(first_data_row) == len(headers) + 1
    +
    +    rendered_groups: list[str] = []
    +    for index, row in enumerate(table_block[2:], start=1):
    +        cells = split_markdown_table_row(row)
    +        if has_row_label_col:
    +            heading = cells[0] if cells and cells[0] else f"Row {index}"
    +            data_cells = cells[1:]
    +        else:
    +            heading = next((cell for cell in cells if cell), f"Row {index}")
    +            data_cells = cells
    +
    +        if len(data_cells) < len(headers):
    +            data_cells.extend([""] * (len(headers) - len(data_cells)))
    +        elif len(data_cells) > len(headers):
    +            data_cells = data_cells[: len(headers)]
    +
    +        bullets: list[str] = []
    +        for header, value in zip(headers, data_cells):
    +            if not has_row_label_col and value == heading:
    +                continue
    +            bullets.append(f"• {header}: {value}")
    +
    +        group_lines = [f"**{heading}**", *bullets]
    +        rendered_groups.append("\n".join(group_lines))
    +
    +    return "\n\n".join(rendered_groups)
    +
    +
    +def convert_table_to_bullets(text: str) -> str:
    +    """Rewrite GFM pipe tables into bold-heading + bullet groups.
    +
    +    Tables inside fenced code blocks are left alone.
    +    """
    +    if '|' not in text or '-' not in text:
    +        return text
    +
    +    lines = text.split('\n')
    +    out: list[str] = []
    +    in_fence = False
    +    i = 0
    +    while i < len(lines):
    +        line = lines[i]
    +        stripped = line.lstrip()
    +
    +        if stripped.startswith('```'):
    +            in_fence = not in_fence
    +            out.append(line)
    +            i += 1
    +            continue
    +        if in_fence:
    +            out.append(line)
    +            i += 1
    +            continue
    +
    +        if (
    +            '|' in line
    +            and i + 1 < len(lines)
    +            and TABLE_SEPARATOR_RE.match(lines[i + 1])
    +        ):
    +            table_block = [line, lines[i + 1]]
    +            j = i + 2
    +            while j < len(lines) and is_table_row(lines[j]):
    +                table_block.append(lines[j])
    +                j += 1
    +            out.append(_render_table_block(table_block))
    +            i = j
    +            continue
    +
    +        out.append(line)
    +        i += 1
    +
    +    return '\n'.join(out)
    ```
  - `plugins/platforms/discord/adapter.py`

    ```diff
    @@ -102,7 +102,7 @@ def __init__(self, id: int) -> None:  # noqa: A002 - matches discord API
     
     from gateway.config import Platform, PlatformConfig
     
    -from gateway.platforms.helpers import MessageDeduplicator, ThreadParticipationTracker
    +from gateway.platforms.helpers import MessageDeduplicator, ThreadParticipationTracker, convert_table_to_bullets
     from utils import atomic_json_write, env_float
     from gateway.platforms.base import (
         BasePlatformAdapter,
    ```

    ```diff
    @@ -3411,13 +3411,14 @@ async def _resolve_allowed_usernames(self) -> None:
                 print(f"[{self.name}] Updated DISCORD_ALLOWED_USERS with {resolved_count} resolved ID(s)")
     
         def format_message(self, content: str) -> str:
    -        """
    -        Format message for Discord.
    +        """Format message for Discord.
     
    -        Discord uses its own markdown variant.
    +        Converts GFM markdown tables to bullet-list groups since Discord
    +        does not render pipe tables natively.
             """
    -        # Discord markdown is fairly standard, no special escaping needed
    -        return content
    +        if not content:
    +            return content
    +        return convert_table_to_bullets(content)
     
         async def _run_simple_slash(
             self,
    ```
  - `plugins/platforms/telegram/adapter.py`

    ```diff
    @@ -217,137 +217,13 @@ def _separate_chunk_indicator_from_fence(text: str) -> str:
     # ---------------------------------------------------------------------------
     # Telegram's MarkdownV2 has no table syntax — '|' is just an escaped literal,
     # so pipe tables render as noisy backslash-pipe text with no alignment.
    -# Reformating each row into a bold heading plus bullet list keeps the content
    -# readable on mobile clients while preserving the source data.
    -
    -# Matches a GFM table delimiter row: optional outer pipes, cells containing
    -# only dashes (with optional leading/trailing colons for alignment) separated
    -# by '|'.  Requires at least one internal '|' so lone '---' horizontal rules
    -# are NOT matched.
    -_TABLE_SEPARATOR_RE = re.compile(
    -    r'^\s*\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*){1,}\|?\s*$'
    -)
    -
    -
    -def _is_table_row(line: str) -> bool:
    -    """Return True if *line* could plausibly be a table data row."""
    -    stripped = line.strip()
    -    return bool(stripped) and '|' in stripped
    -
    -
    -def _split_markdown_table_row(line: str) -> list[str]:
    -    """Split a simple GFM table row into stripped cell values."""
    -    stripped = line.strip()
    -    if stripped.startswith("|"):
    -        stripped = stripped[1:]
    -    if stripped.endswith("|"):
    -        stripped = stripped[:-1]
    -    return [cell.strip() for cell in stripped.split("|")]
    -
    -
    -def _render_table_block_for_telegram(table_block: list[str]) -> str:
    -    """Render a detected GFM table as Telegram-friendly row groups."""
    -    if len(table_block) < 3:
    -        return "\n".join(table_block)
    -
    -    headers = _split_markdown_table_row(table_block[0])
    -    if len(headers) < 2:
    -        return "\n".join(table_block)
    -
    -    # Detect row-label column: present when data rows have one more cell
    -    # than the header row (the row-label column carries no header).
    -    first_data_row = _split_markdown_table_row(table_block[2]) if len(table_block) > 2 else []
    -    has_row_label_col = len(first_data_row) == len(headers) + 1
    -
    -    rendered_groups: list[str] = []
    -    for index, row in enumerate(table_block[2:], start=1):
    -        cells = _split_markdown_table_row(row)
    -        if has_row_label_col:
    -            # First cell is the row-label (heading); remaining cells align with headers.
    -            heading = cells[0] if cells and cells[0] else f"Row {index}"
    -            data_cells = cells[1:]
    -        else:
    -            # No row-label column: use first non-empty cell as heading.
    -            heading = next((cell for cell in cells if cell), f"Row {index}")
    -            data_cells = cells
    -
    -        # Pad or trim data_cells to match headers length.
    -        if len(data_cells) < len(headers):
    -            data_cells.extend([""] * (len(headers) - len(data_cells)))
    -        elif len(data_cells) > len(headers):
    -            data_cells = data_cells[: len(headers)]
    -
    -        # Build the bulleted lines for this row.  Skip any bullet whose value
    -        # duplicates the heading text -- when has_row_label_col is False the
    -        # heading IS the first data cell, and emitting it twice (once as the
    -        # bold heading, once as the first bullet) is visual noise.
    -        bullets: list[str] = []
    -        for header, value in zip(headers, data_cells):
    -            if not has_row_label_col and value == heading:
    -                continue
    -            bullets.append(f"• {header}: {value}")
    +# The shared convert_table_to_bullets() in gateway.platforms.helpers handles
    +# the full conversion (detection + rendering); Telegram just calls it.
     
    -        # Within a row-group: single newline between heading and its bullets,
    -        # and between successive bullets.  This keeps the row visually tight
    -        # on Telegram instead of stretching each bullet into its own paragraph.
    -        group_lines = [f"**{heading}**", *bullets]
    -        rendered_groups.append("\n".join(group_lines))
    -
    -    # Between row-groups: blank line so each group reads as a distinct block.
    -    return "\n\n".join(rendered_groups)
    -
    -
    -def _wrap_markdown_tables(text: str) -> str:
    -    """Rewrite GFM-style pipe tables into Telegram-friendly bullet groups.
    -
    -    Detected by a row containing '|' immediately followed by a delimiter
    -    row matching :data:`_TABLE_SEPARATOR_RE`.  Subsequent pipe-containing
    -    non-blank lines are consumed as the table body and rewritten as
    -    per-row bullet groups. Tables inside existing fenced code blocks are left
    -    alone.
    -    """
    -    if '|' not in text or '-' not in text:
    -        return text
    -
    -    lines = text.split('\n')
    -    out: list[str] = []
    -    in_fence = False
    -    i = 0
    -    while i < len(lines):
    -        line = lines[i]
    -        stripped = line.lstrip()
    -
    -        # Track existing fenced code blocks — never touch content inside.
    -        if stripped.startswith('```'):
    -            in_fence = not in_fence
    -            out.append(line)
    -            i += 1
    -            continue
    -        if in_fence:
    -            out.append(line)
    -            i += 1
    -            continue
    -
    -        # Look for a header row (contains '|') immediately followed by a
    -        # delimiter row.
    -        if (
    -            '|' in line
    -            and i + 1 < len(lines)
    -            and _TABLE_SEPARATOR_RE.match(lines[i + 1])
    -        ):
    -            table_block = [line, lines[i + 1]]
    -            j = i + 2
    -            while j < len(lines) and _is_table_row(lines[j]):
    -                table_block.append(lines[j])
    -                j += 1
    -            out.append(_render_table_block_for_telegram(table_block))
    -            i = j
    -            continue
    -
    -        out.append(line)
    -        i += 1
    -
    -    return '\n'.join(out)
    +from gateway.platforms.helpers import (
    +    TABLE_SEPARATOR_RE as _TABLE_SEPARATOR_RE,
    +    convert_table_to_bullets as _wrap_markdown_tables,
    +)
     
     
     # ---------------------------------------------------------------------------
    ```
  - `tests/gateway/test_discord_format.py`

    ```diff
    @@ -0,0 +1,58 @@
    +"""Discord format_message: tables converted to bullet groups."""
    +
    +import types
    +import sys
    +
    +
    +def _make_discord_adapter():
    +    """Construct a DiscordAdapter with discord.py stubbed out."""
    +    fake_discord = types.ModuleType("discord")
    +    fake_discord.Intents = type("Intents", (), {"default": classmethod(lambda cls: cls())})
    +    fake_discord.Message = object
    +    fake_ext = types.ModuleType("discord.ext")
    +    fake_commands = types.ModuleType("discord.ext.commands")
    +    fake_ext.commands = fake_commands
    +    fake_discord.ext = fake_ext
    +    sys.modules.setdefault("discord", fake_discord)
    +    sys.modules.setdefault("discord.ext", fake_ext)
    +    sys.modules.setdefault("discord.ext.commands", fake_commands)
    +
    +    from plugins.platforms.discord.adapter import DiscordAdapter
    +    adapter = object.__new__(DiscordAdapter)
    +    return adapter
    +
    +
    +class TestDiscordFormatMessage:
    +
    +    def test_table_converted_to_bullets(self):
    +        adapter = _make_discord_adapter()
    +        text = (
    +            "Results:\n\n"
    +            "| Name | Score |\n"
    +            "|------|-------|\n"
    +            "| Alice | 95   |\n"
    +            "| Bob   | 80   |\n"
    +            "\nDone."
    +        )
    +        out = adapter.format_message(text)
    +        assert "**Alice**" in out
    +        assert "• Score: 95" in out
    +        assert "**Bob**" in out
    +        assert "• Score: 80" in out
    +        assert out.startswith("Results:")
    +        assert out.rstrip().endswith("Done.")
    +        assert "|---" not in out
    +
    +    def test_plain_text_unchanged(self):
    +        adapter = _make_discord_adapter()
    +        text = "Hello world, no tables here."
    +        assert adapter.format_message(text) == text
    +
    +    def test_code_block_table_unchanged(self):
    +        adapter = _make_discord_adapter()
    +        text = "```\n| a | b |\n|---|---|\n| 1 | 2 |\n```"
    +        assert adapter.format_message(text) == text
    +
    +    def test_empty_string(self):
    +        adapter = _make_discord_adapter()
    +        assert adapter.format_message("") == ""
    ```
  - `tests/gateway/test_table_helpers.py`

    ```diff
    @@ -0,0 +1,137 @@
    +"""Shared GFM table → bullet conversion helpers."""
    +
    +from gateway.platforms.helpers import (
    +    TABLE_SEPARATOR_RE,
    +    is_table_row,
    +    split_markdown_table_row,
    +    convert_table_to_bullets,
    +)
    +
    +
    +class TestTablePrimitives:
    +
    +    def test_separator_re_matches_basic(self):
    +        assert TABLE_SEPARATOR_RE.match("|---|---|")
    +
    +    def test_separator_re_matches_alignment(self):
    +        assert TABLE_SEPARATOR_RE.match("|:-----|----:|:----:|")
    +
    +    def test_separator_re_rejects_lone_rule(self):
    +        assert not TABLE_SEPARATOR_RE.match("---")
    +
    +    def test_is_table_row_with_pipe(self):
    +        assert is_table_row("| Alice | 150 |")
    +
    +    def test_is_table_row_blank(self):
    +        assert not is_table_row("")
    +
    +    def test_split_row_strips_outer_pipes(self):
    +        assert split_markdown_table_row("| a | b | c |") == ["a", "b", "c"]
    +
    +    def test_split_row_no_outer_pipes(self):
    +        assert split_markdown_table_row("a | b | c") == ["a", "b", "c"]
    +
    +
    +class TestConvertTableToBullets:
    +
    +    def test_basic_table(self):
    +        text = (
    +            "| Player | Score |\n"
    +            "|--------|-------|\n"
    +            "| Alice  | 150   |\n"
    +            "| Bob    | 120   |"
    +        )
    +        out = convert_table_to_bullets(text)
    +        assert "**Alice**" in out
    +        assert "• Score: 150" in out
    +        assert "**Bob**" in out
    +        assert "• Score: 120" in out
    +        assert "• Player: Alice" not in out
    +
    +    def test_three_column_table(self):
    +        text = (
    +            "| Name | Age | City |\n"
    +            "|:-----|----:|:----:|\n"
    +            "| Ada  |  30 | NYC  |"
    +        )
    +        out = convert_table_to_bullets(text)
    +        assert "**Ada**" in out
    +        assert "• Name: Ada" not in out
    +        assert "• Age: 30" in out
    +        assert "• City: NYC" in out
    +        assert "**Ada**\n• Age: 30\n• City: NYC" in out
    +
    +    def test_row_label_column(self):
    +        text = (
    +            "|        | Score | Rank |\n"
    +            "|--------|-------|------|\n"
    +            "| Alice  | 150   | 1    |\n"
    +            "| Bob    | 120   | 2    |"
    +        )
    +        out = convert_table_to_bullets(text)
    +        assert "**Alice**" in out
    +        assert "• Score: 150" in out
    +        assert "• Rank: 1" in out
    +        assert "**Alice**\n• Score: 150\n• Rank: 1" in out
    +
    +    def test_bare_pipe_table(self):
    +        text = "head1 | head2\n--- | ---\na | b\nc | d"
    +        out = convert_table_to_bullets(text)
    +        assert "**a**" in out
    +        assert "• head1: a" not in out
    +        assert "• head2: b" in out
    +
    +    def test_two_consecutive_tables(self):
    +        text = (
    +            "| A | B |\n"
    +            "|---|---|\n"
    +            "| 1 | 2 |\n"
    +            "\n"
    +            "| X | Y |\n"
    +            "|---|---|\n"
    +            "| 9 | 8 |"
    +        )
    +        out = convert_table_to_bullets(text)
    +        assert out.count("**1**") == 1
    +        assert out.count("**9**") == 1
    +        assert "• B: 2" in out
    +        assert "• Y: 8" in out
    +
    +    def test_surrounding_prose_preserved(self):
    +        text = (
    +            "Scores:\n\n"
    +            "| Player | Score |\n"
    +            "|--------|-------|\n"
    +            "| Alice  | 150   |\n"
    +            "\nEnd."
    +        )
    +        out = convert_table_to_bullets(text)
    +        assert out.startswith("Scores:")
    +        assert out.endswith("End.")
    +
    +    def test_table_inside_code_fence_untouched(self):
    +        text = "```\n| a | b |\n|---|---|\n| 1 | 2 |\n```"
    +        assert convert_table_to_bullets(text) == text
    +
    +    def test_plain_text_with_pipes_untouched(self):
    +        text = "Use the | pipe operator to chain."
    +        assert convert_table_to_bullets(text) == text
    +
    +    def test_horizontal_rule_not_matched(self):
    +        text = "Section A\n\n---\n\nSection B"
    +        assert convert_table_to_bullets(text) == text
    +
    +    def test_no_pipe_short_circuits(self):
    +        text = "Plain **bold** text."
    +        assert convert_table_to_bullets(text) == text
    +
    +    def test_row_groups_separated_by_blank_line(self):
    +        text = (
    +            "| A | B |\n"
    +            "|---|---|\n"
    +            "| x | 1 |\n"
    +            "| y | 2 |"
    +        )
    +        out = convert_table_to_bullets(text)
    +        assert "• B: 1\n\n**y**" in out
    +        assert "\n\n• " not in out
    ```
- **#35707: fix(discord): wrap markdown tables in code fences for readable rendering**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#4401`, `#16903`, `#21170`, `#29593`, `#21168`
- **#45781: fix(discord): auto-convert markdown tables to bullet groups**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#53284`
- **#53067: fix(discord): convert markdown tables to readable bullet groups**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`

## Fix status

- Merged to main in PR #53284 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-26`.
- Subsystem: `gateway`
