Files
oikos/plans/2026-07-13-mcp-tool-apps-custom-chat-renderers.md
dtoro 6b52c1ae57
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Move 2026-07-12-wails-desktop-app plan to done/
Phase 0 deployed 2026-07-12. Phases 1.0–1.4 implemented 2026-07-13.
Plan complete.
2026-07-13 22:41:45 +02:00

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

  1. Create tool-renderers.ts registry
  2. Create renderer directory + index
  3. Modify ToolCallGroup.svelte to accept unmatched prop
  4. Modify Chat.svelte to dispatch inline cards
  5. Add __renderer to get_entity, whoami, explain in server.go
  6. Create EntityCard.svelte — the flagship renderer
  7. Test: ask Nomos "tell me about host:hubris"
  8. Verify: entity card renders inline, unrelated tools stay collapsed

Phase 2 — Remaining renderers

Add in priority order:

  1. HealthSummary + get_health_summary annotation
  2. LXCList + list_lxcs annotation
  3. KnowledgeResults + search_knowledge / get_entity_knowledge annotation
  4. BlastRadius + get_blast_radius annotation
  5. EntityTable + list_entities annotation
  6. ChangeLog + get_change_history / get_agent_activity annotation
  7. FleetSnapshot + get_state_snapshot annotation
  8. MetricChart + query_metrics annotation

Phase 3 — Polish

  1. Smooth streaming: renderer shows skeleton on tool_use, renders on tool_result
  2. Error states: renderer shows compact error card (not raw JSON)
  3. Mobile: renderers stack full-width on narrow viewports
  4. Accessibility: renderer cards have proper ARIA labels

Risks / open questions

  1. Tool name vs __renderer mismatch: If the server adds __renderer but 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.

  2. 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 merges tool_result. The card transitions from skeleton → rich. This is the same model as ToolCallGroup's spinner → check transition. No new complexity.

  3. Multiple tools of same type: A single agent turn might call get_entity twice. Each gets its own inline card — natural, no dedup needed.

  4. 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.

  5. 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.

  6. Persistence: Tool results are already persisted in the DB (via Nomos's incremental message persistence). On session reload, mergeToolCalls()toChatMessages() reconstructs ToolCallResult[] including result. The __renderer field survives the roundtrip because it's part of the result JSON, which is stored as-is. No schema change. Verified: cmd/nomos/continue.go marshals tool results to JSONB without filtering fields — __renderer passes through transparently.

  7. The gated-execution tools (run, request_execution): These already surface as InlineApproval cards at the message level via extractApprovals() in chat.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