diff --git a/README.md b/README.md
new file mode 100644
index 0000000..946ce60
--- /dev/null
+++ b/README.md
@@ -0,0 +1,381 @@
+# Agent UI
+
+Dynamic canvas interface for Claude Code interaction via MCP (Model Context Protocol).
+
+## Overview
+
+Agent UI provides a visual canvas where Claude Code can render dynamic Vue 3 components, HTML content, and interactive UIs in real-time. It bridges the gap between CLI-based AI assistance and rich visual interfaces.
+
+## Architecture
+
+```
+agent-ui/
+├── frontend/ # Vue 3 + Vite + Pinia
+│ └── src/
+│ ├── components/ # Canvas, Toolbar, StatusBar
+│ ├── services/ # Dynamic components system
+│ ├── stores/ # Pinia state management
+│ └── styles/ # Global CSS
+├── server/ # Bun HTTP API + SQLite
+└── .mcp.json # MCP server configuration
+```
+
+## Quick Start
+
+```bash
+# Install dependencies
+npm install
+
+# Start development (frontend + server)
+npm start
+```
+
+- **Frontend**: http://localhost:4100
+- **API Server**: http://localhost:4101
+- **WebMCP**: ws://localhost:4102
+
+## Connecting to Claude Code
+
+1. Start the development server: `npm start`
+2. In Claude Code, run `/mcp` to reconnect
+3. Click the widget (bottom-right) in the browser
+4. Paste the token from Claude Code
+
+---
+
+# Dynamic Canvas
+
+The dynamic canvas system allows Claude Code to render fully-featured Vue 3 components at runtime.
+
+## MCP Tools
+
+### `render_vue_component`
+
+Renders a Vue 3 component directly in the canvas.
+
+```javascript
+{
+ id: "my-counter", // Unique component ID
+ name: "MyCounter", // Component name
+ template: `
...
`, // Vue template
+ setup: `...`, // Composition API setup code
+ style: `.class { ... }`, // Scoped CSS
+ props: ["title"], // Props list
+ imports: ["ref", "computed"], // Vue imports needed
+ componentProps: { title: "Hello" }, // Props values
+ mode: "replace" | "append" // Render mode
+}
+```
+
+### `save_vue_component`
+
+Saves a component to SQLite for later reuse.
+
+```javascript
+{
+ id: "my-component", // Optional, auto-generated if empty
+ name: "MyComponent",
+ template: `...`,
+ setup: `...`,
+ style: `...`,
+ props: [...],
+ imports: [...]
+}
+```
+
+### `load_vue_component`
+
+Loads and renders a previously saved component.
+
+```javascript
+{
+ id: "my-component",
+ componentProps: { ... },
+ mode: "replace" | "append"
+}
+```
+
+### `list_vue_components`
+
+Lists all saved components in the database.
+
+### `delete_vue_component`
+
+Deletes a component from the database.
+
+```javascript
+{
+ id: "my-component"
+}
+```
+
+### `render_html`
+
+Renders raw HTML with script/style support (legacy, simpler option).
+
+```javascript
+{
+ html: "...
",
+ mode: "replace" | "append" | "prepend"
+}
+```
+
+---
+
+## Setup Function API
+
+Inside the `setup` string, components have access to:
+
+### Vue Reactivity (via `imports`)
+
+```javascript
+ref, reactive, computed, watch, watchEffect,
+onMounted, onUnmounted, nextTick, provide, inject, h
+```
+
+### Props & Context
+
+```javascript
+props // Component props
+ctx // { emit, attrs, slots, expose }
+```
+
+### Event Bus
+
+```javascript
+$emit(event, ...args) // Emit global event
+$on(event, callback) // Listen (returns unsubscribe fn)
+$once(event, callback) // Listen once
+$off(event, callback) // Stop listening
+```
+
+### Utilities
+
+```javascript
+$fetch(url, options) // Native fetch
+$nextTick(callback) // Vue nextTick
+useCanvasStore() // Pinia store (shared with main app)
+```
+
+### Components API
+
+```javascript
+$components.load(id) // Load component from DB
+$components.list() // List all components
+$components.save(comp) // Save component to DB
+```
+
+---
+
+## Example: Interactive Counter
+
+```javascript
+// MCP call: render_vue_component
+{
+ id: "counter",
+ name: "Counter",
+ template: `
+
+
{{ title }}
+
Count: {{ count }}
+
+
+
Double: {{ doubled }}
+
+ `,
+ setup: `
+ const count = ref(0);
+ const doubled = computed(() => count.value * 2);
+
+ const increment = () => count.value++;
+ const decrement = () => count.value--;
+
+ onMounted(() => {
+ console.log('Counter mounted!');
+ });
+
+ return { count, doubled, increment, decrement, title: props.title };
+ `,
+ style: `
+ .counter { padding: 20px; background: #1e1e28; border-radius: 8px; }
+ .counter button { margin: 0 5px; padding: 8px 16px; }
+ `,
+ props: ["title"],
+ imports: ["ref", "computed", "onMounted"],
+ componentProps: { title: "My Counter" }
+}
+```
+
+---
+
+## Example: Async Data Fetching
+
+```javascript
+{
+ id: "data-loader",
+ name: "DataLoader",
+ template: `
+
+
Loading...
+
{{ data }}
+
+ `,
+ setup: `
+ const loading = ref(true);
+ const data = ref(null);
+
+ // Async setup - uses Suspense internally
+ const res = await $fetch('http://localhost:4101/api/health');
+ data.value = await res.json();
+ loading.value = false;
+
+ return { loading, data };
+ `,
+ imports: ["ref"]
+}
+```
+
+---
+
+## Example: Event Communication
+
+```javascript
+// Component A - Emitter
+{
+ id: "emitter",
+ name: "Emitter",
+ template: ``,
+ setup: `
+ const send = () => {
+ $emit('my-event', { message: 'Hello!', timestamp: Date.now() });
+ };
+ return { send };
+ `
+}
+
+// Component B - Listener
+{
+ id: "listener",
+ name: "Listener",
+ template: `Last event: {{ lastEvent }}
`,
+ setup: `
+ const lastEvent = ref('none');
+
+ $on('my-event', (data) => {
+ lastEvent.value = JSON.stringify(data);
+ });
+
+ return { lastEvent };
+ `,
+ imports: ["ref"]
+}
+```
+
+---
+
+## Features
+
+### CSS Scoping
+
+Styles are automatically scoped to prevent collisions:
+
+```css
+/* You write: */
+.btn { background: red; }
+
+/* Generated: */
+#canvas-content [data-v-abc123] .btn { background: red; }
+```
+
+### Async Setup
+
+Components can use `await` directly in setup - automatically wrapped in Suspense:
+
+```javascript
+setup: `
+ const data = await $fetch('/api/data');
+ return { data };
+`
+```
+
+### Shared State
+
+Components share Pinia stores with the main app:
+
+```javascript
+setup: `
+ const store = useCanvasStore();
+ console.log(store.isConnected); // Same state as main app
+`
+```
+
+### No App Overhead
+
+Components are rendered using `render()` + `createVNode()` instead of creating separate Vue apps, sharing the main app's context efficiently.
+
+---
+
+## Technical Notes
+
+### Vite Configuration
+
+Runtime template compilation requires this alias in `vite.config.ts`:
+
+```typescript
+resolve: {
+ alias: {
+ 'vue': 'vue/dist/vue.esm-bundler.js'
+ }
+}
+```
+
+### How It Works
+
+1. **MCP Tool Call** → Claude Code sends component definition via WebSocket
+2. **Build Component** → `buildComponent()` creates Vue component with dynamic `setup` function using `new Function()`
+3. **CSS Scoping** → Styles are transformed: `.btn` → `#canvas-content [data-v-xxx] .btn`
+4. **Render** → `createVNode()` + `render()` mounts component without creating new Vue app
+5. **Context Inheritance** → Component inherits `appContext` from main app (Pinia, directives, etc.)
+
+### File Structure
+
+```
+frontend/src/services/dynamicComponents.ts (~300 lines)
+├── EventBus # Inter-component communication
+├── CSS Scoping # Style isolation
+├── Components API # CRUD with server
+├── Vue Helpers # What setup() receives
+├── buildComponent # Create component from definition
+└── renderInlineComponent # Mount to DOM
+```
+
+---
+
+## API Endpoints
+
+| Endpoint | Method | Description |
+|----------|--------|-------------|
+| `/api/health` | GET | Health check |
+| `/api/history` | GET | Get tool execution history |
+| `/api/history` | POST | Log tool execution |
+| `/api/history` | DELETE | Clear history |
+| `/api/components` | GET | List saved components |
+| `/api/components` | POST | Save component |
+| `/api/components/:id` | GET | Get component by ID |
+| `/api/components/:id` | DELETE | Delete component |
+| `/api/config` | GET/POST | Key-value config storage |
+
+---
+
+## Tech Stack
+
+- **Frontend**: Vue 3, Vite, Pinia, TypeScript
+- **Server**: Bun, SQLite
+- **MCP**: @nucleoriofrio/webmcp
+- **PWA**: vite-plugin-pwa
+
+---
+
+## License
+
+MIT
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index d0938b1..3e7381b 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1405,7 +1405,7 @@
},
"node_modules/@nucleoriofrio/webmcp": {
"version": "0.2.0",
- "resolved": "git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git#480d4d618e04a679d249609089df72bc4bb41643",
+ "resolved": "git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git#d5a912be97dbcf49adf5dc055fd437d5653ef5d0",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.6.1",
diff --git a/frontend/src/components/Canvas.vue b/frontend/src/components/Canvas.vue
index 8ccb16a..dcc4d47 100644
--- a/frontend/src/components/Canvas.vue
+++ b/frontend/src/components/Canvas.vue
@@ -1,6 +1,11 @@
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index dcc6d11..e7b9b07 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -3,6 +3,13 @@ import { createPinia } from 'pinia'
import App from './App.vue'
import './styles/main.css'
+const pinia = createPinia()
const app = createApp(App)
-app.use(createPinia())
+
+app.use(pinia)
+
+// Exponer contexto global para componentes dinámicos
+;(window as any).__vueApp = app
+;(window as any).__pinia = pinia
+
app.mount('#app')
diff --git a/frontend/src/services/dynamicComponents.ts b/frontend/src/services/dynamicComponents.ts
new file mode 100644
index 0000000..a9d0baf
--- /dev/null
+++ b/frontend/src/services/dynamicComponents.ts
@@ -0,0 +1,304 @@
+import {
+ ref,
+ reactive,
+ computed,
+ watch,
+ watchEffect,
+ onMounted,
+ onUnmounted,
+ nextTick,
+ provide,
+ inject,
+ h,
+ render,
+ createVNode,
+ Suspense,
+ type App,
+ type Component
+} from 'vue'
+import { setActivePinia, type Pinia } from 'pinia'
+import { useCanvasStore } from '../stores/canvas'
+
+const API_URL = 'http://localhost:4101'
+
+// Tipos
+export interface VueComponentDefinition {
+ id: string
+ name: string
+ template: string
+ setup?: string
+ style?: string
+ props?: string[]
+ imports?: string[]
+}
+
+// ============================================
+// EVENT BUS
+// ============================================
+
+type EventCallback = (...args: any[]) => void
+
+class EventBus {
+ private events: Map> = new Map()
+
+ on(event: string, callback: EventCallback) {
+ if (!this.events.has(event)) {
+ this.events.set(event, new Set())
+ }
+ this.events.get(event)!.add(callback)
+ return () => this.off(event, callback)
+ }
+
+ off(event: string, callback: EventCallback) {
+ this.events.get(event)?.delete(callback)
+ }
+
+ emit(event: string, ...args: any[]) {
+ this.events.get(event)?.forEach(cb => cb(...args))
+ }
+
+ once(event: string, callback: EventCallback) {
+ const wrapper = (...args: any[]) => {
+ callback(...args)
+ this.off(event, wrapper)
+ }
+ this.on(event, wrapper)
+ }
+}
+
+export const eventBus = new EventBus()
+;(window as any).__eventBus = eventBus
+
+// ============================================
+// CSS SCOPING
+// ============================================
+
+function generateScopeId(id: string): string {
+ return 'v-' + id.replace(/[^a-zA-Z0-9]/g, '').slice(0, 8)
+}
+
+function scopeCSS(css: string, scopeId: string): string {
+ const scopePrefix = `#canvas-content [data-${scopeId}]`
+
+ return css.replace(
+ /([^{}]+)\{([^{}]*)\}/g,
+ (_match, selectors: string, rules: string) => {
+ const scopedSelectors = selectors
+ .split(',')
+ .map((selector: string) => {
+ selector = selector.trim()
+ if (!selector || selector.startsWith('@')) return selector
+ return `${scopePrefix} ${selector}`
+ })
+ .join(', ')
+
+ return `${scopedSelectors} {${rules}}`
+ }
+ )
+}
+
+function injectScopedStyle(css: string, scopeId: string, componentId: string): void {
+ const styleId = `style-${componentId}`
+ let styleEl = document.getElementById(styleId)
+
+ if (!styleEl) {
+ styleEl = document.createElement('style')
+ styleEl.id = styleId
+ document.head.appendChild(styleEl)
+ }
+
+ styleEl.textContent = scopeCSS(css, scopeId)
+}
+
+// ============================================
+// COMPONENTS API
+// ============================================
+
+export const componentsApi = {
+ async getAll(): Promise {
+ const res = await fetch(`${API_URL}/api/components`)
+ const data = await res.json()
+ return data.map((row: any) => ({
+ ...row,
+ props: JSON.parse(row.props || '[]'),
+ imports: JSON.parse(row.imports || '[]')
+ }))
+ },
+
+ async getById(id: string): Promise {
+ const res = await fetch(`${API_URL}/api/components/${id}`)
+ if (!res.ok) return null
+ const row = await res.json()
+ return {
+ ...row,
+ props: JSON.parse(row.props || '[]'),
+ imports: JSON.parse(row.imports || '[]')
+ }
+ },
+
+ async save(component: VueComponentDefinition): Promise<{ success: boolean; id: string }> {
+ const res = await fetch(`${API_URL}/api/components`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(component)
+ })
+ return res.json()
+ },
+
+ async delete(id: string): Promise<{ success: boolean }> {
+ const res = await fetch(`${API_URL}/api/components/${id}`, { method: 'DELETE' })
+ return res.json()
+ },
+
+ async deleteAll(): Promise<{ success: boolean }> {
+ const res = await fetch(`${API_URL}/api/components`, { method: 'DELETE' })
+ return res.json()
+ }
+}
+
+// ============================================
+// VUE EXPORTS & HELPERS
+// ============================================
+
+const vueExports = {
+ ref, reactive, computed, watch, watchEffect,
+ onMounted, onUnmounted, nextTick, provide, inject, h
+}
+
+function getCanvasStore() {
+ const globalPinia = (window as any).__pinia as Pinia | undefined
+ if (globalPinia) setActivePinia(globalPinia)
+ return useCanvasStore()
+}
+
+const dynamicHelpers = {
+ $emit: (event: string, ...args: any[]) => eventBus.emit(event, ...args),
+ $on: (event: string, cb: EventCallback) => eventBus.on(event, cb),
+ $once: (event: string, cb: EventCallback) => eventBus.once(event, cb),
+ $off: (event: string, cb: EventCallback) => eventBus.off(event, cb),
+ $fetch: fetch.bind(window),
+ $components: {
+ load: (id: string) => componentsApi.getById(id),
+ list: () => componentsApi.getAll(),
+ save: (comp: VueComponentDefinition) => componentsApi.save(comp),
+ },
+ useCanvasStore: getCanvasStore,
+ $nextTick: nextTick,
+}
+
+// ============================================
+// BUILD COMPONENT
+// ============================================
+
+export function buildComponent(definition: VueComponentDefinition): Component {
+ const imports = definition.imports || ['ref', 'reactive', 'computed']
+
+ const vueImports: Record = {}
+ imports.forEach(i => {
+ if (i in vueExports) vueImports[i] = (vueExports as any)[i]
+ })
+
+ const isAsync = definition.setup ? /\bawait\b/.test(definition.setup) : false
+
+ let setupFn: Function | null = null
+ if (definition.setup) {
+ try {
+ const fnBody = isAsync
+ ? `return (async () => { ${definition.setup} })()`
+ : definition.setup
+
+ setupFn = new Function(
+ ...Object.keys(vueImports),
+ 'props',
+ 'ctx',
+ ...Object.keys(dynamicHelpers),
+ fnBody
+ )
+ } catch (e) {
+ console.error('[DynamicComponent] Error parsing setup:', e)
+ }
+ }
+
+ const componentDef: Component = {
+ name: definition.name,
+ props: definition.props || [],
+ template: definition.template
+ }
+
+ const executeSetup = (props: any, ctx: any) => {
+ if (!setupFn) return {}
+ return setupFn(
+ ...Object.values(vueImports),
+ props,
+ ctx,
+ ...Object.values(dynamicHelpers)
+ )
+ }
+
+ if (isAsync) {
+ componentDef.async = true
+ componentDef.setup = async (props: any, ctx: any) => await executeSetup(props, ctx)
+ } else {
+ componentDef.setup = executeSetup
+ }
+
+ return componentDef
+}
+
+// ============================================
+// RENDER COMPONENT
+// ============================================
+
+const renderedContainers: Map = new Map()
+
+export function renderInlineComponent(
+ definition: VueComponentDefinition,
+ target: HTMLElement,
+ props: Record = {},
+ append: boolean = false
+): { unmount: () => void } {
+ const scopeId = generateScopeId(definition.id)
+
+ const container = document.createElement('div')
+ container.id = `inline-${definition.id}`
+ container.className = 'dynamic-component-wrapper'
+ container.setAttribute(`data-${scopeId}`, '')
+
+ if (append) {
+ target.appendChild(container)
+ } else {
+ const oldContainer = renderedContainers.get(definition.id)
+ if (oldContainer) render(null, oldContainer)
+ target.innerHTML = ''
+ target.appendChild(container)
+ }
+
+ if (definition.style) {
+ injectScopedStyle(definition.style, scopeId, definition.id)
+ }
+
+ const isAsync = definition.setup ? /\bawait\b/.test(definition.setup) : false
+ const component = buildComponent(definition)
+
+ const vnode = isAsync
+ ? createVNode(Suspense, null, {
+ default: () => createVNode(component, props),
+ fallback: () => createVNode('div', { class: 'loading' }, 'Loading...')
+ })
+ : createVNode(component, props)
+
+ const mainApp = (window as any).__vueApp as App | undefined
+ if (mainApp?._context) vnode.appContext = mainApp._context
+
+ render(vnode, container)
+ renderedContainers.set(definition.id, container)
+
+ return {
+ unmount: () => {
+ render(null, container)
+ renderedContainers.delete(definition.id)
+ container.remove()
+ document.getElementById(`style-${definition.id}`)?.remove()
+ }
+ }
+}
diff --git a/frontend/src/types/webmcp.d.ts b/frontend/src/types/webmcp.d.ts
new file mode 100644
index 0000000..f3e118d
--- /dev/null
+++ b/frontend/src/types/webmcp.d.ts
@@ -0,0 +1,28 @@
+declare module '@nucleoriofrio/webmcp/src/webmcp.js' {
+ interface WebMCPOptions {
+ color?: string
+ position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'
+ inactivityTimeout?: number
+ }
+
+ interface ToolSchema {
+ type: string
+ properties: Record
+ required?: string[]
+ }
+
+ type ToolHandler = (args: any) => string | Promise
+
+ class WebMCP {
+ constructor(options?: WebMCPOptions)
+ registerTool(name: string, description: string, schema: ToolSchema, handler: ToolHandler): void
+ unregisterTool(name: string): void
+ connect(): Promise
+ disconnect(): void
+ on?(event: 'connected' | 'disconnected' | string, callback: () => void): void
+ off?(event: string, callback: () => void): void
+ isConnected?: boolean
+ }
+
+ export default WebMCP
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 4ecc9e7..5c92c96 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -3,6 +3,12 @@ import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
+ resolve: {
+ alias: {
+ // Habilitar compilación de templates en runtime para componentes dinámicos
+ 'vue': 'vue/dist/vue.esm-bundler.js'
+ }
+ },
plugins: [
vue(),
VitePWA({
diff --git a/server/index.ts b/server/index.ts
index d221087..0e24ac1 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -22,6 +22,21 @@ db.run(`
)
`)
+// Tabla para componentes Vue dinámicos
+db.run(`
+ CREATE TABLE IF NOT EXISTS vue_components (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ template TEXT NOT NULL,
+ setup TEXT,
+ style TEXT,
+ props TEXT,
+ imports TEXT,
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP
+ )
+`)
+
console.log('[DB] SQLite inicializado: agent-ui.db')
// API HTTP solamente - WebSocket lo maneja webmcp
@@ -85,6 +100,57 @@ Bun.serve({
return Response.json({ status: 'ok', timestamp: new Date().toISOString() }, { headers: corsHeaders })
}
+ // API de Componentes Vue
+ if (url.pathname === '/api/components') {
+ if (req.method === 'GET') {
+ const rows = db.query('SELECT * FROM vue_components ORDER BY updated_at DESC').all()
+ return Response.json(rows, { headers: corsHeaders })
+ }
+
+ if (req.method === 'POST') {
+ const body = await req.json()
+ const id = body.id || `comp-${Date.now()}`
+ const stmt = db.prepare(`
+ INSERT OR REPLACE INTO vue_components
+ (id, name, template, setup, style, props, imports, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
+ `)
+ stmt.run(
+ id,
+ body.name,
+ body.template,
+ body.setup || '',
+ body.style || '',
+ JSON.stringify(body.props || []),
+ JSON.stringify(body.imports || [])
+ )
+ return Response.json({ success: true, id }, { headers: corsHeaders })
+ }
+
+ if (req.method === 'DELETE') {
+ db.run('DELETE FROM vue_components')
+ return Response.json({ success: true }, { headers: corsHeaders })
+ }
+ }
+
+ // Obtener componente por ID
+ if (url.pathname.startsWith('/api/components/')) {
+ const id = url.pathname.split('/').pop()
+
+ if (req.method === 'GET') {
+ const row = db.query('SELECT * FROM vue_components WHERE id = ?').get(id)
+ if (!row) {
+ return Response.json({ error: 'Component not found' }, { status: 404, headers: corsHeaders })
+ }
+ return Response.json(row, { headers: corsHeaders })
+ }
+
+ if (req.method === 'DELETE') {
+ db.run('DELETE FROM vue_components WHERE id = ?', [id])
+ return Response.json({ success: true }, { headers: corsHeaders })
+ }
+ }
+
return new Response('Not Found', { status: 404, headers: corsHeaders })
}
})