diff --git a/app/ai/agent_loop.py b/app/ai/agent_loop.py
index f376e94..9feff45 100644
--- a/app/ai/agent_loop.py
+++ b/app/ai/agent_loop.py
@@ -197,6 +197,7 @@ async def run_react_loop(
"tenant_id": str(tenant_id),
"user_id": str(user_id),
"db": db,
+ "agent_name": getattr(agent_definition, "name", "Agent"),
}
# Audit helper — records every tool call in the audit log.
diff --git a/app/ai/miniapp_tools.py b/app/ai/miniapp_tools.py
new file mode 100644
index 0000000..2dcf6dc
--- /dev/null
+++ b/app/ai/miniapp_tools.py
@@ -0,0 +1,127 @@
+"""Core AI agent tools for MiniApp output (Phase M6).
+
+``send_miniapp`` lets an agent embed a MiniApp as an interactive output
+block in its chat room (block_type "miniapp", approval_request precedent
+from agent_loop). Permission is checked fail-closed against the CALLING
+user for the target app's own permission — the tool never widens access.
+"""
+
+from __future__ import annotations
+
+import logging
+import uuid
+from typing import Any
+
+from app.ai.tool_registry import get_tool_registry
+from app.core.permissions import check_permission, resolve_permissions
+from app.plugins.miniapp_registry import get_miniapp_registry
+
+logger = logging.getLogger(__name__)
+
+
+def _get_komm_contract() -> Any | None:
+ """Resolve the kommunikation contract (None when plugin inactive)."""
+ from app.plugins.builtins.contracts import get_contract_registry
+
+ return get_contract_registry().get("kommunikation")
+
+
+async def _send_miniapp_handler(arguments: dict[str, Any], context: dict[str, Any]) -> str:
+ """Send a MiniApp as an output block to the agent's chat room."""
+ app_id = str(arguments.get("app_id") or "")
+ settings = arguments.get("settings") or {}
+ if not isinstance(settings, dict):
+ settings = {}
+
+ app = get_miniapp_registry().get_app(app_id)
+ if app is None:
+ return f"Error: MiniApp '{app_id}' not found"
+
+ db = context.get("db")
+ tenant_id = context.get("tenant_id")
+ user_id = context.get("user_id")
+ if not tenant_id or not user_id or db is None:
+ return "Error: Missing tenant/user context"
+
+ try:
+ tenant_uuid = uuid.UUID(str(tenant_id))
+ user_uuid = uuid.UUID(str(user_id))
+ except (ValueError, TypeError):
+ return "Error: Invalid tenant/user context"
+
+ # Fail-closed permission check against the calling user
+ resolved = await resolve_permissions(db, user_uuid, tenant_uuid)
+ if app.permission and not check_permission(resolved, app.permission):
+ return f"Error: Permission '{app.permission}' required for MiniApp '{app.app_id}'"
+
+ komm = _get_komm_contract()
+ if komm is None:
+ return "Error: Communication plugin not available"
+
+ agent_name = str(context.get("agent_name") or "Agent")
+ conv_id = await komm.find_locked_room_id(
+ db=db,
+ tenant_id=tenant_uuid,
+ plugin_name="automation",
+ title=f"Agent: {agent_name}",
+ )
+ if conv_id is None:
+ return f"Info: No agent chat room found for '{agent_name}' — MiniApp not posted"
+
+ agent_id_raw = context.get("agent_id")
+ try:
+ sender_id = uuid.UUID(str(agent_id_raw)) if agent_id_raw else None
+ except (ValueError, TypeError):
+ sender_id = None
+
+ await komm.send_message(
+ db=db,
+ tenant_id=tenant_uuid,
+ conversation_id=conv_id,
+ sender_id=sender_id,
+ sender_type="agent",
+ content=f"MiniApp: {app.name}",
+ content_format="text",
+ blocks=[
+ {
+ "block_type": "miniapp",
+ "block_data": {"app_id": app_id, "config": settings},
+ "sort_order": 0,
+ }
+ ],
+ )
+ return f"MiniApp '{app_id}' sent to chat"
+
+
+# ─── Registration ───
+
+
+def register_miniapp_tools() -> None:
+ """Register the MiniApp agent tools in the global tool registry."""
+ registry = get_tool_registry()
+ registry.register(
+ name="send_miniapp",
+ description=(
+ "Eine MiniApp als interaktiven Ausgabe-Block in den Agent-Chat senden "
+ "(z.B. ein Widget mit Einstellungen anzeigen). Verfügbare App-IDs "
+ "stehen in /api/v1/miniapps."
+ ),
+ parameters={
+ "type": "object",
+ "properties": {
+ "app_id": {
+ "type": "string",
+ "description": "ID der MiniApp (z.B. recent_contacts, tasks_summary)",
+ },
+ "settings": {
+ "type": "object",
+ "description": "Optionale Einstellungen für die MiniApp-Instanz",
+ },
+ },
+ "required": ["app_id"],
+ },
+ handler=_send_miniapp_handler,
+ plugin_name="system",
+ required_permission=None, # per-app check inside the handler (fail-closed)
+ category="ui",
+ )
diff --git a/app/main.py b/app/main.py
index 9e9eafc..b1e3cd1 100644
--- a/app/main.py
+++ b/app/main.py
@@ -223,6 +223,11 @@ async def lifespan(app: FastAPI):
register_system_miniapps()
+ # Core AI agent tools for MiniApp output (Phase M6)
+ from app.ai.miniapp_tools import register_miniapp_tools
+
+ register_miniapp_tools()
+
# Install discovered builtin plugins and activate only those marked active in DB
from sqlalchemy import select as sa_select
from sqlalchemy.ext.asyncio import async_sessionmaker
diff --git a/frontend/src/__tests__/dashboard/MiniAppWindow.test.tsx b/frontend/src/__tests__/dashboard/MiniAppWindow.test.tsx
new file mode 100644
index 0000000..048027e
--- /dev/null
+++ b/frontend/src/__tests__/dashboard/MiniAppWindow.test.tsx
@@ -0,0 +1,103 @@
+import React from 'react';
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { MiniAppWindowContent } from '@/components/dashboard/MiniAppWindowContent';
+import { openMiniAppWindow } from '@/components/dashboard/openMiniAppWindow';
+import { useWindowStore } from '@/store/windowStore';
+
+/**
+ * M6 — Windows host tests: MiniApps open in floating windows via the
+ * existing window manager (openMiniAppWindow + MiniAppWindowContent).
+ */
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+vi.mock('@/api/miniapps', () => ({
+ useMiniapps: () => ({
+ data: {
+ items: [
+ {
+ app_id: 'recent_contacts', name: 'Recent Contacts', icon: 'Users', description: 'desc',
+ plugin_name: 'contacts', render_schema: {}, permission: '', settings_schema: {},
+ col_span: 2, row_span: 1, hosts: ['chat', 'dashboard', 'window'],
+ component: '@/components/dashboard/RecentContactsWidget', order: 10, builtin: true,
+ },
+ ],
+ total: 1,
+ },
+ isLoading: false,
+ }),
+}));
+
+vi.mock('@/components/dashboard/MiniAppHost', () => ({
+ MiniAppHost: ({ appId }: { appId: string }) => (
+
host:{appId}
+ ),
+}));
+
+const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+
+function renderContent() {
+ return render(
+
+
+
+ );
+}
+
+describe('MiniAppWindowContent', () => {
+ it('renders the MiniApp host with the given app id', () => {
+ renderContent();
+ expect(screen.getByTestId('miniapp-window-recent_contacts')).toBeInTheDocument();
+ expect(screen.getByTestId('miniapp-recent_contacts')).toBeInTheDocument();
+ });
+
+ it('shows the app description when present', () => {
+ renderContent();
+ expect(screen.getByText('desc')).toBeInTheDocument();
+ });
+});
+
+describe('openMiniAppWindow', () => {
+ it('opens a window with miniapp type and props', () => {
+ const spy = vi.fn();
+ // spy on the store's openWindow implementation
+ const state = useWindowStore.getState();
+ const origOpen = state.openWindow;
+ useWindowStore.setState({ openWindow: spy as never });
+
+ openMiniAppWindow({
+ appId: 'recent_contacts',
+ settings: { limit: 3 },
+ component: MiniAppWindowContent as never,
+ });
+
+ expect(spy).toHaveBeenCalledTimes(1);
+ const config = spy.mock.calls[0][0];
+ expect(config.type).toBe('miniapp-recent_contacts');
+ expect(config.title).toBe('recent_contacts');
+ expect(config.componentProps).toEqual({ appId: 'recent_contacts', settings: { limit: 3 } });
+
+ // restore
+ useWindowStore.setState({ openWindow: origOpen as never, windows: [] });
+ });
+
+ it('uses the def name as window title when provided', () => {
+ const spy = vi.fn();
+ const state = useWindowStore.getState();
+ const origOpen = state.openWindow;
+ useWindowStore.setState({ openWindow: spy as never });
+
+ openMiniAppWindow({
+ appId: 'x',
+ def: { name: 'Mein Widget' } as never,
+ component: MiniAppWindowContent as never,
+ });
+
+ expect(spy.mock.calls[0][0].title).toBe('Mein Widget');
+ useWindowStore.setState({ openWindow: origOpen as never, windows: [] });
+ });
+});
diff --git a/frontend/src/components/comm/blocks/MiniAppBlock.tsx b/frontend/src/components/comm/blocks/MiniAppBlock.tsx
index e305a0e..2af1ae1 100644
--- a/frontend/src/components/comm/blocks/MiniAppBlock.tsx
+++ b/frontend/src/components/comm/blocks/MiniAppBlock.tsx
@@ -1,42 +1,36 @@
-import React, { useState, useEffect } from 'react';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
import type { MessageBlock } from '@/store/commStore';
-import { AppWindow, Loader2 } from 'lucide-react';
-import { apiClient } from '@/api/client';
+import { AppWindow, ExternalLink, Loader2 } from 'lucide-react';
+import { useMiniapps, type MiniAppDef } from '@/api/miniapps';
+import { MiniAppWindowContent } from '@/components/dashboard/MiniAppWindowContent';
+import { openMiniAppWindow } from '@/components/dashboard/openMiniAppWindow';
interface MiniAppBlockProps {
block: MessageBlock;
}
-interface MiniAppDef {
- app_id: string;
- name: string;
- icon: string;
- description: string;
- plugin_name: string;
- render_schema: Record;
-}
-
const MiniAppBlock: React.FC = ({ block }) => {
+ const { t } = useTranslation();
const { app_id, config } = block.block_data;
- const [appDef, setAppDef] = useState(null);
- const [loading, setLoading] = useState(false);
+ // M6: universal registry endpoint (carries component/permission fields;
+ // the legacy /comm/miniapps list never did, hiding the window button)
+ const { data: miniappsData, isLoading: loading } = useMiniapps();
+ const appDef: MiniAppDef | null =
+ miniappsData?.items.find((a) => a.app_id === app_id) ?? null;
- useEffect(() => {
- if (!app_id) return;
- setLoading(true);
- apiClient.get('/comm/miniapps')
- .then((res) => {
- const apps: MiniAppDef[] = res.data || [];
- const found = apps.find((a) => a.app_id === app_id);
- setAppDef(found || null);
- })
- .catch(() => setAppDef(null))
- .finally(() => setLoading(false));
- }, [app_id]);
+ const canOpenInWindow = Boolean(appDef?.component);
+ const openInWindow = () =>
+ openMiniAppWindow({
+ appId: app_id || '',
+ def: appDef ?? undefined,
+ settings: (config as Record) ?? {},
+ component: MiniAppWindowContent,
+ });
const appId: string = app_id || 'Unbekannt';
const hasConfig = config && typeof config === 'object' && Object.keys(config).length > 0;
- const schema = appDef?.render_schema;
+ const schema = appDef?.render_schema as { fields?: { name: string; label?: string }[] } | undefined;
const hasSchema = schema && Object.keys(schema).length > 0;
if (loading) {
@@ -60,6 +54,18 @@ const MiniAppBlock: React.FC = ({ block }) => {
{appDef?.plugin_name && (
{appDef.plugin_name}
)}
+ {canOpenInWindow && (
+
+ )}
{hasSchema && schema.fields && (
diff --git a/frontend/src/components/dashboard/DashboardBuilder.tsx b/frontend/src/components/dashboard/DashboardBuilder.tsx
index f9d4e71..f4b71d0 100644
--- a/frontend/src/components/dashboard/DashboardBuilder.tsx
+++ b/frontend/src/components/dashboard/DashboardBuilder.tsx
@@ -28,6 +28,7 @@ import {
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
+ ExternalLink,
LayoutDashboard,
Pencil,
Check,
@@ -56,6 +57,8 @@ import {
} from '@/api/dashboards';
import { useMiniapps, renderableDashboardApps, type MiniAppDef } from '@/api/miniapps';
import { MiniAppHost } from './MiniAppHost';
+import { MiniAppWindowContent } from './MiniAppWindowContent';
+import { openMiniAppWindow } from './openMiniAppWindow';
import { WidgetSettingsForm } from './WidgetSettingsForm';
let widgetIdCounter = 0;
@@ -166,6 +169,22 @@ function SortableWidget({ widget, def, editMode, onRemove, onResize, onOpenSetti
>
+