diff --git a/backend/apps/apps_sdk/__init__.py b/backend/apps/apps_sdk/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/apps/apps_sdk/apps_sdk.py b/backend/apps/apps_sdk/apps_sdk.py new file mode 100644 index 00000000..39de0e4d --- /dev/null +++ b/backend/apps/apps_sdk/apps_sdk.py @@ -0,0 +1,121 @@ +"""REST surface for OpenSwarm-built apps (the app-side SDK's host half). + +Apps already hold the install token (frontend via ?token=, backends via +OPENSWARM_HOST_TOKEN_FILE), and workflows/agents already expose first-class +routes the SDK helpers call directly. This SubApp adds only what REST could +not do before: a provider-agnostic LLM completion, and an agent spawn that +can land its card at a position on the canvas. +""" + +from contextlib import asynccontextmanager +from typing import Any, AsyncIterator, Dict, Optional + +from fastapi import HTTPException +from pydantic import BaseModel, ConfigDict +from typeguard import typechecked + +from backend.config.Apps import SubApp + + +@asynccontextmanager +async def apps_sdk_lifespan() -> AsyncIterator[None]: + yield + + +apps_sdk = SubApp("apps-sdk", apps_sdk_lifespan) + + +class LlmRequest(BaseModel): + model_config = ConfigDict(validate_assignment=True) + + prompt: str + system: Optional[str] = None + # Short model name (sonnet/haiku/gpt-5-mini/...); absent means the cheap tier of whatever provider the user runs. + model: Optional[str] = None + max_tokens: int = 1024 + + +class LlmReply(BaseModel): + model_config = ConfigDict(validate_assignment=True) + + text: str + model: str + + +@apps_sdk.router.post("/llm") +@typechecked +async def llm(body: LlmRequest) -> LlmReply: + from backend.apps.agents.providers.registry import resolve_aux_model, resolve_model_id_for_sdk + from backend.apps.settings.credentials import get_anthropic_client_for_model + from backend.apps.settings.settings import load_settings + + if not body.prompt.strip(): + raise HTTPException(status_code=422, detail="prompt is empty") + settings = load_settings() + if body.model: + api_model = resolve_model_id_for_sdk(body.model, settings) + else: + api_model, _ = await resolve_aux_model(settings) + client = get_anthropic_client_for_model(settings, api_model) + kwargs: Dict[str, Any] = { + "model": api_model, + "max_tokens": max(1, min(body.max_tokens, 8192)), + "messages": [{"role": "user", "content": body.prompt}], + } + if body.system: + kwargs["system"] = body.system + try: + # STREAM, never .create(): 9router's non-Anthropic lanes answer as real SSE that the non-streaming client parses to empty content. + async with client.messages.stream(**kwargs) as stream: + resp = await stream.get_final_message() + except Exception as e: + raise HTTPException(status_code=502, detail=f"LLM call failed: {e}") + text = "".join(b.text for b in resp.content if getattr(b, "type", "") == "text") + return LlmReply(text=text, model=api_model) + + +class SpawnAgentRequest(BaseModel): + model_config = ConfigDict(validate_assignment=True) + + prompt: str + name: str = "Agent" + model: Optional[str] = None + dashboard_id: Optional[str] = None + # Canvas-space position for the spawned card; both present or the placement broadcast is skipped. + x: Optional[float] = None + y: Optional[float] = None + + +class SpawnAgentReply(BaseModel): + model_config = ConfigDict(validate_assignment=True) + + session_id: str + + +@apps_sdk.router.post("/agents/spawn") +@typechecked +async def spawn_agent(body: SpawnAgentRequest) -> SpawnAgentReply: + import asyncio + + from backend.apps.agents.agent_manager import agent_manager + from backend.apps.agents.core.models import AgentConfig + from backend.apps.agents.core.ws_manager import ws_manager + + if not body.prompt.strip(): + raise HTTPException(status_code=422, detail="prompt is empty") + config = AgentConfig( + name=body.name, + prompt=body.prompt, + model=body.model or "sonnet", + dashboard_id=body.dashboard_id, + ) + session = await agent_manager.launch_agent(config) + asyncio.create_task(agent_manager.send_message(session.id, body.prompt)) + if body.x is not None and body.y is not None: + await ws_manager.broadcast_global("apps_sdk:place_agent_card", { + "session_id": session.id, + "dashboard_id": body.dashboard_id, + "x": body.x, + "y": body.y, + }) + return SpawnAgentReply(session_id=session.id) diff --git a/backend/apps/outputs/app_builder_skill.md b/backend/apps/outputs/app_builder_skill.md index 9b3bc21c..d24de975 100644 --- a/backend/apps/outputs/app_builder_skill.md +++ b/backend/apps/outputs/app_builder_skill.md @@ -353,6 +353,21 @@ export const JOBS_LIST = '/api/jobs/list'; --- +## The host SDK — the user's models, workflows, and agents + +The workspace ships pre-wired helpers for calling the OpenSwarm HOST from inside the app: +LLM completions through the user's own subscription (any provider, model selectable), +listing + firing the user's workflows and reading run results, and spawning real agent +cards on the canvas (optionally positioned). **Read `SDK.md` at the workspace root before +building any feature that needs intelligence, automation, or agents** — the helpers are: + +- Frontend: `import { llm, listWorkflows, runWorkflow, spawnAgent } from '@/openswarmHost'` +- Backend: `from backend.apps.openswarm_host.openswarm_host import llm, run_workflow, spawn_agent` + +Auth is automatic (host-injected token); never hand-roll fetches against host routes. The SDK +works in preview and installed apps; for features that must survive PUBLISHING to the public +web, use `window.OUTPUT_LLM` / `window.OUTPUT_COMPUTE` below instead. + ## Publishable AI + compute — `window.OUTPUT_LLM` / `window.OUTPUT_COMPUTE` The FastAPI backend above runs in preview but is **not hosted when an app is diff --git a/backend/apps/outputs/webapp_template/SDK.md b/backend/apps/outputs/webapp_template/SDK.md new file mode 100644 index 00000000..6be32de4 --- /dev/null +++ b/backend/apps/outputs/webapp_template/SDK.md @@ -0,0 +1,65 @@ +# OpenSwarm App SDK — call the host from your app + +Your app runs INSIDE OpenSwarm, and the host lends it real capabilities: the user's +LLM subscription, their saved workflows, and live agents on the canvas. Two pre-wired +helper modules expose all of it; never hand-roll fetch calls to the host. + +| Where | Import | +| --- | --- | +| Frontend (React/TS) | `import { llm, listWorkflows, runWorkflow, listWorkflowRuns, spawnAgent, agentSession } from '@/openswarmHost';` | +| Backend (FastAPI) | `from backend.apps.openswarm_host.openswarm_host import llm, list_workflows, run_workflow, list_workflow_runs, spawn_agent, agent_session` | + +Auth is automatic: the frontend reads the `?token=` the host injects into the preview URL; +the backend reads the rotating token file the host passes via `OPENSWARM_HOST_TOKEN_FILE`. +You never handle credentials. + +## LLM calls (the user's own subscription, any provider) + +```ts +const answer = await llm('Summarize this in one line: ' + text); +const haiku = await llm('Write a haiku about rain', { model: 'haiku', system: 'You are terse.' }); +``` + +```python +answer = llm("Summarize this in one line: " + text) +``` + +- Omit `model` for the cheapest tier of whatever provider the user runs (never assume Anthropic). +- One-shot only; keep prompts small, this is the user's real money. + +## Workflows + +```ts +const flows = await listWorkflows(); // [{id, name, enabled, ...}] +await runWorkflow(flows[0].id); // fire it now +const runs = await listWorkflowRuns(); // read status/results +``` + +A workflow the user switched OFF will refuse to run; surface the host's error to the user +instead of retrying. + +## Agents on the canvas + +```ts +const sessionId = await spawnAgent('Research the top 3 CRM tools and report back', { + name: 'CRM scout', + x: 400, y: 300, // optional canvas position for the card +}); +const state = await agentSession(sessionId); // {status, messages, ...} — poll while status === 'running' +``` + +The agent is a real OpenSwarm agent card the user can watch and take over. Spawn sparingly: +one agent per user action, never in a loop. + +## What the SDK does NOT give you (yet) + +- Direct calls to the user's connected tools/MCP connectors (Gmail, Slack, ...). That surface + needs per-app permission grants and is not wired; do not fake it by calling other host routes. + If your app needs a tool action today, spawn an agent and ask it to do the task. + +## Ground rules + +- Degrade gracefully: every helper throws on a host error; catch and show a clean message, + never a blank screen. +- These helpers only work while the app runs inside OpenSwarm (preview or installed). A + published web app on openswarm.host has no host; guard with a try/catch and hide the feature. diff --git a/backend/apps/outputs/webapp_template/backend/apps/openswarm_host/__init__.py b/backend/apps/outputs/webapp_template/backend/apps/openswarm_host/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/apps/outputs/webapp_template/backend/apps/openswarm_host/openswarm_host.py b/backend/apps/outputs/webapp_template/backend/apps/openswarm_host/openswarm_host.py new file mode 100644 index 00000000..ce931f8e --- /dev/null +++ b/backend/apps/outputs/webapp_template/backend/apps/openswarm_host/openswarm_host.py @@ -0,0 +1,93 @@ +"""Call the OpenSwarm host (the user's models, workflows, agents) from app backend code. + +The host hands this runtime a token FILE path via OPENSWARM_HOST_TOKEN_FILE (a path, not the +value, so token rotations are picked up on every call). See SDK.md at the workspace root for +the guide; the frontend twin of this module is frontend/src/openswarmHost.ts. +""" + +import os +from typing import Any, Dict, List, Optional + +import httpx +from typeguard import typechecked + +HOST = os.environ.get("OPENSWARM_HOST_API", "http://127.0.0.1:8324") + + +@typechecked +def host_token() -> str: + path = os.environ.get("OPENSWARM_HOST_TOKEN_FILE", "") + if not path or not os.path.exists(path): + return "" + try: + with open(path, "r", encoding="utf-8") as f: + return f.read().strip() + except OSError: + return "" + + +@typechecked +def p_request(method: str, path: str, body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + headers = {"Authorization": f"Bearer {host_token()}"} + with httpx.Client(timeout=120.0) as client: + resp = client.request(method, f"{HOST}{path}", json=body, headers=headers) + if resp.status_code >= 400: + raise RuntimeError(f"OpenSwarm host {path} -> {resp.status_code}: {resp.text[:300]}") + data = resp.json() + return data if isinstance(data, dict) else {"result": data} + + +@typechecked +def llm(prompt: str, system: Optional[str] = None, model: Optional[str] = None, max_tokens: int = 1024) -> str: + """One-shot completion through the user's own configured providers/subscription. + `model` is a short name (sonnet, haiku, ...); omit for the cheap tier.""" + reply = p_request("POST", "/api/apps-sdk/llm", { + "prompt": prompt, "system": system, "model": model, "max_tokens": max_tokens, + }) + return str(reply.get("text", "")) + + +@typechecked +def list_workflows() -> List[Dict[str, Any]]: + """The user's saved workflows.""" + data = p_request("GET", "/api/workflows/list") + flows = data.get("workflows", []) + return flows if isinstance(flows, list) else [] + + +@typechecked +def run_workflow(workflow_id: str) -> Dict[str, Any]: + """Fire a workflow now; returns the host's report for the started run.""" + return p_request("POST", f"/api/workflows/{workflow_id}/run", {}) + + +@typechecked +def list_workflow_runs() -> List[Dict[str, Any]]: + """All workflow runs (newest first), for reading a fired run's status/result.""" + data = p_request("GET", "/api/workflows/runs/all") + runs = data.get("runs", []) + return runs if isinstance(runs, list) else [] + + +@typechecked +def spawn_agent( + prompt: str, + name: str = "Agent", + model: Optional[str] = None, + dashboard_id: Optional[str] = None, + x: Optional[float] = None, + y: Optional[float] = None, +) -> str: + """Spawn a real agent on the canvas with a first prompt; returns its session id. + Give both x and y to place the card at a canvas position.""" + reply = p_request("POST", "/api/apps-sdk/agents/spawn", { + "prompt": prompt, "name": name, "model": model, + "dashboard_id": dashboard_id, "x": x, "y": y, + }) + return str(reply.get("session_id", "")) + + +@typechecked +def agent_session(session_id: str) -> Dict[str, Any]: + """A spawned agent's live state: status plus its transcript so far.""" + return p_request("GET", f"/api/agents/sessions/{session_id}") diff --git a/backend/apps/outputs/webapp_template/frontend/src/openswarmHost.ts b/backend/apps/outputs/webapp_template/frontend/src/openswarmHost.ts new file mode 100644 index 00000000..1446dd59 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/openswarmHost.ts @@ -0,0 +1,106 @@ +// The app-side SDK: call the OpenSwarm host (your tools, workflows, models, agents) from app +// frontend code. The auth token rides in the preview URL (?token=), injected by the host; every +// helper resolves it lazily so importing this file never throws. See SDK.md for the guide. + +const HOST = 'http://localhost:8324'; + +function hostToken(): string { + try { + return new URLSearchParams(window.location.search).get('token') ?? ''; + } catch { + return ''; + } +} + +async function hostFetch(path: string, init?: RequestInit): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${hostToken()}`, + ...(init?.headers as Record | undefined), + }; + const res = await fetch(`${HOST}${path}`, { ...init, headers }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`OpenSwarm host ${path} -> ${res.status}: ${body.slice(0, 300)}`); + } + return res.json() as Promise; +} + +export interface LlmOptions { + system?: string; + /** Short model name (sonnet, haiku, ...). Omit for the cheap tier of the user's provider. */ + model?: string; + maxTokens?: number; +} + +/** One-shot completion through the user's own configured providers/subscription. */ +export async function llm(prompt: string, opts: LlmOptions = {}): Promise { + const reply = await hostFetch<{ text: string; model: string }>('/api/apps-sdk/llm', { + method: 'POST', + body: JSON.stringify({ + prompt, + system: opts.system, + model: opts.model, + max_tokens: opts.maxTokens ?? 1024, + }), + }); + return reply.text; +} + +export interface WorkflowSummary { + id: string; + name: string; + enabled?: boolean; + [key: string]: unknown; +} + +/** The user's saved workflows. */ +export async function listWorkflows(): Promise { + const data = await hostFetch<{ workflows?: WorkflowSummary[] }>('/api/workflows/list'); + return data.workflows ?? []; +} + +/** Fire a workflow now. Returns whatever the host reports for the started run. */ +export async function runWorkflow(workflowId: string): Promise> { + return hostFetch>(`/api/workflows/${encodeURIComponent(workflowId)}/run`, { + method: 'POST', + body: JSON.stringify({}), + }); +} + +/** All workflow runs (newest first), for reading a fired run's status/result. */ +export async function listWorkflowRuns(): Promise[]> { + const data = await hostFetch<{ runs?: Record[] }>('/api/workflows/runs/all'); + return data.runs ?? []; +} + +export interface SpawnAgentOptions { + name?: string; + /** Short model name; omit for the user's default. */ + model?: string; + dashboardId?: string; + /** Canvas position for the spawned card; give both or neither. */ + x?: number; + y?: number; +} + +/** Spawn a real agent on the canvas with a first prompt. Returns its session id. */ +export async function spawnAgent(prompt: string, opts: SpawnAgentOptions = {}): Promise { + const reply = await hostFetch<{ session_id: string }>('/api/apps-sdk/agents/spawn', { + method: 'POST', + body: JSON.stringify({ + prompt, + name: opts.name ?? 'Agent', + model: opts.model, + dashboard_id: opts.dashboardId, + x: opts.x, + y: opts.y, + }), + }); + return reply.session_id; +} + +/** A spawned agent's live state: status plus its transcript so far. */ +export async function agentSession(sessionId: string): Promise> { + return hostFetch>(`/api/agents/sessions/${encodeURIComponent(sessionId)}`); +} diff --git a/backend/main.py b/backend/main.py index 47dbf462..785c227d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -50,11 +50,12 @@ from backend.apps.agents.proxy.anthropic_proxy import anthropic_proxy from backend.apps.agents.core.openai_passthrough import openai_passthrough from backend.apps.workflows.workflows import workflows from backend.apps.workflows.cloud.routes import cloud_workflows +from backend.apps.apps_sdk.apps_sdk import apps_sdk from fastapi.middleware.cors import CORSMiddleware from fastapi import WebSocket, WebSocketDisconnect import json -main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, output_versions, dashboards, swarm, service, subscription, auth, web, onboarding, voice, memory, help_app, anthropic_proxy, workflows, cloud_workflows, openai_passthrough]) +main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, output_versions, dashboards, swarm, service, subscription, auth, web, onboarding, voice, memory, help_app, anthropic_proxy, workflows, cloud_workflows, openai_passthrough, apps_sdk]) app = main_app.app # Generate per-install auth token BEFORE we bind the HTTP port. By the time any request lands, the token file exists. See backend/auth.py. diff --git a/backend/tests/test_apps_sdk.py b/backend/tests/test_apps_sdk.py new file mode 100644 index 00000000..a992164d --- /dev/null +++ b/backend/tests/test_apps_sdk.py @@ -0,0 +1,161 @@ +"""The apps SDK host surface (ENG-202): provider-agnostic LLM completions and positioned agent +spawns for OpenSwarm-built apps, plus the template helpers that ride them.""" + +import os + +from fastapi.testclient import TestClient + +from backend.main import app + +client = TestClient(app) +P_TEMPLATE = os.path.join(os.path.dirname(__file__), "..", "apps", "outputs", "webapp_template") + + +def p_auth() -> dict: + from backend.auth import init_auth_token + return {"Authorization": f"Bearer {init_auth_token()}"} + + +class Blk: + def __init__(self, type: str, text: str = "") -> None: + self.type = type + self.text = text + + +class FakeStream: + def __init__(self, resp) -> None: + self.resp = resp + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def get_final_message(self): + return self.resp + + +class FakeLLMClient: + def __init__(self) -> None: + self.calls = [] + self.messages = self + + def stream(self, **kw): + self.calls.append(kw) + resp = type("R", (), {"content": [Blk("text", "hello from fake")]})() + return FakeStream(resp) + + +def test_llm_routes_through_the_users_provider(monkeypatch): + import backend.apps.agents.providers.registry as reg + import backend.apps.settings.credentials as cred + import backend.apps.settings.settings as settings_mod + + fake = FakeLLMClient() + monkeypatch.setattr(settings_mod, "load_settings", lambda: {"fake": True}, raising=True) + + async def p_aux(settings, preferred_tier="haiku", primary_api=None): + return ("aux-cheap", None) + monkeypatch.setattr(reg, "resolve_aux_model", p_aux, raising=True) + monkeypatch.setattr(cred, "get_anthropic_client_for_model", lambda s, m: fake, raising=True) + + r = client.post("/api/apps-sdk/llm", json={"prompt": "say hello"}, headers=p_auth()) + assert r.status_code == 200, r.text + assert r.json() == {"text": "hello from fake", "model": "aux-cheap"} + assert fake.calls[0]["model"] == "aux-cheap" + + +def test_llm_honors_an_explicit_model(monkeypatch): + import backend.apps.agents.providers.registry as reg + import backend.apps.settings.credentials as cred + import backend.apps.settings.settings as settings_mod + + fake = FakeLLMClient() + monkeypatch.setattr(settings_mod, "load_settings", lambda: {"fake": True}, raising=True) + monkeypatch.setattr(reg, "resolve_model_id_for_sdk", lambda short, s: f"resolved-{short}", raising=True) + monkeypatch.setattr(cred, "get_anthropic_client_for_model", lambda s, m: fake, raising=True) + + r = client.post("/api/apps-sdk/llm", json={"prompt": "hi", "model": "haiku", "system": "terse"}, + headers=p_auth()) + assert r.status_code == 200, r.text + assert r.json()["model"] == "resolved-haiku" + assert fake.calls[0]["system"] == "terse" + + +def test_llm_rejects_an_empty_prompt(): + r = client.post("/api/apps-sdk/llm", json={"prompt": " "}, headers=p_auth()) + assert r.status_code == 422 + + +def test_spawn_agent_launches_and_broadcasts_position(monkeypatch): + from backend.apps.agents import agent_manager as am_mod + from backend.apps.agents.core import ws_manager as ws_mod + from backend.apps.agents.core.models import AgentSession + + launched = [] + messaged = [] + broadcasts = [] + + async def p_launch(config): + launched.append(config) + return AgentSession(id="sess-1", name=config.name, model=config.model, mode=config.mode) + + async def p_send(session_id, prompt): + messaged.append((session_id, prompt)) + + async def p_broadcast(event, payload): + broadcasts.append((event, payload)) + + monkeypatch.setattr(am_mod.agent_manager, "launch_agent", p_launch, raising=True) + monkeypatch.setattr(am_mod.agent_manager, "send_message", p_send, raising=True) + monkeypatch.setattr(ws_mod.ws_manager, "broadcast_global", p_broadcast, raising=True) + + r = client.post("/api/apps-sdk/agents/spawn", json={ + "prompt": "research crm tools", "name": "CRM scout", "x": 400, "y": 300, + }, headers=p_auth()) + assert r.status_code == 200, r.text + assert r.json() == {"session_id": "sess-1"} + assert launched[0].name == "CRM scout" and launched[0].prompt == "research crm tools" + assert broadcasts == [("apps_sdk:place_agent_card", + {"session_id": "sess-1", "dashboard_id": None, "x": 400.0, "y": 300.0})] + + +def test_spawn_agent_without_position_skips_the_broadcast(monkeypatch): + from backend.apps.agents import agent_manager as am_mod + from backend.apps.agents.core import ws_manager as ws_mod + from backend.apps.agents.core.models import AgentSession + + broadcasts = [] + + async def p_launch(config): + return AgentSession(id="sess-2", name=config.name, model=config.model, mode=config.mode) + + async def p_send(session_id, prompt): + return None + + async def p_broadcast(event, payload): + broadcasts.append(event) + + monkeypatch.setattr(am_mod.agent_manager, "launch_agent", p_launch, raising=True) + monkeypatch.setattr(am_mod.agent_manager, "send_message", p_send, raising=True) + monkeypatch.setattr(ws_mod.ws_manager, "broadcast_global", p_broadcast, raising=True) + + r = client.post("/api/apps-sdk/agents/spawn", json={"prompt": "hi"}, headers=p_auth()) + assert r.status_code == 200 + assert broadcasts == [] + + +def test_template_ships_both_sdk_helpers_and_the_skill_references_them(): + front = os.path.join(P_TEMPLATE, "frontend", "src", "openswarmHost.ts") + back = os.path.join(P_TEMPLATE, "backend", "apps", "openswarm_host", "openswarm_host.py") + guide = os.path.join(P_TEMPLATE, "SDK.md") + assert os.path.exists(front) and os.path.exists(back) and os.path.exists(guide) + skill_path = os.path.join(P_TEMPLATE, "..", "app_builder_skill.md") + with open(skill_path, "r", encoding="utf-8") as f: + skill = f.read() + assert "SDK.md" in skill and "openswarmHost" in skill and "openswarm_host" in skill + # The tools/MCP surface is deliberately not wired yet; the guide must not advertise it as available. + with open(guide, "r", encoding="utf-8") as f: + text = f.read() + assert "does NOT give you" in text diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index 7b36e50c..31dbfce3 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -30,6 +30,7 @@ import { import { streamStart, streamDelta, streamEnd, clearStreamingForSession } from '../state/streamingSlice'; import { addBrowserCardFromBackend, setBrowserDocked, markBrowserCardEnding, keepBrowserCardOpen, placeBesideCard, placeBelowCard, placeBrowserBesideChat, setBrowserCardPosition, setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards, removeBrowserCard, GRID_GAP, WORKFLOW_CARD_GAP, openWorkflowsApp, openWorkflowMonitor } from '../state/dashboardLayoutSlice'; import { upsertOutput } from '../state/outputsSlice'; +import { setCardPosition } from '../state/dashboardLayoutSlice'; import { fetchSettings } from '../state/settingsSlice'; import { displaySessionName } from '../state/sessionDisplay'; import { upsertRun, ackRun, runWorkflowNow, openWorkflowCard, upsertWorkflow, removeWorkflow } from '../state/workflowsSlice'; @@ -537,6 +538,26 @@ class WebSocketManager { } break; + case 'apps_sdk:place_agent_card': { + // An app asked for its spawned agent at a specific canvas spot; the card is created async + // by the session lifecycle, so nudge it into place with a short bounded retry. + const sid = data.session_id as string; + const px = Number(data.x); + const py = Number(data.y); + if (!sid || !Number.isFinite(px) || !Number.isFinite(py)) break; + let tries = 0; + const place = () => { + const exists = !!store.getState().dashboardLayout.cards[sid]; + if (exists) { + store.dispatch(setCardPosition({ sessionId: sid, x: px, y: py })); + return; + } + if (++tries < 20) setTimeout(place, 300); + }; + place(); + break; + } + case 'agent:stream_start': case 'agent:stream_delta': case 'agent:stream_end':