Markdown Rendering

Restyle the markdown in assistant messages, or replace the markdown renderer entirely, via the markdownRenderer slot.

Assistant replies arrive as markdown. CopilotKit renders them through the markdownRenderer slot on CopilotChatAssistantMessage, whose default is a thin wrapper around Streamdown — a markdown renderer built for text that is still streaming in.

You reach the slot by drilling down from the chat component:

page.tsx
<CopilotChat
  messageView={{
    assistantMessage: {
      markdownRenderer: {
        /* ... */
      },
    },
  }}
/>

Everything on this page works the same way on CopilotSidebar and CopilotPopup, and on CopilotChatMessageView (where the path starts one level in, at assistantMessage).

Restyle individual HTML tags#

This is the route to reach for first. The slot forwards its props to Streamdown, so a props override can set Streamdown's components map, which swaps the React component used for one HTML tag:

page.tsx
import { CopilotChat } from "@copilotkit/react-core/v2";

export function Chat() {
  return (
    <CopilotChat
      messageView={{
        assistantMessage: {
          markdownRenderer: {
            components: {
              a: ({ node, children, ...props }) => (
                <a {...props} className="my-link">
                  {children}
                </a>
              ),
              h2: ({ node, children, ...props }) => (
                <h2 {...props} className="my-heading">
                  {children}
                </h2>
              ),
            },
          },
        },
      }}
    />
  );
}

Three things are worth knowing about the props your component receives.

Drop node. Every component is handed a node prop holding the parsed syntax-tree node. It is not a DOM attribute, so spreading it onto an element writes a literal node="[object Object]" into the HTML. Destructure it out, as above, and it disappears.

Spread the rest. The remaining props come from the sanitized document, not from the raw model output. An a is handed href, target and rel, with target="_blank" rel="noopener noreferrer" already applied by the renderer's link hardening; an img is handed src and alt. Spreading them keeps that work. Rebuilding the element by hand throws it away.

You are replacing, not extending. The default component for a tag carries Streamdown's own classes and a data-streamdown attribute — a default link renders as <a class="wrap-anywhere font-medium text-primary underline" data-streamdown="link" …>. Your component supplies neither unless you write them, so any CSS you have targeting [data-streamdown="link"] stops matching that tag.

Restyle the whole markdown block#

To change the block as a whole rather than one tag, pass a class string. It is merged onto the markdown container:

page.tsx
<CopilotChat
  messageView={{
    assistantMessage: { markdownRenderer: "text-sm leading-7" },
  }}
/>

Replace the renderer#

Passing a component instead of an object replaces the renderer outright. It receives exactly one prop, content, holding the assistant message's raw markdown:

page.tsx
const PlainText = ({ content }: { content: string }) => (
  <pre className="whitespace-pre-wrap">{content}</pre>
);

<CopilotChat
  messageView={{ assistantMessage: { markdownRenderer: PlainText } }}
/>;

Reach for this only when you want a different markdown engine. Streamdown is doing real work that you take over with it: parsing markdown that is still half-written without flashing broken output, math and syntax highlighting, and sanitizing model-generated HTML. That last one matters — content is raw model output, so anything you render it with has to be safe against it.

Custom tags are not supported#

You cannot teach the renderer a tag of your own, such as <reference-chip>. This is deliberate, and it is enforced twice.

The type only accepts real HTML tag names, so a custom key is a compile error:

error TS2353: Object literal may only specify known properties,
and '"reference-chip"' does not exist in type 'Components'.

And at runtime the renderer sanitizes model output against an allowlist of standard HTML elements before any component mapping happens. An unknown tag is stripped, its text kept: Hi <reference-chip id="42">Doc 42</reference-chip>. renders as <p>Hi Doc 42.</p>, and a component registered for it is never called.

Tags that are on the allowlist do survive, and are overridable like any other — <kbd> and <sup> written directly in a reply render as real elements.

If you need a richer, non-textual surface in the conversation, that is what generative UI is for: the agent calls a tool and you render a React component for that tool call. Do not turn off sanitization to get a custom tag through.

Other frontends#

This page describes the React API. The other frontends expose the same idea under different names, and the Streamdown components map is React-only:

Reference#