"""Agents API — souls & activity.""" from __future__ import annotations import asyncio import json from typing import Any, Optional from fastapi import File, UploadFile, APIRouter, HTTPException, Request from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field from app.db import fetch_all, fetch_one from app.services import agent_approvals, agent_integration, agent_souls from app.services.agent_names import normalize_agent_key from app.services.agent_terminal import execute_terminal_command router = APIRouter(prefix="/api/agents", tags=["agents-api"]) class SoulUpdate(BaseModel): display_name: Optional[str] = None role_title: Optional[str] = None soul_md: Optional[str] = None responsibilities: Optional[str] = None is_active: Optional[bool] = None class ActionRequestBody(BaseModel): agent_key: str title: str = Field(..., min_length=1, max_length=255) action_type: str = "query" query_payload: dict[str, Any] = Field(default_factory=dict) class RejectBody(BaseModel): reason: str = "" class ExecuteBody(BaseModel): result: dict[str, Any] = Field(default_factory=dict) class HandoffBody(BaseModel): from_agent: str to_agent: str handoff_type: str = "partner" payload: dict[str, Any] = Field(default_factory=dict) correlation_id: Optional[str] = None class TerminalCommandBody(BaseModel): command: str = Field(..., min_length=1, max_length=2000) def _aggregate_agent_stats() -> dict[str, dict[str, Any]]: from datetime import datetime, timedelta, timezone rows = fetch_all( """ SELECT agent_name, title, status, created_at FROM agent_events WHERE created_at >= NOW() - INTERVAL '7 days' ORDER BY created_at DESC """ ) now = datetime.now(timezone.utc) by_key: dict[str, dict[str, Any]] = {} for r in rows: key = normalize_agent_key(str(r.get("agent_name") or "")) if not key: continue bucket = by_key.setdefault( key, {"events_6h": 0, "errors_24h": 0, "last_event_at": None, "last_event_title": None}, ) created = r.get("created_at") if bucket["last_event_at"] is None and created is not None: bucket["last_event_at"] = created bucket["last_event_title"] = r.get("title") if created is not None: if hasattr(created, "tzinfo") and created.tzinfo is None: created = created.replace(tzinfo=timezone.utc) if created >= now - timedelta(hours=6): bucket["events_6h"] += 1 if created >= now - timedelta(hours=24) and str(r.get("status") or "") in ("error", "rejected"): bucket["errors_24h"] += 1 return by_key def _node_health(events_6h: int, errors_24h: int, event_count: int) -> str: if events_6h > 0 and errors_24h == 0: return "healthy" if events_6h > 0: return "warn" if event_count > 0: return "idle" return "offline" @router.get("/collaboration") def api_collaboration() -> dict[str, Any]: items = agent_integration.list_collaboration() return {"items": items, "count": len(items)} @router.post("/handoff") def api_create_handoff(body: HandoffBody) -> dict[str, Any]: try: handoff = agent_integration.create_handoff( body.from_agent, body.to_agent, handoff_type=body.handoff_type, payload=body.payload, correlation_id=body.correlation_id, ) except ValueError as exc: raise HTTPException(400, str(exc)) from exc return {"ok": True, "handoff": handoff} def _terminal_payload(ev: dict[str, Any]) -> dict[str, Any]: if ev.get("created_at") and hasattr(ev["created_at"], "isoformat"): ev["created_at"] = ev["created_at"].isoformat() meta = ev.get("metadata") if isinstance(meta, str): try: meta = json.loads(meta) except Exception: meta = {} ev["metadata"] = meta or {} line_type = "handoff" if "handoff" in str(ev.get("event_type") or "") else "action" if str(ev.get("event_type") or "").startswith("terminal_"): line_type = "command" if ev.get("event_type") == "terminal_in" else "output" if ev["metadata"].get("target_agent"): line_type = "handoff_out" if ev["metadata"].get("source_agent"): line_type = "handoff_in" if str(ev.get("status") or "") in ("error", "rejected"): line_type = "error" return { "type": line_type, "id": ev.get("id"), "agent": normalize_agent_key(ev.get("agent_name")), "message": ev.get("title") or ev.get("event_type"), "detail": (ev.get("body") or "")[:500], "status": ev.get("status"), "correlation_id": ev["metadata"].get("correlation_id"), "at": ev.get("created_at"), } @router.get("/souls/{agent_key}/terminal/stream") async def api_agent_terminal_stream(agent_key: str, correlation_id: Optional[str] = None): key = normalize_agent_key(agent_key) soul = agent_souls.get_soul(key) if not soul: raise HTTPException(404, "Agent not found") async def event_gen(): last_id = 0 try: hist = fetch_all( """ SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at FROM agent_events WHERE LOWER(agent_name) = %s ORDER BY id DESC LIMIT 30 """, (key,), ) for row in reversed(hist): last_id = max(last_id, int(row["id"])) payload = _terminal_payload(dict(row)) yield f"data: {json.dumps(payload, default=str)}\n\n" except Exception: pass while True: try: if correlation_id: rows = fetch_all( """ SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at FROM agent_events WHERE metadata->>'correlation_id' = %s AND id > %s ORDER BY id ASC LIMIT 50 """, (correlation_id, last_id), ) else: rows = fetch_all( """ SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at FROM agent_events WHERE LOWER(agent_name) = %s AND id > %s ORDER BY id ASC LIMIT 50 """, (key, last_id), ) for row in rows: last_id = max(last_id, int(row["id"])) payload = _terminal_payload(dict(row)) yield f"data: {json.dumps(payload, default=str)}\n\n" except Exception as exc: err = {"type": "error", "message": str(exc)} yield f"data: {json.dumps(err)}\n\n" await asyncio.sleep(0.4) return StreamingResponse( event_gen(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) @router.get("/live/stream") async def api_agents_live_stream(): """Single realtime stream for all agent terminals.""" async def event_gen(): last_id = 0 try: recent = fetch_all( """ SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at FROM agent_events WHERE created_at >= NOW() - INTERVAL '30 minutes' ORDER BY id ASC LIMIT 80 """ ) for row in recent: last_id = max(last_id, int(row["id"])) payload = _terminal_payload(dict(row)) payload["replay"] = True yield f"data: {json.dumps(payload, default=str)}\n\n" except Exception: pass yield f"data: {json.dumps({'type': 'connected', 'last_id': last_id})}\n\n" while True: try: rows = fetch_all( """ SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at FROM agent_events WHERE id > %s ORDER BY id ASC LIMIT 100 """, (last_id,), ) for row in rows: last_id = max(last_id, int(row["id"])) payload = _terminal_payload(dict(row)) yield f"data: {json.dumps(payload, default=str)}\n\n" except Exception as exc: err = {"type": "error", "message": str(exc)} yield f"data: {json.dumps(err)}\n\n" await asyncio.sleep(0.25) return StreamingResponse( event_gen(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) @router.get("/souls") def api_list_souls() -> dict[str, Any]: return {"items": agent_souls.list_souls(), "count": len(agent_souls.list_souls())} @router.get("/souls/{agent_key}") def api_get_soul(agent_key: str) -> dict[str, Any]: soul = agent_souls.get_soul(agent_key) if not soul: raise HTTPException(404, "Agent not found") return {"soul": soul} @router.get("/souls/{agent_key}/events") def api_list_agent_events(agent_key: str, limit: int = 50) -> dict[str, Any]: soul = agent_souls.get_soul(agent_key) if not soul: raise HTTPException(404, "Agent not found") items = agent_souls.list_agent_events(agent_key, limit=min(limit, 100)) return {"agent_key": agent_key.lower(), "items": items, "count": len(items)} @router.post("/souls/{agent_key}/terminal/command") async def api_terminal_command(agent_key: str, body: TerminalCommandBody) -> dict[str, Any]: try: return await execute_terminal_command(agent_key, body.command.strip()) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: raise HTTPException(status_code=500, detail=str(exc)) from exc @router.put("/souls/{agent_key}") def api_update_soul(agent_key: str, body: SoulUpdate) -> dict[str, Any]: try: soul = agent_souls.update_soul(agent_key, **body.model_dump(exclude_none=True)) except ValueError as exc: raise HTTPException(404, str(exc)) from exc return {"soul": soul} @router.get("/mesh") def api_agents_mesh() -> dict[str, Any]: souls = agent_souls.list_souls() by_key = _aggregate_agent_stats() nodes: list[dict[str, Any]] = [] for soul in souls: key = str(soul.get("agent_key") or "").lower() row = by_key.get(key, {}) events_6h = int(row.get("events_6h") or 0) errors_24h = int(row.get("errors_24h") or 0) health = _node_health(events_6h, errors_24h, int(soul.get("event_count") or 0)) is_active = health == "healthy" and events_6h > 0 node = dict(soul) node["health"] = health node["events_6h"] = events_6h node["errors_24h"] = errors_24h node["is_active"] = is_active node["last_event_title"] = row.get("last_event_title") lat = row.get("last_event_at") if lat is not None and hasattr(lat, "isoformat"): node["last_event_at"] = lat.isoformat() nodes.append(node) report_edges: list[dict[str, Any]] = [] for node in nodes: key = str(node.get("agent_key") or "").lower() if key == "herman" or not node.get("is_active"): continue report_edges.append( { "source": key, "target": "herman", "type": "report", "active": True, "weight": int(node.get("events_6h") or 1), } ) delegate_rows = fetch_all( """ SELECT metadata, created_at FROM agent_events WHERE agent_name = 'herman' AND created_at >= NOW() - INTERVAL '6 hours' AND ( event_type IN ('openswarm_delegation', 'delegate', 'packaging_delivered', 'telegram_delegation') OR metadata ? 'delegated' ) ORDER BY created_at DESC LIMIT 100 """ ) delegate_edges: list[dict[str, Any]] = [] seen_delegate: set[tuple[str, str]] = set() for r in delegate_rows: meta = r.get("metadata") or {} if isinstance(meta, str): try: meta = json.loads(meta) except Exception: meta = {} delegated = meta.get("delegated") or meta.get("delegated_agents") or [] if isinstance(delegated, str): delegated = [delegated] channel = meta.get("channel") or "herman" for agent in delegated: tgt = normalize_agent_key(str(agent)) if not tgt or tgt == "herman": continue pair = ("herman", tgt) if pair in seen_delegate: continue seen_delegate.add(pair) delegate_edges.append( { "source": "herman", "target": tgt, "type": "delegate", "active": True, "channel": channel, } ) peer_static = [ { "source": str(r["from_agent"]), "target": str(r["to_agent"]), "type": "static", "handoff_type": r.get("handoff_type"), } for r in agent_integration.list_collaboration() ] peer_live = agent_integration.peer_edges_live(hours=6) peer_edges = peer_static + peer_live herman_active = any(n.get("agent_key") == "herman" and n.get("is_active") for n in nodes) herman_active = herman_active or bool(delegate_edges) or bool(report_edges) executive_edges = [] if herman_active: executive_edges = [ {"source": "herman", "target": "ceo", "type": "executive", "active": True}, {"source": "herman", "target": "cto", "type": "executive", "active": True}, ] return { "nodes": nodes, "report_edges": report_edges, "delegate_edges": delegate_edges, "peer_edges": peer_edges, "executive_edges": executive_edges, "edges": report_edges, "executives": [ {"id": "ceo", "label": "CEO", "role": "Aissa"}, {"id": "cto", "label": "CTO", "role": "Platform"}, ], } @router.get("/approvals") def api_list_approvals(status: Optional[str] = None, limit: int = 50) -> dict[str, Any]: try: items = agent_approvals.list_requests(status=status, limit=limit) except Exception as exc: raise HTTPException(status_code=500, detail=str(exc)) from exc return {"items": items, "count": len(items)} @router.post("/requests") def api_create_action_request(body: ActionRequestBody) -> dict[str, Any]: try: req = agent_approvals.create_request( body.agent_key, body.title, action_type=body.action_type, query_payload=body.query_payload, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: raise HTTPException(status_code=500, detail=str(exc)) from exc auto = None rid = (req or {}).get("id") if rid: try: auto = agent_approvals.try_auto_approve_and_execute(int(rid)) except Exception: auto = None msg = "Wacht op goedkeuring voordat query uitgevoerd mag worden" if auto: msg = "Auto-goedgekeurd en uitgevoerd (SysOps policy)" return {"ok": True, "request": req, "auto_executed": bool(auto), "message": msg} @router.post("/approvals/{request_id}/approve") def api_approve_request(request_id: int) -> dict[str, Any]: import os import httpx try: req = agent_approvals.approve_request(request_id, approved_by="ceo") action = req.get("action_type") or "" auto_result = None if action in ("config_backup", "maintenance_scan"): tools_url = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/") path = "/ops/backup/run" if action == "config_backup" else "/ops/maintenance/scan" payload = {"approval_request_id": request_id} if action == "config_backup" else None with httpx.Client(timeout=180.0) as client: resp = client.post(f"{tools_url}{path}", json=payload or {}) try: auto_result = resp.json() except Exception: auto_result = {"ok": False, "detail": resp.text} req = agent_approvals.mark_executed(request_id, auto_result or {}) except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except Exception as exc: raise HTTPException(status_code=500, detail=str(exc)) from exc return {"ok": True, "request": req, "executed": auto_result is not None, "result": auto_result} @router.post("/approvals/{request_id}/reject") def api_reject_request(request_id: int, body: RejectBody) -> dict[str, Any]: try: req = agent_approvals.reject_request(request_id, reason=body.reason) except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return {"ok": True, "request": req} @router.post("/approvals/{request_id}/execute") def api_execute_approved_request(request_id: int, body: ExecuteBody) -> dict[str, Any]: import os import httpx try: req = agent_approvals.require_approved(request_id) result_payload = body.result or {} action = req.get("action_type") or "" if action == "config_backup": tools_url = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/") with httpx.Client(timeout=180.0) as client: resp = client.post( f"{tools_url}/ops/backup/run", json={"approval_request_id": request_id}, ) try: result_payload = resp.json() except Exception: result_payload = {"ok": False, "detail": resp.text} if resp.status_code >= 400: raise HTTPException(status_code=502, detail=result_payload.get("detail") or resp.text) elif action == "maintenance_scan": tools_url = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/") with httpx.Client(timeout=120.0) as client: resp = client.post(f"{tools_url}/ops/maintenance/scan") result_payload = resp.json() if resp.status_code < 400 else {"ok": False, "detail": resp.text} req = agent_approvals.mark_executed(request_id, result_payload) except HTTPException: raise except PermissionError as exc: raise HTTPException(status_code=403, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except Exception as exc: raise HTTPException(status_code=500, detail=str(exc)) from exc return {"ok": True, "request": req} # --- War Room chat --- class WarroomMessageBody(BaseModel): content: str = Field("", max_length=8000) attachments: list[dict] = Field(default_factory=list) @router.get("/warroom/rooms") async def warroom_list_rooms() -> dict[str, Any]: from app.services import agent_chat agent_chat.ensure_warroom() return {"items": agent_chat.list_rooms()} @router.post("/warroom/rooms/dm/{agent_key}") async def warroom_open_dm(agent_key: str) -> dict[str, Any]: from app.services import agent_chat try: room = agent_chat.get_or_create_dm(agent_key) except ValueError as exc: raise HTTPException(404, str(exc)) from exc return {"room": room} @router.get("/warroom/rooms/{room_id}/messages") async def warroom_list_messages(room_id: int, limit: int = 100, after_id: int | None = None) -> dict[str, Any]: from app.services import agent_chat agent_chat.ensure_warroom() items = agent_chat.list_messages(room_id, limit=min(limit, 300), after_id=after_id) deleted_ids = agent_chat.list_deleted_ids(room_id) if after_id else [] return {"ok": True, "items": items, "deleted_ids": deleted_ids} @router.post("/warroom/rooms/{room_id}/messages") async def warroom_post_message(request: Request, room_id: int, body: WarroomMessageBody) -> dict[str, Any]: from app.services import agent_chat user = getattr(request.state, "user", None) or {} try: result = await agent_chat.post_user_message( room_id, body.content, username=(user.get("username") if isinstance(user, dict) else None), role=(user.get("role") if isinstance(user, dict) else None), sender_avatar_url=(user.get("avatar_url") if isinstance(user, dict) else None), attachments=body.attachments, ) except ValueError as exc: raise HTTPException(400, str(exc)) from exc return result @router.post("/warroom/rooms/{room_id}/messages/stream") async def warroom_post_message_stream(request: Request, room_id: int, body: WarroomMessageBody): """SSE stream: user → typing tokens → agent message(s) → done.""" import json from app.services import agent_chat user = getattr(request.state, "user", None) or {} async def gen(): try: async for ev in agent_chat.post_user_message_stream( room_id, body.content, username=(user.get("username") if isinstance(user, dict) else None), role=(user.get("role") if isinstance(user, dict) else None), sender_avatar_url=(user.get("avatar_url") if isinstance(user, dict) else None), attachments=body.attachments, ): yield f"data: {json.dumps(ev, default=str)}\n\n" except ValueError as exc: yield f"data: {json.dumps({'event': 'error', 'detail': str(exc)})}\n\n" except Exception as exc: yield f"data: {json.dumps({'event': 'error', 'detail': str(exc)})}\n\n" return StreamingResponse( gen(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no", }, ) @router.post("/warroom/upload") async def warroom_upload(file: UploadFile = File(...)) -> dict[str, Any]: """Upload a file to share in War Room (images/docs).""" from pathlib import Path as P import uuid import re raw_name = (file.filename or "upload.bin").strip() safe = re.sub(r"[^a-zA-Z0-9._-]+", "_", raw_name)[:120] or "upload.bin" ext = P(safe).suffix.lower() allowed = { ".png", ".jpg", ".jpeg", ".gif", ".webp", ".pdf", ".txt", ".csv", ".xlsx", ".xls", ".doc", ".docx", ".zip", ".json", ".md", } if ext not in allowed: raise HTTPException(400, f"Bestandstype niet toegestaan: {ext or '(geen)'}") data = await file.read() if len(data) > 12 * 1024 * 1024: raise HTTPException(400, "Max 12MB per bestand") dest_dir = P(__file__).resolve().parent.parent.parent / "static" / "uploads" / "warroom" dest_dir.mkdir(parents=True, exist_ok=True) fname = f"{uuid.uuid4().hex[:12]}_{safe}" (dest_dir / fname).write_bytes(data) url = f"/static/uploads/warroom/{fname}" return { "ok": True, "url": url, "name": raw_name[:180], "mime": file.content_type or "application/octet-stream", "size": len(data), } @router.post("/warroom/digest") async def warroom_daily_digest(hours: int = 24, deliver: bool = True) -> dict[str, Any]: """Generate Herman War Room day report and optionally push to Aïssa + Mo on Telegram.""" from app.services import warroom_digest try: return await warroom_digest.run_daily_digest(hours=max(1, min(hours, 72)), deliver=deliver) except Exception as exc: raise HTTPException(500, str(exc)) from exc @router.post("/warroom/rooms/{room_id}/continue") async def warroom_continue(request: Request, room_id: int, turns: int = 2): """Autonomous A2A continuation — agents keep debating with cockpit data.""" import json from app.services import agent_chat user = getattr(request.state, "user", None) or {} async def gen(): try: async for ev in agent_chat.continue_warroom_stream( room_id, username=(user.get("username") if isinstance(user, dict) else None), role=(user.get("role") if isinstance(user, dict) else None), max_turns=max(1, min(int(turns or 2), 4)), ): yield f"data: {json.dumps(ev, default=str)}\n\n" except ValueError as exc: yield f"data: {json.dumps({'event': 'error', 'detail': str(exc)})}\n\n" except Exception as exc: yield f"data: {json.dumps({'event': 'error', 'detail': str(exc)})}\n\n" return StreamingResponse( gen(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no", }, ) @router.delete("/warroom/rooms/{room_id}/messages/{message_id}") async def warroom_delete_message(room_id: int, message_id: int) -> dict[str, Any]: """Soft-delete a War Room message.""" from app.services import agent_chat room = agent_chat.get_room(room_id) if not room: raise HTTPException(404, "Room niet gevonden") row = agent_chat.delete_message(room_id, message_id) if not row: raise HTTPException(404, "Bericht niet gevonden") return {"ok": True, "id": message_id} @router.post("/warroom/rooms/{room_id}/voice-room") async def warroom_voice_room(request: Request, room_id: int) -> dict[str, Any]: """Open/announce a Foodlinkk Voice Room (humans + agents via STT/TTS).""" from app.services import agent_chat room = agent_chat.get_room(room_id) if not room: raise HTTPException(404, "Room niet gevonden") body = {} try: body = await request.json() except Exception: body = {} action = str(body.get("action") or "join").lower() agents = body.get("agents") or ["herman", "retail", "bizdev", "halal", "finance"] if not isinstance(agents, list): agents = ["herman"] agents = [str(a).strip().lower() for a in agents if str(a).strip()][:12] user = getattr(request.state, "user", None) or {} who = user.get("username") if isinstance(user, dict) else "user" if action == "leave": agent_chat.post_system_line( f"🎙️ {who} verliet de Voice Room", agent_key="herman", metadata={"kind": "voice_room", "action": "leave", "opened_by": who}, ) return {"ok": True, "action": "leave"} agent_chat.post_system_line( f"🎙️ Voice Room open — {who} + agents: " + ", ".join("@" + a for a in agents), agent_key="herman", metadata={ "kind": "voice_room", "action": "join", "opened_by": who, "agents": agents, }, ) return {"ok": True, "action": "join", "agents": agents, "host": who} @router.post("/warroom/rooms/{room_id}/call") async def warroom_start_call(request: Request, room_id: int) -> dict[str, Any]: """Open a simple no-account video room in an external HTTPS window. Cockpit stays on HTTP. Mic/camera need HTTPS in a separate tab. Default provider is Freifunk meet (no 8x8 login / no moderator gate). """ import os from urllib.parse import quote from app.db import execute, fetch_one from app.services import agent_chat room = agent_chat.get_room(room_id) if not room: raise HTTPException(404, "Room niet gevonden") base = (os.getenv("WARROOM_MEET_BASE") or "https://meet.ffmuc.net").rstrip("/") slug = f"FoodlinkkWR{room_id}" if room.get("kind") == "dm" and room.get("agent_key"): slug = f"FoodlinkkDM{room.get('agent_key')}{room_id}" url = f"{base}/{quote(slug)}" user = getattr(request.state, "user", None) or {} who = user.get("username") if isinstance(user, dict) else "user" recent = fetch_one( """SELECT id, metadata FROM warroom_messages WHERE room_id = %s AND deleted_at IS NULL AND metadata->>'kind' = 'call' AND created_at > NOW() - INTERVAL '20 minutes' ORDER BY id DESC LIMIT 1""", (room_id,), ) reused = bool(recent and (recent.get("metadata") or {}).get("call_url") == url) if not reused: execute( """UPDATE warroom_messages SET deleted_at = NOW(), content = '', metadata = COALESCE(metadata, '{}'::jsonb) || '{"deleted": true}'::jsonb WHERE room_id = %s AND deleted_at IS NULL AND ( metadata->>'kind' = 'call' OR content ILIKE %s OR content ILIKE %s )""", (room_id, "%War Room-call%", "%Call open%"), ) agent_chat.post_system_line( f"📹 Call open — geen account nodig: {url}", agent_key="herman", metadata={"kind": "call", "call_url": url, "room_slug": slug, "opened_by": who}, ) return { "ok": True, "url": url, "slug": slug, "provider": "ffmuc", "embed": False, "mode": "external", "reused": reused, "hint": "Cockpit blijft op HTTP. Gesprek opent in een apart venster — geen login.", }