Remove unrelated plan file from stale branch
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

This commit is contained in:
2026-07-13 22:41:50 +02:00
parent 6b52c1ae57
commit 680575e2cf

View File

@@ -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}
<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 |