Custom Renderers

Overview

The SDK renders every message part through a renderer registry. Built-in defaults handle every part type out of the box, but you can override any of them to match your design system or render domain-specific content.

The flow: MessagePart checks the registry for a custom renderer matching the part's type. If one exists, it calls that function. If not, it falls back to the built-in default.

Creating a renderer registry

Use createRendererRegistry() with a map of part type to render function:

import { createRendererRegistry } from 'gecx-chat/react';

const renderers = createRendererRegistry({
  text: (part) => <p className="chat-text">{part.text}</p>,

  'product-carousel': (part) => (
    <div className="product-grid">
      {part.products.map((p) => (
        <div key={p.id} className="product-card">
          <img src={p.imageUrl} alt={p.title} />
          <h3>{p.title}</h3>
          <span>{p.price}</span>
        </div>
      ))}
    </div>
  ),

  'order-summary': (part) => (
    <div className="order-card">
      <h3>Order #{part.orderId}</h3>
      <ul>
        {part.items.map((item) => (
          <li key={item.name}>{item.name} x{item.quantity} -- {item.price}</li>
        ))}
      </ul>
      <strong>Total: {part.total}</strong>
    </div>
  ),
});

Each key is a ChatMessagePart['type'] string. TypeScript narrows the part argument automatically -- a text renderer receives a TextPart, a product-carousel renderer receives a ProductCarouselPart, and so on.

Passing to ChatProvider

Hand the registry to ChatProvider via the renderers prop:

import { ChatProvider } from 'gecx-chat/react';
import { createChatClient } from 'gecx-chat';

const client = createChatClient({ /* config */ });

function App() {
  return (
    <ChatProvider client={client} renderers={renderers}>
      {/* your chat UI */}
    </ChatProvider>
  );
}

Every MessagePart rendered inside this provider tree will now use your custom renderers.

Renderer function signature

Each renderer receives the typed part object and returns a ReactNode:

type PartRenderer<T> = (part: T) => ReactNode;

The part is the specific part type -- not the full ChatMessagePart union. You get full type safety on the fields available for that part type.

Partial overrides

You only need to provide renderers for the types you want to customize. Everything else uses the built-in defaults. This means you can override just text and product-carousel without touching the other 14 types.

Versioned renderers

When your backend evolves a part type (e.g., a v2 product card adds new fields), you can register version-specific renderers:

const renderers = createRendererRegistry({
  'product-carousel': {
    1: (part) => <SimpleProductList products={part.products} />,
    2: (part) => <RichProductGrid products={part.products} />,
  },
});

The registry picks the renderer whose version matches the part's payloadVersion. If no exact match exists, it falls back to the default (unversioned) renderer for that type.

Default-renderer overrides for new part types

A few newer part types ship with intentionally minimal defaults — they expect hosts to override:

Part typeDefault rendererWhen to override
sentiment-signal, intent-signalsr-only (accessibility-only)Always, if you want a visible UI like a sentiment meter or intent badge.
audio-cueno-opIf you want overlays (barge-in indicator, end-of-turn affordance).
computer-use-surface<ComputerUseSurface> (sandboxed iframe, action log, consent banner, abort)Rarely — the default is intentionally complete. Override only if your app needs custom chrome.
memory-approval, memory-recall-resultBuilt-in approval card and recall listOverride for product-specific memory UX.

The signal renderer is the most common override; the SDK leaves it sr-only so non-instrumented apps don't accidentally expose model-internal classifications.

What's next

Source: docs/guides/custom-renderers.md