| name | matrx-extend-tool-display |
| description | Customize how a tool call renders inside the matrx-extend Chrome extension's chat surface (the agent harness). Use when adding a new entry to the per-tool display registry — overriding the inline header (icon, prefix, name, suffix, info), tweaking how args and results are shown, plugging in a fully custom React component, or troubleshooting a registered tool that's falling back to the default rendering. Applies ONLY to this Chrome extension's `src/features/chat/tool-display/` system; do not use for other Matrx surfaces (admin UI, dashboard, workflow studio) which have their own UIs. |
matrx-extend Tool Display Registry
Per-tool customization for how a tool call shows up in the chat surface of the matrx-extend Chrome extension (the agent harness side panel). Anything not registered keeps rendering exactly as it does today — the registry only intercepts on opt-in.
Scope guardrail: this only applies to src/features/chat/tool-display/ in the matrx-extend repo. The Matrx admin app, dashboard, workflow-studio, etc. are separate surfaces with their own rendering pipelines. Do NOT confuse with server-side tool catalog generation (pnpm catalog:tools) — that's about advertising tools to the LLM, not displaying their results to humans.
When to use this skill
Trigger when the user asks to:
- Add a new tool to the display registry / "make X tool show up nicely"
- Customize what shows in the inline header (icon, prefix, "Getting X" labels, info hint)
- Render a tool's result with custom components instead of raw JSON
- Plug in a fully custom React component for one tool
- Debug a registered tool that's silently falling back to the default
Mental model in 30 seconds
chat → ToolTimelineRow (client tools) ─┐
chat → ServerToolRow (server tools) ─┴→ toolDisplayRegistry[toolName] ?
├─ no → default rendering (unchanged)
├─ yes + cfg.CustomComponent → that
└─ yes + config → ConfigurableToolRow
(wrapped in ToolDisplayBoundary)
Three failure modes are all silent — they log console.warn and degrade gracefully:
- Bad path/key in config → that field is skipped, others render.
- Throwing transform / field component → that field is skipped, others render.
- Throwing custom component → entire row falls back to default.
The user never sees an error UI. Verify your config by checking the rendered row, not by waiting for an exception.
Quick start — minimal entry
For a tool whose result has a label and content field that should render nicely:
export const toolDisplayRegistry: Record<string, ToolDisplayEntry> = {
my_tool_name: {
inline: {
prefix: { started: 'Doing', completed: 'Did' },
info: { path: 'args.target', transform: 'snakeToTitle' },
},
results: {
displayType: 'custom',
keysInfo: [
{ key: 'label', component: 'BoldLabel' },
{ key: 'content', component: 'Markdown', transform: 'textClean' },
],
},
},
};
That's it. The tool name is the raw toolName string the SW dispatcher uses (e.g. ctx_get, read_page, seo_get_keyword_data) — not the title-cased label. Find it by triggering the tool once and reading the inline row.
File map
| File | Purpose | When to edit |
|---|
src/features/chat/tool-display/registry.tsx | The actual entries map | Adding a new tool — almost always the only file you touch |
src/features/chat/tool-display/types.ts | TS shapes for the config | Only when adding a new top-level config field (rare) |
src/features/chat/tool-display/registry-transforms.ts | Named transforms (titleCase, textClean, …) | Adding a reusable string transformer |
src/features/chat/tool-display/registry-components.tsx | Field components (BoldLabel, Markdown, …) | Adding a new way to render a single value |
src/features/chat/tool-display/helpers.ts | Resolution + error boundary | Almost never |
src/features/chat/tool-display/ConfigurableToolRow.tsx | The renderer | Almost never — config-driven |
src/features/chat/ToolTimelineRow.tsx | Client-tool dispatch (do NOT touch the default branch) | Only if changing the dispatcher itself |
src/features/chat/ServerToolRow.tsx | Server-tool dispatch (same) | Same |
One golden rule: edits go to registry.tsx (and maybe -transforms.ts / -components.tsx). Never modify the default render paths in ToolTimelineRow / ServerToolRow — they are the safety net for every unregistered tool plus the fallback for any registered tool whose config blows up.
The full config shape
Every visual segment is PhaseAware<T>: pass a single value to apply to all phases, or { started, completed, error } to vary per phase.
interface ToolDisplayEntry {
inline?: {
hidden?: PhaseAware<boolean>;
icon?: PhaseAware<IconName | InfoSpec>;
prefix?: PhaseAware<string>;
name?: PhaseAware<string | InfoSpec>;
suffix?: PhaseAware<string>;
info?: PhaseAware<string | InfoSpec>;
color?: PhaseAware<ColorToken>;
isMultiline?: boolean;
spinIcon?: PhaseAware<boolean>;
shimmerOnRunning?: boolean;
};
args?: {
hidden?: <>;
?: | | ;
};
?: {
?: <>;
?: | | | ;
?: [];
?: ;
};
?: <{ : ; : | }>;
}
{
: ;
?: | [];
?: ;
}
{
: ;
: ;
?: ;
?: | [] | ;
?: ;
}
Path expressions
Used in inline.info.path and (with shallow-only resolution) in keysInfo[].key. Recognized roots for the inline path:
args.<key>... — the model-supplied input
output.<key>... (or result.<key>..., alias) — the tool's return payload
message — error message string (only set in error phase)
toolName, callId — for completeness; rarely useful
Numeric segments index into arrays: output.items.0.title. Misses are silent — render skips that segment.
Transforms (current set)
In registry-transforms.ts. Add new ones there — same (unknown) => unknown contract, never throw.
| Name | Effect |
|---|
titleCase / snakeToTitle / kebabToTitle | clean_content_markdown → Clean Content Markdown (aliases — same impl) |
textClean | Strips markdown escapes (\_, \*, \`), trims whitespace |
truncate80 / truncate200 | Truncate long strings with an ellipsis |
lowercase / uppercase | Case conversion |
formatImageDimensions | { width, height, ... } → "WxH" (e.g. 2576×1911). Use with info.path: 'output' for image tools. |
formatBytes | Number → "123 KB" / "4.5 MB" |
browserCategoryIcon | Browser-tools category name → distinct lucide icon (core → Wrench, forms → FormInput, cookies → Cookie, …). Use as the transform on inline.icon with path: 'args.category'. |
Chain by passing an array: transform: ['textClean', 'truncate80'].
Field components (current set)
In registry-components.tsx. Each receives { value, className? }.
| Name | Renders as |
|---|
BoldLabel | <div> with bold 12px text |
TextDisplay | Plain 12px <div> |
Markdown | <MarkdownView density="compact"> (full markdown w/ remark-gfm) |
Code | Pre-formatted scrollable code block |
Json | Pretty-printed JSON in a code block |
Image | <img> — value must be a URL or data-URI string |
Base64Image | <img> from base64 — accepts a string OR an object like { image_base64, media_type, width?, height?, byte_length? } (the screenshot result shape). Renders the image plus a small caption with dimensions and size. |
Badge | Small pill — good for counts, statuses |
Chips | An array of strings rendered as a wrapped row of monospace pills. Good for tool lists, tag arrays, etc. Non-array values are coerced to a single chip. |
TabCard | Favicon + title + clickable URL card — built for get_active_tab-style payloads. Reads fav_icon_url (or favIconUrl/favicon), title, and url keys; falls back to a Globe icon if the favicon fails to load. Use with key: '' to pass the whole result. |
When adding a new component: keep it small, accept unknown for value, never throw.
Whole-result key convention
keysInfo[].key normally points at one field of the result. To pass the entire result object to the field component, set key: '' (or key: '*'). This is how Base64Image gets at both image_base64 and media_type in one render — no need to combine fields with a transform.
keysInfo: [{ key: '', component: 'Base64Image' }]
Visual design philosophy
The chat surface treats tool calls like the Reasoning block — slim, inline, low-noise. No borders, no card backgrounds, just py-0.5 rows with hover affordance. The visual heaviness of a tool row should match its semantic weight: a typical tool call is one log line, not a dashboard widget.
What this means concretely:
- Inline rows are tiny. Don't bulk them up with extra info segments unless they earn their space.
- Most "rich" content goes in the expanded body. Click to inspect args + result JSON.
- A tool whose result IS the point (screenshots, charts, favicons) should hoist that content with
results.alwaysShow: true OR by putting the visual signal in the inline icon (favicons via InfoSpec).
- Long-running tools (
find, AI tools, network-heavy actions) get a shimmering label automatically while phase=started — keep shimmerOnRunning enabled (it's the default).
results.alwaysShow — render the result without a click
Set when the result payload IS the user-facing point of the tool. The keysInfo content renders directly under the row, visible by default. The click-to-expand still works for inspecting args + raw JSON.
results: {
displayType: 'custom',
keysInfo: [{ key: '', component: 'Base64Image' }],
alwaysShow: true,
}
When to use it:
- Yes: screenshots, generated images, charts, single-pane media.
- Maybe: a TabCard or Chips list — but consider hoisting the signal into the inline row instead (favicon as icon, count in info).
- No: long text, JSON dumps, list of items — these are better behind a click so the chat doesn't sprawl.
Result display modes
results.displayType controls how the (typically object) result is shown:
'json' (default) — pretty-printed JSON in a <pre>. Same as today's default.
'key-value' — two-column grid: bold key, value. One row per top-level key. Non-object payloads fall back to JSON.
'values-only' — bulleted list of just the values. Loses keys. Useful when keys are noise.
'custom' — iterate keysInfo; each entry getByPath → applyTransforms → render with component. Missing keys with no fallback are silently skipped + warned.
args.displayType supports the same modes minus 'custom'.
Icons + colors
Icons: any export name from lucide-react. Examples: HandGrab, Database, Search, Globe, Cookie, Camera, MousePointerClick, Keyboard, FileText, BookOpenText, Wrench, Bug, Sparkles, Plug, AppWindow. Unknown names log a warning and fall back to the phase default (Loader2 / CheckCircle2 / AlertTriangle).
Dynamic icons (favicons, per-arg icons): pass an InfoSpec instead of a literal name. The resolved string is auto-detected as a lucide name OR a URL:
icon: { completed: { path: 'output.fav_icon_url', fallback: 'Globe' } }
icon: { completed: { path: 'args.category', transform: 'browserCategoryIcon' } }
When the URL fails to load (favicon 404, CSP block, etc.) the renderer falls back to a Globe icon. When an InfoSpec resolves to nothing, falls back to the phase-default lucide icon (use fallback: 'SomeLucideName' to override).
Colors: pick a ColorToken — blue, sky, emerald, amber, red, violet, slate, primary, muted. Resolves to a text-{color}-600 dark:text-{color}-400 class. Error phase always wins (forced red) regardless of the override — keeps error visuals consistent across the app.
The CustomComponent escape hatch
When the config isn't expressive enough — e.g. you want a chart, a comparison view, or per-tool interactive elements — pass a full React component:
import type { ToolTimelineEntry } from '../ToolTimelineRow';
function MyTool({ entry, kind }: { entry: ToolTimelineEntry; kind: 'server' | 'client' }) {
return <div className="rounded-md border bg-card/60 p-2">…your UI…</div>;
}
my_tool: { CustomComponent: MyTool }
When CustomComponent is set, inline/args/results config is ignored — your component owns the entire visual. If your component throws on render, ToolDisplayBoundary catches it and the row falls back to the default rendering (with a console warning).
You're responsible for the outer card / styling — match kind === 'server' vs kind === 'client' if you want surface consistency. See ConfigurableToolRow for examples of the existing card classes.
Adding a new tool — checklist
- [ ] 1. Find the raw toolName. Trigger the tool once; the default row shows
the snake_case name. (For server tools, ServerToolRow titleCases it,
so use the catalog: types/tool-catalog.json or src/lib/tools/handlers/*.)
- [ ] 2. Decide the inline shape:
- prefix (phase-aware verb: "Getting" / "Got" / "Failed to get")
- icon (a lucide name that suggests the action)
- color (one of the eight ColorTokens; error is always red)
- info (the most identifying arg or result field, transformed for humans)
- [ ] 3. Decide the result shape:
- Simple? Use 'key-value'.
- Has a label + body? Use 'custom' with BoldLabel + Markdown/TextDisplay.
- Already pretty as JSON? Leave default ('json').
- [ ] 4. Add the entry to toolDisplayRegistry in registry.tsx.
- [ ] 5. pnpm tsc --noEmit — confirm types check.
- [ ] 6. Reload the extension, trigger the tool, walk all three phases:
- while running: prefix + icon + spinning + info text correct
- after success: prefix swap + icon swap + color correct
- on error: prefix swap + red icon, no crash, message visible
(Force an error by passing invalid args or breaking the network.)
Common patterns (cookbook)
Phase-aware prefix that conjugates by tense
prefix: { started: 'Searching', completed: 'Searched', error: 'Search failed' }
"Saving X" → "Saved X" with the X coming from args
inline: {
prefix: { started: 'Saving', completed: 'Saved' },
name: '',
info: { path: 'args.title', transform: 'truncate80' },
}
Inline shows a count from the result, only after completion
info: {
started: undefined,
completed: { path: 'output.items.length', fallback: '0' },
}
Render a result that's a list of {title, url} objects
Use a CustomComponent — keysInfo only addresses single values, not "render every item in this array". Or pre-shape the result on the server.
Suppress the row entirely while running, show only when done
inline: { hidden: { started: true } }
Verification
After every registry change:
- Typecheck:
pnpm tsc --noEmit (must exit 0).
- Build:
pnpm wxt build (extension must build cleanly).
- Visual sweep: open the side panel, trigger the tool, walk all three phases (
started → completed → error). Compare against an unregistered tool to confirm the default still works for everything else.
- Console check: open DevTools console while triggering. Any
[tool-display] ... warning means a path/transform/icon is wrong — silent in the UI but logged.
- Catalog regen (only if you changed tool handlers, not just display):
pnpm catalog:tools:md and commit. Display registry edits do NOT need catalog regen.
Anti-patterns
- ❌ Modifying
DefaultToolTimelineRow / DefaultServerToolRow. The defaults are the safety net — touch them and every fallback inherits your bug.
- ❌ Adding error UI in the configurable renderer. Failures should be silent + logged. The user should never see "config error" — they should see the default rendering.
- ❌ Putting tool-specific logic inside
helpers.ts or ConfigurableToolRow.tsx. That's what the registry config + CustomComponent are for.
- ❌ Using the
result. alias in keysInfo[].key. keysInfo paths are scoped to the result object already — just use label (or nested.field), not result.label.
- ❌ Registering a tool name that doesn't exist. The dispatcher silently falls through to the default; you'll think your config doesn't work when really the tool never ran.
- ❌ Importing from outside
src/features/chat/tool-display/ into the registry maps. Registry files should be a flat description of behavior — pull in shared UI through registry-components.tsx instead.
Reference: the ctx_get worked example
Currently the only registered tool. It's the canonical example for the four core capabilities:
ctx_get: {
inline: {
icon: { started: 'Loader2', completed: 'HandGrab', error: 'AlertTriangle' },
prefix: { started: 'Getting', completed: 'Got', error: 'Failed to get' },
name: '',
info: { path: 'args.key', transform: 'snakeToTitle' },
color: { started: 'primary', completed: 'blue', error: 'red' },
},
args: { displayType: 'key-value' },
results: {
displayType: 'custom',
keysInfo: [
{ key: 'label', component: 'BoldLabel', className: 'text-foreground' },
{ key: 'content', component: 'Markdown', className: 'text-foreground', transform: 'textClean' },
],
},
}
Reads as: "While running, show a spinner with Getting Clean Content Markdown in primary color. After success, swap to a HandGrab icon and Got Clean Content Markdown in blue. On error, red AlertTriangle and Failed to get Clean Content Markdown. Expanded body shows args as a key-value grid, then a bold label + markdown-rendered content with backslash escapes cleaned."
Use it as the starting template for new entries.
name accepting an InfoSpec
Two patterns where this matters:
Mid-sentence values (the dynamic word lives between static prefix/suffix):
inline: {
prefix: { started: 'Loading my', completed: 'Loaded my', error: 'Failed to load my' },
name: { path: 'args.category' },
suffix: 'browser tools',
}
Title-as-name (a full descriptive title takes over the label after success):
inline: {
prefix: { started: 'Reading active tab', error: "Couldn't read active tab" },
name: {
started: '',
completed: { path: 'output.title', transform: 'truncate80' },
error: '',
},
}
Why both '' and undefined matter:
name: undefined → falls back to titleCase(entry.toolName) (e.g. Get Active Tab)
name: '' → suppresses the segment entirely (no fallback)
name: { path: '...' } resolving to nothing → empty (no fallback to titleCase)
When you only want a name in some phases, set the others to '' explicitly — otherwise the auto title-case sneaks back in.
A second example — take_screenshot
Demonstrates: the whole-result key convention, the Base64Image field component, and using a transform on the entire output object for the inline info.
take_screenshot: {
inline: {
icon: { started: 'Loader2', completed: 'Camera', error: 'AlertTriangle' },
prefix: {
started: 'Capturing screenshot',
completed: 'Captured screenshot',
error: 'Failed to capture screenshot',
},
name: '',
info: { completed: { path: 'output', transform: 'formatImageDimensions' } },
color: { started: 'primary', completed: 'violet', error: 'red' },
},
args: { displayType: 'key-value' },
results: {
displayType: 'custom',
keysInfo: [{ key: '', component: 'Base64Image' }],
},
}
Reads as: "While capturing, show a spinner with Capturing screenshot in primary color. After success, swap to a Camera icon and Captured screenshot 2576×1911 in violet — dimensions extracted by formatImageDimensions reading width/height off the whole output object. The expanded body renders the actual image inline, with a small caption underneath showing 2576×1911 · 313.0 KB. On error, red AlertTriangle and Failed to capture screenshot with no dimensions."
Universal copy button
Every row (default and configurable, server and client) has a clipboard icon on the right that appears on hover. Clicking copies the full payload as pretty-printed JSON:
{
"tool": "ctx_get",
"phase": "completed",
"args": { "key": "clean_content_markdown" },
"result": { "label": "...", "content": "..." },
"duration_ms": 263,
"callId": "..."
}
Lives in CopyToolButton.tsx. Each renderer constructs a ToolCopyData object and passes it as data. The button stops click propagation so it never toggles the row open. Don't add per-tool overrides for it — the universal payload is the right shape for users (paste into bug reports, share with another agent, etc.).
When to reach for CustomComponent
The config-driven path covers ~90% of tools. Use CustomComponent when the tool needs interactive UI — not just a richer display, but inputs the user fills in and submits. Examples:
interaction_ask — server-side multi-question questionnaire (radio + toggle inputs). The args carry the spec; the card renders the form, collects answers, and posts them back as a regular user chat message via useChatStream().send(). Submission state is persisted per callId in a small Zustand store inside the card so the form doesn't reappear after scrolling away. See InteractionAskCard.tsx as the reference implementation for "tool that asks for input".
The pattern for "answer goes back to the agent" tools without a dedicated SSE response channel: format the answer as a chat message and void send(text, { agentId, conversationId }). The next agent turn sees it like any other user message.
Cookbook — phased animations and dynamic icons
Long-running tool with shimmering query as the label
find: {
inline: {
icon: 'Search',
prefix: { started: 'Searching for', completed: 'Found', error: 'Search failed' },
name: { path: 'args.query', transform: 'truncate80' },
info: { completed: { path: 'output.matches.length', fallback: '0' } },
suffix: { completed: 'matches' },
color: { started: 'primary', completed: 'violet', error: 'red' },
},
}
While running: spinning Search icon + shimmering "Searching for the sign-in button". On success: violet Search + "Found the sign-in button 3 matches".
Per-category dynamic icon
load_chrome_tools: {
inline: {
icon: {
started: 'Loader2',
completed: { path: 'args.category', transform: 'browserCategoryIcon' },
error: 'AlertTriangle',
},
prefix: { started: 'Loading my', completed: 'Loaded my', error: 'Failed to load my' },
name: { path: 'args.category' },
suffix: 'browser tools',
},
}
forms category → FormInput icon. cookies → Cookie. debug → Bug. Add new categories to BROWSER_CATEGORY_ICONS in registry-transforms.ts.
Favicon as the inline icon
get_active_tab: {
inline: {
icon: {
started: 'Loader2',
completed: { path: 'output.fav_icon_url', fallback: 'Globe' },
error: 'AlertTriangle',
},
prefix: { started: 'Reading active tab', error: "Couldn't read active tab" },
name: { started: '', completed: { path: 'output.title', transform: 'truncate80' }, error: '' },
info: { completed: { path: 'output.url', transform: 'truncate80' } },
},
}
When the URL fails to load (CSP block, 404), it falls back to Globe automatically. The whole tab identity (favicon + title + URL) lives in the inline row — no expanded body needed.