=== DONE === - MCP Apps UI system added (11 apps with _meta.ui.resourceUri) - 19 new tool modules added - Tool count: 269 → 461 across 38 categories - Upstream changes merged - All tools tagged with _meta labels - Package lock updated === TO-DO === - [ ] Fix 42 failing edge case tests (BLOCKER — Stage 11) - [ ] Live API testing with GHL credentials - [ ] App design approval for Stage 7→8
640 lines
21 KiB
Markdown
640 lines
21 KiB
Markdown
# MCP App Fallback Architecture
|
||
## Graceful Degradation for Hosts Without Full Interactive Support
|
||
|
||
**Date:** 2026-02-03
|
||
**Status:** Proposed
|
||
**Applies to:** GoHighLevel MCP UI Kit React App
|
||
|
||
---
|
||
|
||
## Executive Summary
|
||
|
||
The ext-apps SDK provides explicit **host capability negotiation** via `McpUiHostCapabilities`. After the `ui/initialize` handshake, the View knows exactly which features the host supports. We use this to implement a **three-tier progressive enhancement** model: Static → Context-Synced → Fully Interactive.
|
||
|
||
---
|
||
|
||
## 1. Feature Detection (The Foundation)
|
||
|
||
### How It Works
|
||
|
||
When the View connects, the host returns `McpUiHostCapabilities` in the `ui/initialize` response. The `App` class exposes this via `app.getHostCapabilities()`.
|
||
|
||
**The key capability flags:**
|
||
|
||
```typescript
|
||
interface McpUiHostCapabilities {
|
||
serverTools?: { listChanged?: boolean }; // Can proxy tools/call to MCP server
|
||
serverResources?: { listChanged?: boolean }; // Can proxy resources/read
|
||
updateModelContext?: {}; // Accepts ui/update-model-context
|
||
message?: {}; // Accepts ui/message (send chat messages)
|
||
openLinks?: {}; // Can open external URLs
|
||
logging?: {}; // Accepts log messages
|
||
sandbox?: { permissions?: {...}; csp?: {...} };
|
||
}
|
||
```
|
||
|
||
**The critical check: `serverTools` tells you if `callServerTool` will work.**
|
||
|
||
### Implementation: `useHostCapabilities` Hook
|
||
|
||
```typescript
|
||
// hooks/useHostCapabilities.ts
|
||
|
||
import { useMemo } from "react";
|
||
import { useMCPApp } from "../context/MCPAppContext.js";
|
||
import type { McpUiHostCapabilities } from "@modelcontextprotocol/ext-apps";
|
||
|
||
export type InteractionTier = "static" | "context-synced" | "full";
|
||
|
||
export interface HostCapabilityInfo {
|
||
/** Raw capabilities from the host */
|
||
raw: McpUiHostCapabilities | undefined;
|
||
/** Whether callServerTool() will work */
|
||
canCallTools: boolean;
|
||
/** Whether updateModelContext() will work */
|
||
canUpdateContext: boolean;
|
||
/** Whether sendMessage() will work */
|
||
canSendMessages: boolean;
|
||
/** The interaction tier this host supports */
|
||
tier: InteractionTier;
|
||
}
|
||
|
||
export function useHostCapabilities(): HostCapabilityInfo {
|
||
const { app } = useMCPApp();
|
||
|
||
return useMemo(() => {
|
||
const raw = app?.getHostCapabilities?.() as McpUiHostCapabilities | undefined;
|
||
|
||
const canCallTools = !!raw?.serverTools;
|
||
const canUpdateContext = !!raw?.updateModelContext;
|
||
const canSendMessages = !!raw?.message;
|
||
|
||
// Determine tier
|
||
let tier: InteractionTier = "static";
|
||
if (canCallTools) {
|
||
tier = "full";
|
||
} else if (canUpdateContext) {
|
||
tier = "context-synced";
|
||
}
|
||
|
||
return { raw, canCallTools, canUpdateContext, canSendMessages, tier };
|
||
}, [app]);
|
||
}
|
||
```
|
||
|
||
### Add to MCPAppContext
|
||
|
||
```typescript
|
||
// In MCPAppContext.tsx — extend the context value:
|
||
|
||
export interface MCPAppContextValue {
|
||
// ... existing fields ...
|
||
|
||
/** Host capability info, available after connection */
|
||
capabilities: HostCapabilityInfo;
|
||
|
||
/** Safe tool call: uses callServerTool if available,
|
||
falls back to updateModelContext, or no-ops */
|
||
safeCallTool: (
|
||
toolName: string,
|
||
args: Record<string, any>,
|
||
options?: { localOnly?: boolean }
|
||
) => Promise<CallToolResult | null>;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 2. The Three-Tier Model
|
||
|
||
### Tier 1: Static (No host capabilities)
|
||
- Pure data visualization — charts, tables, badges, timelines
|
||
- All data comes from the initial `ontoolresult` payload
|
||
- No server communication, no context updates
|
||
- **Components work as-is:** BarChart, PieChart, StatusBadge, DataTable, Timeline, etc.
|
||
|
||
### Tier 2: Context-Synced (`updateModelContext` available, no `serverTools`)
|
||
- Local interactivity works (drag, edit, toggle, form fills)
|
||
- User actions update LOCAL state immediately
|
||
- State changes are synced to the LLM via `updateModelContext`
|
||
- The LLM can then decide to call tools itself on the next turn
|
||
- **Key pattern:** User drags a Kanban card → local state updates → `updateModelContext` tells the LLM what happened → LLM calls `move_opportunity` on its own
|
||
|
||
### Tier 3: Full Interactive (`serverTools` available)
|
||
- Everything works as currently designed
|
||
- Components call `callServerTool` directly for real-time server mutations
|
||
- Optimistic UI with server confirmation
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────┐
|
||
│ TIER 3: FULL │
|
||
│ callServerTool ✓ updateModelContext ✓ │
|
||
│ Direct server mutations, optimistic UI │
|
||
│ ┌─────────────────────────────────────────────┐ │
|
||
│ │ TIER 2: CONTEXT-SYNCED │ │
|
||
│ │ callServerTool ✗ updateModelContext ✓ │ │
|
||
│ │ Local state + LLM-informed sync │ │
|
||
│ │ ┌─────────────────────────────────────────┐│ │
|
||
│ │ │ TIER 1: STATIC ││ │
|
||
│ │ │ Read-only data visualization ││ │
|
||
│ │ │ Charts, tables, badges, timelines ││ │
|
||
│ │ └─────────────────────────────────────────┘│ │
|
||
│ └─────────────────────────────────────────────┘ │
|
||
└─────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 3. Component Classification
|
||
|
||
### Always Static (work everywhere)
|
||
These components are pure data display — they render from the UITree data and never call tools:
|
||
|
||
| Component | Category |
|
||
|-----------|----------|
|
||
| BarChart, LineChart, PieChart, SparklineChart, FunnelChart | charts |
|
||
| StatusBadge, ProgressBar, CurrencyDisplay, StarRating | data |
|
||
| DataTable, KeyValueList, TagList, AvatarGroup | data |
|
||
| Timeline, MetricCard, DetailHeader, InfoBlock | data |
|
||
| PageHeader, Section, Card, StatsGrid, SplitLayout | layout |
|
||
| ChatThread, TranscriptView, EmailPreview, ContentPreview | comms |
|
||
| CalendarView, TreeView, FlowDiagram, DuplicateCompare | viz |
|
||
|
||
### Context-Syncable (Tier 2+)
|
||
These work with local state and sync via `updateModelContext`:
|
||
|
||
| Component | Local Behavior | Context Sync |
|
||
|-----------|---------------|--------------|
|
||
| KanbanBoard | Drag cards between columns locally | Report new board state to LLM |
|
||
| EditableField | Edit text inline locally | Report edited values to LLM |
|
||
| FormGroup | Fill form fields locally | Report form state to LLM |
|
||
| SelectDropdown | Select options locally | Report selection to LLM |
|
||
| AmountInput | Adjust amounts locally | Report new amounts to LLM |
|
||
| ChecklistView | Toggle checkboxes locally | Report checklist state to LLM |
|
||
| FilterChips | Toggle filters locally | Report active filters to LLM |
|
||
| TabGroup | Switch tabs locally | Report active tab to LLM |
|
||
| SearchBar | Type search queries locally | Report search to LLM |
|
||
|
||
### Full Interactive Only (Tier 3)
|
||
These **require** server tool calls and degrade gracefully at lower tiers:
|
||
|
||
| Component | Tier 3 | Tier 2 Fallback | Tier 1 Fallback |
|
||
|-----------|--------|-----------------|-----------------|
|
||
| ActionButton | Calls tool | Shows "Ask assistant" hint | Disabled/hidden |
|
||
| AppointmentBooker | Books via tool | Shows form, reports to LLM | Shows read-only schedule |
|
||
| InvoiceBuilder | Creates via tool | Builds locally, reports to LLM | Shows existing invoice data |
|
||
| OpportunityEditor | Saves via tool | Edits locally, reports to LLM | Shows read-only data |
|
||
| ContactPicker | Searches via tool | Shows static options list | Shows read-only display |
|
||
|
||
---
|
||
|
||
## 4. Concrete Implementation
|
||
|
||
### 4a. `safeCallTool` — The Universal Action Handler
|
||
|
||
```typescript
|
||
// In MCPAppProvider
|
||
|
||
const safeCallTool = useCallback(
|
||
async (
|
||
toolName: string,
|
||
args: Record<string, any>,
|
||
options?: { localOnly?: boolean }
|
||
): Promise<CallToolResult | null> => {
|
||
// Tier 3: Full tool call
|
||
if (capabilities.canCallTools && !options?.localOnly) {
|
||
return callTool(toolName, args);
|
||
}
|
||
|
||
// Tier 2: Inform LLM via updateModelContext
|
||
if (capabilities.canUpdateContext && app) {
|
||
const markdown = [
|
||
`---`,
|
||
`action: ${toolName}`,
|
||
`timestamp: ${new Date().toISOString()}`,
|
||
...Object.entries(args).map(([k, v]) => `${k}: ${JSON.stringify(v)}`),
|
||
`---`,
|
||
`User performed action "${toolName}" in the UI.`,
|
||
`Please execute the corresponding server-side operation.`,
|
||
].join("\n");
|
||
|
||
await app.updateModelContext({
|
||
content: [{ type: "text", text: markdown }],
|
||
});
|
||
|
||
// Return a synthetic "pending" result
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: `Action "${toolName}" reported to assistant. It will be processed on the next turn.`,
|
||
},
|
||
],
|
||
} as CallToolResult;
|
||
}
|
||
|
||
// Tier 1: No-op, return null
|
||
console.warn(`[MCPApp] Cannot execute ${toolName}: no host support`);
|
||
return null;
|
||
},
|
||
[capabilities, callTool, app]
|
||
);
|
||
```
|
||
|
||
### 4b. `<InteractiveGate>` — Tier-Aware Wrapper
|
||
|
||
```tsx
|
||
// components/shared/InteractiveGate.tsx
|
||
|
||
import React from "react";
|
||
import { useHostCapabilities, type InteractionTier } from "../../hooks/useHostCapabilities.js";
|
||
|
||
interface InteractiveGateProps {
|
||
/** Minimum tier required to show children */
|
||
requires: InteractionTier;
|
||
/** What to render if the tier isn't met */
|
||
fallback?: React.ReactNode;
|
||
/** If true, hide entirely instead of showing fallback */
|
||
hideOnUnsupported?: boolean;
|
||
children: React.ReactNode;
|
||
}
|
||
|
||
const tierLevel: Record<InteractionTier, number> = {
|
||
static: 0,
|
||
"context-synced": 1,
|
||
full: 2,
|
||
};
|
||
|
||
export const InteractiveGate: React.FC<InteractiveGateProps> = ({
|
||
requires,
|
||
fallback,
|
||
hideOnUnsupported = false,
|
||
children,
|
||
}) => {
|
||
const { tier } = useHostCapabilities();
|
||
|
||
if (tierLevel[tier] >= tierLevel[requires]) {
|
||
return <>{children}</>;
|
||
}
|
||
|
||
if (hideOnUnsupported) return null;
|
||
|
||
if (fallback) return <>{fallback}</>;
|
||
|
||
// Default fallback: subtle message
|
||
return (
|
||
<div className="interactive-unavailable" role="note">
|
||
<span className="interactive-unavailable-icon">ℹ️</span>
|
||
<span>This feature requires a supported host. Ask the assistant to perform this action.</span>
|
||
</div>
|
||
);
|
||
};
|
||
```
|
||
|
||
### 4c. Refactored `ActionButton` with Fallback
|
||
|
||
```tsx
|
||
// components/shared/ActionButton.tsx (revised)
|
||
|
||
export const ActionButton: React.FC<ActionButtonProps> = ({
|
||
label,
|
||
variant = "secondary",
|
||
size = "md",
|
||
disabled,
|
||
toolName,
|
||
toolArgs,
|
||
}) => {
|
||
const { safeCallTool } = useMCPApp();
|
||
const { tier, canCallTools, canSendMessages } = useHostCapabilities();
|
||
const [loading, setLoading] = useState(false);
|
||
const [pendingMessage, setPendingMessage] = useState<string | null>(null);
|
||
|
||
const handleClick = useCallback(async () => {
|
||
if (!toolName || loading || disabled) return;
|
||
|
||
setLoading(true);
|
||
setPendingMessage(null);
|
||
try {
|
||
const result = await safeCallTool(toolName, toolArgs || {});
|
||
|
||
// Tier 2: show "reported to assistant" feedback
|
||
if (!canCallTools && result) {
|
||
setPendingMessage("Reported to assistant");
|
||
setTimeout(() => setPendingMessage(null), 3000);
|
||
}
|
||
} catch {
|
||
// handled by context
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [toolName, toolArgs, loading, disabled, safeCallTool, canCallTools]);
|
||
|
||
// Tier 1 with no context sync: show disabled with hint
|
||
if (tier === "static" && toolName) {
|
||
return (
|
||
<button className={`btn ${vCls} ${sCls} btn-unsupported`} disabled title="Ask the assistant to perform this action">
|
||
{label}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<button className={`btn ${vCls} ${sCls}`} disabled={disabled || loading} onClick={handleClick}>
|
||
{loading && <span className="loading-spinner" style={{ width: 14, height: 14, marginRight: 6, borderWidth: 2, display: "inline-block" }} />}
|
||
{label}
|
||
{pendingMessage && <span className="btn-pending-badge">{pendingMessage}</span>}
|
||
</button>
|
||
);
|
||
};
|
||
```
|
||
|
||
### 4d. Refactored `KanbanBoard` — Local-First + Context Sync
|
||
|
||
The KanbanBoard already uses optimistic local state. The only change: fall back to `updateModelContext` when `moveTool` can't be called directly.
|
||
|
||
```tsx
|
||
// In KanbanBoard onDrop handler (revised):
|
||
|
||
const onDrop = useCallback(async (e: React.DragEvent, toStageId: string) => {
|
||
e.preventDefault();
|
||
setDropTargetStage(null);
|
||
|
||
const drag = dragRef.current;
|
||
if (!drag) return;
|
||
const { cardId, fromStageId } = drag;
|
||
dragRef.current = null;
|
||
setDraggingCardId(null);
|
||
if (fromStageId === toStageId) return;
|
||
|
||
// 1. Optimistic local update (same as before)
|
||
let movedCard: KanbanCard | undefined;
|
||
const prevColumns = columns;
|
||
setColumns(prev => { /* ... same optimistic logic ... */ });
|
||
|
||
// 2. Use safeCallTool — works at Tier 2 AND Tier 3
|
||
if (moveTool) {
|
||
try {
|
||
const result = await safeCallTool(moveTool, {
|
||
opportunityId: cardId,
|
||
pipelineStageId: toStageId,
|
||
});
|
||
|
||
// Tier 2: result is a "reported" message — no revert needed
|
||
// Local state IS the source of truth until LLM processes it
|
||
if (!capabilities.canCallTools && result) {
|
||
// Also report the full board state so LLM has context
|
||
await reportBoardState();
|
||
}
|
||
} catch (err) {
|
||
// Only revert if we attempted a real tool call (Tier 3)
|
||
if (capabilities.canCallTools) {
|
||
console.error("KanbanBoard: move failed, reverting", err);
|
||
setColumns(prevColumns);
|
||
}
|
||
}
|
||
}
|
||
}, [columns, moveTool, safeCallTool, capabilities]);
|
||
|
||
// Report full board state to LLM for context
|
||
const reportBoardState = useCallback(async () => {
|
||
if (!app || !capabilities.canUpdateContext) return;
|
||
|
||
const summary = columns.map(col =>
|
||
`**${col.title}** (${col.cards?.length ?? 0}):\n` +
|
||
(col.cards || []).map(c => ` - ${c.title} (${c.value || 'no value'})`).join('\n')
|
||
).join('\n\n');
|
||
|
||
await app.updateModelContext({
|
||
content: [{
|
||
type: "text",
|
||
text: `---\ncomponent: kanban-board\nupdated: ${new Date().toISOString()}\n---\nCurrent pipeline state:\n\n${summary}`,
|
||
}],
|
||
});
|
||
}, [app, capabilities, columns]);
|
||
```
|
||
|
||
### 4e. Refactored `EditableField` — Works at All Tiers
|
||
|
||
```tsx
|
||
// In EditableField (revised handleSave):
|
||
|
||
const handleSave = async () => {
|
||
setIsEditing(false);
|
||
if (editValue === value) return;
|
||
|
||
if (saveTool) {
|
||
const result = await safeCallTool(saveTool, {
|
||
...saveArgs,
|
||
value: editValue,
|
||
});
|
||
|
||
// Tier 2: field stays locally updated; LLM is informed
|
||
// Tier 3: server confirms; UI may refresh via ontoolresult
|
||
// Tier 1: nothing happens — but user sees their edit locally
|
||
|
||
if (!result && tier === "static") {
|
||
// Show hint that the change is display-only
|
||
setLocalOnlyHint(true);
|
||
setTimeout(() => setLocalOnlyHint(false), 3000);
|
||
}
|
||
}
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## 5. Alternative Interaction: `updateModelContext` as Primary Channel
|
||
|
||
For hosts that support `updateModelContext` but not `serverTools`, we can use a **declarative intent** pattern instead of imperative tool calls:
|
||
|
||
```typescript
|
||
// hooks/useReportInteraction.ts
|
||
|
||
export function useReportInteraction() {
|
||
const { app } = useMCPApp();
|
||
const { canUpdateContext } = useHostCapabilities();
|
||
|
||
return useCallback(
|
||
async (interaction: {
|
||
component: string;
|
||
action: string;
|
||
data: Record<string, any>;
|
||
suggestion?: string; // What we suggest the LLM should do
|
||
}) => {
|
||
if (!canUpdateContext || !app) return;
|
||
|
||
const text = [
|
||
`---`,
|
||
`component: ${interaction.component}`,
|
||
`action: ${interaction.action}`,
|
||
`timestamp: ${new Date().toISOString()}`,
|
||
...Object.entries(interaction.data).map(([k, v]) => `${k}: ${JSON.stringify(v)}`),
|
||
`---`,
|
||
interaction.suggestion || `User performed "${interaction.action}" in ${interaction.component}.`,
|
||
].join("\n");
|
||
|
||
await app.updateModelContext({
|
||
content: [{ type: "text", text }],
|
||
});
|
||
},
|
||
[app, canUpdateContext]
|
||
);
|
||
}
|
||
```
|
||
|
||
**Usage in any component:**
|
||
|
||
```tsx
|
||
const reportInteraction = useReportInteraction();
|
||
|
||
// In a form submit handler:
|
||
await reportInteraction({
|
||
component: "AppointmentBooker",
|
||
action: "book_appointment",
|
||
data: { contactId, calendarId, startTime, endTime },
|
||
suggestion: "User wants to book this appointment. Please call create_appointment with these details.",
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## 6. Server-Side: Dual-Mode Tool Registration
|
||
|
||
The MCP server should register tools that work both ways:
|
||
|
||
```typescript
|
||
// Server-side: Register tool as visible to both model and app
|
||
registerAppTool(server, "move_opportunity", {
|
||
description: "Move an opportunity to a different pipeline stage",
|
||
inputSchema: {
|
||
opportunityId: z.string(),
|
||
pipelineStageId: z.string(),
|
||
},
|
||
_meta: {
|
||
ui: {
|
||
resourceUri: "ui://ghl/pipeline-view",
|
||
visibility: ["model", "app"], // ← Both can call it
|
||
},
|
||
},
|
||
}, handler);
|
||
```
|
||
|
||
With `visibility: ["model", "app"]`:
|
||
- **Tier 3 hosts:** App calls it directly via `callServerTool`
|
||
- **Tier 2 hosts:** App reports intent via `updateModelContext`, LLM sees the tool in its tool list and calls it on the next turn
|
||
- **Tier 1 hosts:** Tool still works as text-only through normal MCP
|
||
|
||
---
|
||
|
||
## 7. `window.__MCP_APP_DATA__` Pre-Injection (Tier 0)
|
||
|
||
The existing `getPreInjectedTree()` in App.tsx already supports a non-MCP path. For environments where the iframe loads but the ext-apps SDK never connects:
|
||
|
||
```typescript
|
||
// App.tsx already handles this:
|
||
useEffect(() => {
|
||
const preInjected = getPreInjectedTree();
|
||
if (preInjected && !uiTree) {
|
||
setUITree(preInjected);
|
||
}
|
||
}, []);
|
||
```
|
||
|
||
This serves as **Tier 0**: pure server-side rendering. The server injects the UITree directly into the HTML. No SDK connection needed. All components render in static mode.
|
||
|
||
---
|
||
|
||
## 8. CSS for Degraded States
|
||
|
||
```css
|
||
/* styles/fallback.css */
|
||
|
||
/* Unsupported interactive elements */
|
||
.btn-unsupported {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
position: relative;
|
||
}
|
||
.btn-unsupported::after {
|
||
content: "↗";
|
||
font-size: 0.7em;
|
||
margin-left: 4px;
|
||
opacity: 0.5;
|
||
}
|
||
|
||
/* "Reported to assistant" badge */
|
||
.btn-pending-badge {
|
||
margin-left: 8px;
|
||
font-size: 0.75em;
|
||
color: var(--color-text-info, #3b82f6);
|
||
animation: fadeIn 0.2s ease-in;
|
||
}
|
||
|
||
/* Unavailable feature hint */
|
||
.interactive-unavailable {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 12px;
|
||
border-radius: var(--border-radius-md, 6px);
|
||
background: var(--color-background-info, #eff6ff);
|
||
color: var(--color-text-secondary, #6b7280);
|
||
font-size: 0.85em;
|
||
}
|
||
|
||
/* Local-only edit hint */
|
||
.ef-local-hint {
|
||
font-size: 0.75em;
|
||
color: var(--color-text-warning, #f59e0b);
|
||
margin-top: 2px;
|
||
}
|
||
|
||
/* Editable fields in static mode: remove edit affordance */
|
||
[data-tier="static"] .ef-edit-icon {
|
||
display: none;
|
||
}
|
||
[data-tier="static"] .ef-display {
|
||
cursor: default;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 9. Decision Matrix
|
||
|
||
| Scenario | Detection | Behavior |
|
||
|----------|-----------|----------|
|
||
| Claude Desktop (full support) | `serverTools` ✓ | Tier 3: all features work |
|
||
| Host with context-only support | `updateModelContext` ✓, `serverTools` ✗ | Tier 2: local + LLM sync |
|
||
| Minimal host / basic iframe | No capabilities | Tier 1: static display |
|
||
| Pre-injected `__MCP_APP_DATA__` | No SDK connection | Tier 0: SSR static |
|
||
| `callServerTool` fails at runtime | Error caught | Downgrade to Tier 2 dynamically |
|
||
|
||
---
|
||
|
||
## 10. Summary: What to Implement
|
||
|
||
### Phase 1 (Essential)
|
||
1. **`useHostCapabilities` hook** — reads `app.getHostCapabilities()`, computes tier
|
||
2. **`safeCallTool` in MCPAppContext** — wraps callTool with fallback to updateModelContext
|
||
3. **Update `ActionButton`** — use safeCallTool, show disabled state at Tier 1
|
||
4. **Update `KanbanBoard`** — already local-first, just wire up safeCallTool + reportBoardState
|
||
5. **`fallback.css`** — styles for degraded states
|
||
|
||
### Phase 2 (Full Coverage)
|
||
6. **`useReportInteraction` hook** — standard way to inform LLM of UI actions
|
||
7. **`<InteractiveGate>` component** — declarative tier gating in templates
|
||
8. **Update all interactive components** — EditableField, FormGroup, AppointmentBooker, etc.
|
||
9. **Runtime downgrade** — if a Tier 3 call fails, auto-downgrade to Tier 2
|
||
|
||
### Phase 3 (Polish)
|
||
10. **Toast notifications** for tier-specific feedback ("Action sent to assistant")
|
||
11. **`data-tier` attribute** on root for CSS-only degradation
|
||
12. **Testing matrix** — automated tests per tier per component
|
||
|
||
---
|
||
|
||
## Key Insight
|
||
|
||
The ext-apps spec was **designed** for this: *"UI is a progressive enhancement, not a requirement."* Our architecture mirrors this philosophy — every component starts as a static data display and progressively gains interactivity based on what the host reports it can do. The KanbanBoard's existing optimistic-update pattern is already the correct Tier 2 pattern; we just need to formalize it and apply it consistently.
|