From 680575e2cfde1f1f3953180955639903560a1ff5 Mon Sep 17 00:00:00 2001 From: dtoro Date: Mon, 13 Jul 2026 22:41:50 +0200 Subject: [PATCH] Remove unrelated plan file from stale branch --- ...-13-mcp-tool-apps-custom-chat-renderers.md | 378 ------------------ 1 file changed, 378 deletions(-) delete mode 100644 plans/2026-07-13-mcp-tool-apps-custom-chat-renderers.md diff --git a/plans/2026-07-13-mcp-tool-apps-custom-chat-renderers.md b/plans/2026-07-13-mcp-tool-apps-custom-chat-renderers.md deleted file mode 100644 index ae6987d..0000000 --- a/plans/2026-07-13-mcp-tool-apps-custom-chat-renderers.md +++ /dev/null @@ -1,378 +0,0 @@ -# 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. - -```go -// 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`) - -```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). - -```ts -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: - -```ts -// 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:** - -```ts -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):** - -```svelte -{:else} -
- - {#each msg.tools as tool (tool.id)} - {@const renderer = getToolRenderer(tool)} - {#if renderer} - - {/if} - {/each} - - - !getToolRenderer(t))} - active={$streaming && i === $messages.length - 1} - /> - - - ... -
-{/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 |