Skip to content

Add Accept: text/markdown content negotiation for NiceGUI pages#5889

Open
evnchn wants to merge 4 commits intozauberzeug:mainfrom
evnchn:expt/markdown-support
Open

Add Accept: text/markdown content negotiation for NiceGUI pages#5889
evnchn wants to merge 4 commits intozauberzeug:mainfrom
evnchn:expt/markdown-support

Conversation

@evnchn
Copy link
Copy Markdown
Collaborator

@evnchn evnchn commented Mar 18, 2026

Motivation

As mentioned in #5833 (comment), there is now the Accept: text/markdown header, which I believe Claude Code actively uses (since when it visits https://code.claude.com/docs/en/setup#alpine-linux-and-musl-based-distributions it correctly gets the Markdown version of the site).

However, currently NiceGUI websites shows up as a monolithic non-understandable JSON blob to AI agents as evidenced by #5833 (comment)

I would like to implement a NiceGUI-level API such that all NiceGUI websites are automatically agent-friendly (short of interactivity, but that's expected.

Implementation

Allow LLMs, CLI tools, and agents to request a markdown representation of any NiceGUI page by sending Accept: text/markdown in the HTTP request. Browsers continue to receive HTML as usual.

The implementation adds a _to_markdown() method to the base Element class with smart duck-typing dispatch (content → text → label+value → recurse children), minimizing per-element overrides. Non-visual elements opt out via a MARKDOWN_SKIP = True class attribute. Clients created for markdown responses are cleaned up immediately via deferred deletion since they will never receive a WebSocket connection.

End-to-end results

image

(note: cannot use the typical Fetch tool, since that only works for publically-accessible URLs)

Progress

  • I chose a meaningful title that completes the sentence: "If applied, this PR will..."
  • The implementation is complete.
  • If this PR addresses a security issue, it has been coordinated via the security advisory process.
  • Pytests have been added.
  • Documentation is not necessary for an underlying AI-facing change?
    • Or do we want to make some docs regardless?

Allow LLMs, CLI tools, and agents to request a markdown representation
of any NiceGUI page by sending `Accept: text/markdown` in the HTTP
request. Browsers continue to receive HTML as usual.

The implementation adds a `_to_markdown()` method to the base `Element`
class with smart duck-typing dispatch (content → text → label+value →
recurse children), minimizing per-element overrides. Non-visual elements
opt out via a `MARKDOWN_SKIP = True` class attribute. Clients created
for markdown responses are cleaned up immediately via deferred deletion
since they will never receive a WebSocket connection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@evnchn evnchn requested a review from rodja March 18, 2026 06:33
@evnchn evnchn added the feature Type/scope: New or intentionally changed behavior label Mar 18, 2026
@rodja rodja requested a review from falkoschindler March 18, 2026 09:53
rodja
rodja previously approved these changes Mar 18, 2026
Copy link
Copy Markdown
Member

@rodja rodja left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really like the concept. Might also be noteworthy in #5834. I have not tested/reviewd the PR in detail and hope that @falkoschindler can do that part.

@evnchn
Copy link
Copy Markdown
Collaborator Author

evnchn commented Mar 18, 2026

@rodja If we have Accept: text/markdown then we likely would not highly advertise the sitewide index, but rather the markdown compatibility then. Because with the big JSON you need the agent to learn to read it, but agent already know how to read Markdown files.

@falkoschindler falkoschindler added the review Status: PR is open and needs review label Mar 18, 2026
@falkoschindler falkoschindler added this to the 3.10 milestone Mar 18, 2026
@rodja
Copy link
Copy Markdown
Member

rodja commented Mar 18, 2026

We should also track markdown deliveries in plausible so we can see how much llms looking things up.

@evnchn
Copy link
Copy Markdown
Collaborator Author

evnchn commented Mar 18, 2026

Perhaps, but it will consume custom events, and we need to do Plausible on server-side which could be a hassle (at least we aren't doing it right now, so we need bootstrapping)

@rodja
Copy link
Copy Markdown
Member

rodja commented Mar 19, 2026

Yes, its only nice to have and should not block the merge of this feature.

@evnchn
Copy link
Copy Markdown
Collaborator Author

evnchn commented Apr 2, 2026

I'd say 3.11?

Copy link
Copy Markdown
Contributor

@falkoschindler falkoschindler left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, 3.11 sounds good. I just did a first review and there are still some things to do and/or to think about:

  1. ChatMessage._markdown_text stores HTML-processed text, making the roundtrip lossy

    In chat_message.py:58, self._markdown_text = text is assigned after the text has been HTML-escaped and \n has been replaced with <br /> (lines 49-51). The _to_markdown method then tries to reverse this with html.unescape() and .replace('<br />', '\n'), but this roundtrip is lossy:

    • If the original text literally contained <br /> or HTML entities like &amp;, these would be incorrectly transformed.
    • When text_html=True, _markdown_text stores raw HTML which _to_markdown doesn't properly handle (it would leave HTML tags in the markdown output).

    Fix: Store the original, unprocessed text before escaping. Add a self._original_text = text (or rename) before line 49, and use that in _to_markdown.

    +       self._original_text = list(text)  # store before HTML processing
            if not text_html:
                text = [html.escape(part) for part in text]
                text = [part.replace('\n', '<br />') for part in text]
                sanitize = False
    -       self._markdown_text = text

    And adjust _to_markdown to use self._original_text directly instead of reversing the escaping.

  2. Duck-typing dispatch can produce misleading markdown for layout/value elements

    The base _to_markdown() in element.py:229 checks hasattr(self, 'label') and hasattr(self, 'value') as a fallback. This matches any ValueElement with a label property, including elements where value has a non-textual meaning:

    • Splitter (value = split percentage, e.g. 50) → would produce ": 50" or just "50"
    • Drawer (value = open/closed boolean) → ": True"
    • Carousel (value = current slide name) → could be confusing
    • LinearProgress / CircularProgress (value = progress float) → ": 0.7"
    • Rating (value = star count) → ": 3"
    • Knob (value = angle/number) → ": 42"
    • Pagination (value = current page) → ": 1"

    These elements should either override _to_markdown() to skip their value and recurse into children (for containers like Drawer, Footer, ScrollArea whose children carry the real content), or produce meaningful output (for Rating, Progress), or be skipped entirely (for Splitter where the value is a split percentage).

    Suggestion: Containers (Drawer, Footer, ScrollArea, Carousel) should override to recurse into children only. Splitter should skip its value and recurse. Fullscreen should use MARKDOWN_SKIP. For Slider, Knob, Rating, Pagination, and Progress, consider custom overrides or skipping.

  3. MARKDOWN_SKIP class attribute placement in Element

    The MARKDOWN_SKIP class attribute at element.py:215 is placed in the middle of the class body between _collect_slot_dict() and _to_dict(). Class-level attributes should typically be grouped at the top of the class body, near other class-level declarations. This improves discoverability and follows the existing pattern in NiceGUI's element subclasses.

  4. Repeated visibility check pattern in every override

    Every _to_markdown override begins with:

    if not self.visible:
        return ''

    This is needed because overrides don't call super()._to_markdown() (which already has this check). Consider a template method pattern:

    def _to_markdown(self) -> str:
        if not self.visible or self.MARKDOWN_SKIP:
            return ''
        return self._render_markdown()
    
    def _render_markdown(self) -> str:
        """Override this in subclasses."""
        # ... current duck-typing dispatch ...

    This eliminates the duplicated guard in every override and makes it impossible to forget the visibility check.

  5. Dialog and Menu should not unconditionally skip markdown

    Dialog, Menu, and Notification all have MARKDOWN_SKIP = True, but all can be visible to the user:

    • Dialog and Menu can be open by default (value=True), so they should render children only when open.
    • Notification is always visible when created and carries a user-facing message — it should render that message instead of being skipped.

    The test test_dialog_skipped would need updating to cover both open/closed cases. Similar tests should be added for Menu and Notification.

  6. Fullscreen missing MARKDOWN_SKIP

    Fullscreen is a non-visual control element (ValueElement with a boolean value) that would hit the label+value fallback and produce misleading output. It should have MARKDOWN_SKIP = True. (Other childless non-visual elements like PageScroller, Skeleton, and Joystick would produce empty strings via the base class recurse-into-children path, so they're harmless — but Fullscreen has a value that triggers the wrong dispatch.)

  7. Accept header parsing is simplistic

    'text/markdown' in accept (client.py:155-156) doesn't handle quality values (text/markdown;q=0.9) or wildcard patterns (text/*). Starlette doesn't provide a built-in Accept parser, so a proper implementation would require a small custom parser. The current approach works correctly for the real-world use case (agents sending exactly Accept: text/markdown), but a code comment noting this simplification would be helpful.

  8. Button markdown: ambiguous syntax and icon-only buttons silently dropped

    [Click me] (button.py:48) looks like an incomplete markdown link. Worse, icon-only buttons (no label) return an empty string and are silently omitted from the output. An icon button is still a meaningful interactive element. Consider using the icon name as fallback (e.g. [icon:thumb_up]) or always emitting [Button] when the label is empty. The [...] syntax itself could also be reconsidered — **Click me** or [Button: Click me] would be less ambiguous.

  9. Expansion hardcodes ### (h3)

    In expansion.py:49, the label is always rendered as ### label. Nested expansions would all render at the same heading level. Consider making this relative to nesting depth, or use bold (**Details**) to avoid heading hierarchy issues.

  10. Simple _to_markdown overrides should be one-liners

    Most overrides (Checkbox, Switch, Button, Link, Image, Separator, Code) are simple enough to condense into a single expression, e.g.:

    def _to_markdown(self) -> str:
        return f'- [{"x" if self.value else " "}] {self._text or ""}' if self.visible else ''

    This reduces visual noise across 15+ overrides and makes the pattern scannable. Multi-step methods like Table._to_markdown and Expansion._to_markdown should stay as-is.

  11. Test coverage gaps

    No tests for:

    • ui.select / ui.radio / ui.slider / ui.number without label
    • ui.badge / ui.chip (text elements)
    • ui.html element (content passthrough)
    • ui.mermaid (content passthrough)
    • ui.date / ui.time (value elements)
    • Nested elements that trigger the duck-typing fallback in unexpected ways
    • text_html=True on ChatMessage
    • Pages with @ui.page async handlers
    • Error scenarios (e.g., what happens when _to_markdown raises)
  12. X-NiceGUI-Content: page header

    The custom header in markdown_response.py:25 is undocumented. If it's intended for programmatic detection, a brief comment explaining its purpose would help.

@falkoschindler falkoschindler modified the milestones: 3.10, 3.11 Apr 3, 2026
…Menu, tests

- Template method: _to_markdown() guards visibility/skip, delegates to _render_markdown()
- ChatMessage: store original text before HTML escaping (lossy roundtrip fix)
- Dialog/Menu: render children only when open, not unconditional MARKDOWN_SKIP
- Notification: render message text instead of skipping
- Fullscreen: add MARKDOWN_SKIP = True
- Button: [Button: label] syntax, icon-only fallback
- Expansion: bold instead of h3 to avoid heading hierarchy issues
- Containers (Drawer/Footer/ScrollArea/Carousel/Splitter): recurse children only
- Non-text value widgets (Slider/Knob/Rating/Pagination/Progress): MARKDOWN_SKIP
- ChoiceElement: resolve selected value to display label
- Condense simple overrides to one-liners
- Accept header: add comment noting simplistic parsing
- X-NiceGUI-Content header: add purpose comment
- 15 new tests covering gaps (select, radio, badge, chip, html, dialog, menu, notification)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@evnchn
Copy link
Copy Markdown
Collaborator Author

evnchn commented Apr 4, 2026

@falkoschindler Your 12 feedbacks was in evnchn#130 and I looked over them before merging into this PR's branch

@falkoschindler falkoschindler self-requested a review April 5, 2026 16:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Type/scope: New or intentionally changed behavior review Status: PR is open and needs review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants