16 KiB
2026-07-13 — MCP tool apps: custom in-chat renderers
Status: Planned
Goal
Today, every MCP tool call renders identically — a collapsed JSON box in
ToolCallGroup.svelte with raw {args, result} dumps. The operator has to
expand and read JSON to understand what the agent did or found. This plan
introduces tool renderers — per-tool Svelte components that render rich,
purpose-built UI inline in the conversation, while unmatched tools stay
collapsed.
Concrete examples: get_entity("host:hubris") renders as a compact entity card
with type badge, health dot, and key attributes — not as 30 lines of JSON.
get_health_summary renders as colored health bars. list_lxcs renders as a
sortable table. The operator reads the chat, not raw tool output.
UX principle: inline by default when matched
Today:
┌─────────────────────────────┐
│ 🔧 3 tools: get_entity, │ ← collapsed
│ get_health_summary, ... │
│ ┌───────────────────────┐ │
│ │ {"slug":"host:hubris",│ │ ← raw JSON when expanded
│ │ "type":"host",...} │ │
│ └───────────────────────┘ │
└─────────────────────────────┘
The fleet is healthy. 42 hosts...
Target:
┌─────────────────────────────┐
│ ● host:hubris [host] 🟢 │ ← entity card, inline
│ healthy · 2 min ago │
│ IP: 10.13.13.1 │
└─────────────────────────────┘
┌─────────────────────────────┐
│ ████████████ healthy 42 │ ← health bar, inline
│ ███ degraded 3 │
│ ██ down 1 │
└─────────────────────────────┘
┌─────────────────────────────┐
│ 🔧 1 tool: list_entities │ ← unmatched → still collapsed
└─────────────────────────────┘
The fleet is healthy...
Tools with a custom renderer appear inline as their own card in the message
flow, between the user bubble and the assistant's markdown text. Tools without
a renderer stay in the collapsed ToolCallGroup. This naturally separates
"rich, informative" tools from "utility/plumbing" tools.
During streaming (tool in progress)
┌─────────────────────────────┐
│ ◌ host:hubris │ ← skeleton while tool_result
│ [host] ⠋ loading… │ hasn't arrived yet
└─────────────────────────────┘
Skeleton/spinner state shown while type === 'tool_use'; full card on tool_result.
The per-tool card has the same chrome as ToolCallGroup: spinner → check/X based
on completion and error state.
Error state
┌─────────────────────────────┐
│ ✕ get_entity │
│ Entity not found: "bad" │
└─────────────────────────────┘
Architecture
Layer 1 — Server annotation (internal/mcp/server.go)
Each tool handler that warrants a custom renderer adds a __renderer key to
its result JSON. This is a plain string field — no protocol changes, no new
content types.
// Before:
return queryEntity(ctx, pool, slug), nil
// After:
r := queryEntity(ctx, pool, slug)
r["__renderer"] = "entity_card"
return jsonResult(r), nil
Renderer IDs by tool:
| Tool | __renderer |
Component |
|---|---|---|
get_entity |
entity_card |
EntityCard.svelte |
whoami |
entity_card |
EntityCard.svelte |
explain |
entity_card |
EntityCard.svelte |
get_health_summary |
health_summary |
HealthSummary.svelte |
list_lxcs |
lxc_list |
LXCList.svelte |
list_entities |
entity_table |
EntityTable.svelte |
search_knowledge |
knowledge_results |
KnowledgeResults.svelte |
get_entity_knowledge |
knowledge_results |
KnowledgeResults.svelte |
query_metrics |
metric_chart |
MetricChart.svelte |
get_blast_radius |
blast_radius |
BlastRadius.svelte |
get_change_history |
change_log |
ChangeLog.svelte |
get_agent_activity |
activity_log |
(reuses ChangeLog.svelte) |
get_state_snapshot |
fleet_snapshot |
FleetSnapshot.svelte |
Non-rendered tools (stay collapsed): get_relations, list_my_secrets,
get_service_status, tail_log, get_lxc_state, ping_service,
get_patterns, get_skills, http_get, preflight, get_signal_history,
get_execution_status, get_audit_trail, get_trend,
get_event_timeline, upsert_knowledge, update_entity_attributes,
create_relationship, run, request_execution.
Gated execution tools (run, request_execution) already have
InlineApproval.svelte rendering in Chat.svelte — they stay in the collapsed
group so the approval card remains prominent at the message level.
Layer 2 — Renderer registry (web/src/lib/tool-renderers.ts)
import type { ComponentType, SvelteComponent } from 'svelte'
import type { ToolCallResult } from '$lib/stores/chat'
export interface ToolRenderer {
match: (tool: ToolCallResult) => boolean
component: ComponentType<{ tool: ToolCallResult }>
}
const registry: ToolRenderer[] = []
export function registerToolRenderer(r: ToolRenderer) {
registry.push(r)
}
export function getToolRenderer(tool: ToolCallResult): ToolRenderer | undefined {
return registry.find(r => r.match(tool))
}
Match strategy: check tool.name against known tool names AND check
tool.result?.__renderer if the result is an object. The tool-name path
handles the streaming case (tool_use arrives before tool_result); the
__renderer path handles cases where the same tool can return different
shapes (not applicable today but future-proof).
function hasRenderer(tool: ToolCallResult, id: string): boolean {
if (tool.name === id) return true
if (tool.result && typeof tool.result === 'object' && tool.result.__renderer === id) return true
return false
}
Layer 3 — Auto-registration via Vite glob (web/src/lib/renderers/index.ts)
Each renderer file exports a Svelte component and an init() call:
// web/src/lib/renderers/entity-card.ts
import EntityCard from './EntityCard.svelte'
import { registerToolRenderer } from '$lib/tool-renderers'
export function init() {
registerToolRenderer({
match: (t) => hasRenderer(t, 'entity_card'),
component: EntityCard
})
}
web/src/lib/renderers/index.ts imports and calls init() for every renderer
module. In web/src/main.ts, a single import './lib/renderers' wires
everything. Adding a new renderer means: create Foo.svelte + foo.ts with
init(), add the import to index.ts. No changes to ToolCallGroup or Chat.
Layer 4 — ToolCallGroup split (web/src/lib/components/ToolCallGroup.svelte)
ToolCallGroup today receives tools: ToolCallResult[] and renders all of them.
Change: add an exported unmatched derived prop, and move the per-tool
rendering to Chat.svelte so inline cards appear in the message flow.
New ToolCallGroup props:
let { tools, unmatched, active = false }: {
tools: ToolCallResult[]
unmatched: ToolCallResult[]
active?: boolean
} = $props()
tools is the full list (for the summary: "3 tools"); unmatched is the
subset without renderers (for the collapsed body). The summary bar says
"1 tool" if only one unmatched tool exists, or "1 tool + 2 cards" to
acknowledge the inline ones.
Chat.svelte changes (lines 114-133):
{:else}
<div class="flex w-full flex-col gap-2">
<!-- Inline tool cards (matched renderers) -->
{#each msg.tools as tool (tool.id)}
{@const renderer = getToolRenderer(tool)}
{#if renderer}
<renderer.component {tool} />
{/if}
{/each}
<!-- Unmatched tools → collapsed group -->
<ToolCallGroup
tools={msg.tools}
unmatched={msg.tools.filter(t => !getToolRenderer(t))}
active={$streaming && i === $messages.length - 1}
/>
<!-- Text + approvals (unchanged) -->
...
</div>
{/if}
Layer 5 — Renderer component contract
Every renderer component receives a single tool: ToolCallResult prop.
Component responsibilities:
- Loading state (
tool.type === 'tool_use'): render a skeleton/spinner with the tool name and relevant args - Success state (
tool.type === 'tool_result' && !tool.error): render the rich card - Error state (
tool.type === 'tool_result' && tool.error): render error with a compact message - No result (tool completed but result is null/empty): render a minimal card with just the tool name + checkmark
Self-contained card: a border, padding, and the same size feel as the existing approval cards. Each card is independent — no shared state between renderers.
New components
| Component | Matches | Visual |
|---|---|---|
EntityCard |
get_entity, whoami, explain |
Slug + type badge + health dot + state + key attrs (IP, version) + "last checked" relative time |
HealthSummary |
get_health_summary |
Horizontal stacked bar: green=healthy, amber=degraded, red=down, with counts |
LXCList |
list_lxcs |
Compact table: name, host, IP, health dot, status |
EntityTable |
list_entities |
Sortable table (reuses EntityTable.svelte from KB page) |
KnowledgeResults |
search_knowledge, get_entity_knowledge |
Result cards: title + excerpt + tags |
MetricChart |
query_metrics |
Sparkline or small bar chart of bucketed values |
BlastRadius |
get_blast_radius |
Entity list grouped by distance (direct → 1 hop → 2 hops) |
ChangeLog |
get_change_history, get_agent_activity |
Timeline of recent entries with timestamps |
FleetSnapshot |
get_state_snapshot |
Summary grid: healthy/degraded/down counts + drift count |
Some already exist partially: EntityTable.svelte and EntityGraph are used
on the KB page — the renderer can wrap or reuse them.
File changes
| File | Change |
|---|---|
internal/mcp/server.go |
Add __renderer to result JSON for ~12 tools |
web/src/lib/tool-renderers.ts |
New — registry + match helpers |
web/src/lib/renderers/index.ts |
New — imports all renderer init modules |
web/src/lib/renderers/entity-card.ts |
New — register EntityCard |
web/src/lib/renderers/EntityCard.svelte |
New — entity card component |
web/src/lib/renderers/health-summary.ts |
New — register HealthSummary |
web/src/lib/renderers/HealthSummary.svelte |
New — health bar component |
web/src/lib/renderers/lxc-list.ts |
New — register LXCList |
web/src/lib/renderers/LXCList.svelte |
New — LXC table component |
web/src/lib/renderers/knowledge-results.ts |
New — register KnowledgeResults |
web/src/lib/renderers/KnowledgeResults.svelte |
New — search results component |
web/src/lib/renderers/metric-chart.ts |
New — register MetricChart |
web/src/lib/renderers/MetricChart.svelte |
New — sparkline component |
web/src/lib/renderers/blast-radius.ts |
New — register BlastRadius |
web/src/lib/renderers/BlastRadius.svelte |
New — blast radius component |
web/src/lib/renderers/change-log.ts |
New — register ChangeLog |
web/src/lib/renderers/ChangeLog.svelte |
New — timeline component |
web/src/lib/renderers/fleet-snapshot.ts |
New — register FleetSnapshot |
web/src/lib/renderers/FleetSnapshot.svelte |
New — snapshot grid component |
web/src/lib/components/ToolCallGroup.svelte |
Add unmatched prop; render only unmatched tools in body; adjust summary text |
web/src/pages/Chat.svelte |
Filter matched tools to inline cards; pass unmatched to ToolCallGroup |
web/src/main.ts |
Add import './lib/renderers' |
Phases
Phase 1 — Infra + first renderer
- Create
tool-renderers.tsregistry - Create renderer directory + index
- Modify
ToolCallGroup.svelteto acceptunmatchedprop - Modify
Chat.svelteto dispatch inline cards - Add
__renderertoget_entity,whoami,explainin server.go - Create
EntityCard.svelte— the flagship renderer - Test: ask Nomos "tell me about host:hubris"
- Verify: entity card renders inline, unrelated tools stay collapsed
Phase 2 — Remaining renderers
Add in priority order:
HealthSummary+get_health_summaryannotationLXCList+list_lxcsannotationKnowledgeResults+search_knowledge/get_entity_knowledgeannotationBlastRadius+get_blast_radiusannotationEntityTable+list_entitiesannotationChangeLog+get_change_history/get_agent_activityannotationFleetSnapshot+get_state_snapshotannotationMetricChart+query_metricsannotation
Phase 3 — Polish
- Smooth streaming: renderer shows skeleton on
tool_use, renders ontool_result - Error states: renderer shows compact error card (not raw JSON)
- Mobile: renderers stack full-width on narrow viewports
- Accessibility: renderer cards have proper ARIA labels
Risks / open questions
-
Tool name vs
__renderermismatch: If the server adds__rendererbut the frontend hasn't deployed yet, the field is silently ignored and the tool falls back to the collapsed group. No breakage. Vice versa (frontend expects a renderer that the server doesn't emit): match falls through to collapsed group. Graceful degradation in both directions. -
Streaming jank: During a turn, tool_use events arrive before tool_result. The renderer sees
type: 'tool_use'initially (shows skeleton), then Svelte reactivity updates when the store mergestool_result. The card transitions from skeleton → rich. This is the same model as ToolCallGroup's spinner → check transition. No new complexity. -
Multiple tools of same type: A single agent turn might call
get_entitytwice. Each gets its own inline card — natural, no dedup needed. -
Card explosion: If the agent calls 15 tools in one turn and 12 of them match renderers, the chat gets 12 inline cards before any text. This could be noisy if the agent is chatty. Mitigations: (a) renderers are compact (3-4 lines), (b) the collapsed group still exists for unmatched tools, (c) we can add a per-turn limit later ("show top 3, collapse rest") but start simple. The agent in practice calls 3-6 tools per turn; 12 is an edge case.
-
Security: Renderers receive tool result data that already passed through the SSE stream (authenticated). No new attack surface. Renderers render data, not HTML — Svelte's auto-escaping handles XSS. No
{@html}in any renderer unless explicitly sanitized. -
Persistence: Tool results are already persisted in the DB (via Nomos's incremental message persistence). On session reload,
mergeToolCalls()→toChatMessages()reconstructsToolCallResult[]includingresult. The__rendererfield survives the roundtrip because it's part of the result JSON, which is stored as-is. No schema change. Verified:cmd/nomos/continue.gomarshals tool results to JSONB without filtering fields —__rendererpasses through transparently. -
The gated-execution tools (
run,request_execution): These already surface asInlineApprovalcards at the message level viaextractApprovals()inchat.ts. They deliberately stay in the collapsed group — don't add a renderer for them. The approval card is the rich UI.
Cost / effort
| Area | Estimate |
|---|---|
| Server annotations | ~20 lines across ~12 tools |
| Registry + infra | ~50 lines (tool-renderers.ts + Chat.svelte + ToolCallGroup changes) |
| First renderer (EntityCard) | ~60 lines |
| Remaining 8 renderers | ~40-80 lines each |
| Total | ~500 lines, 1-2 sessions |