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 type | Default renderer | When to override |
|---|---|---|
sentiment-signal, intent-signal | sr-only (accessibility-only) | Always, if you want a visible UI like a sentiment meter or intent badge. |
audio-cue | no-op | If 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-result | Built-in approval card and recall list | Override 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
- React Integration -- hooks, components, and the full API surface.
- Messages & Parts -- every part type and its data shape.
- Generative UI -- server-driven dynamic UI surfaces.
- A2UI Catalog -- 20 vetted preset surfaces installable via
gecx add ui:<name>.
docs/guides/custom-renderers.md