[eric] apps: the host SDK lands, apps call the user's models, fire workflows, and spawn positioned agents through pre-wired helpers

This commit is contained in:
ciregenz
2026-08-08 01:14:00 -07:00
parent 1f3c211146
commit 610ba00a9d
10 changed files with 584 additions and 1 deletions
View File
+121
View File
@@ -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)
+15
View File
@@ -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
@@ -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.
@@ -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}")
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: `Bearer ${hostToken()}`,
...(init?.headers as Record<string, string> | 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<T>;
}
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<string> {
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<WorkflowSummary[]> {
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<Record<string, unknown>> {
return hostFetch<Record<string, unknown>>(`/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<Record<string, unknown>[]> {
const data = await hostFetch<{ runs?: Record<string, unknown>[] }>('/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<string> {
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<Record<string, unknown>> {
return hostFetch<Record<string, unknown>>(`/api/agents/sessions/${encodeURIComponent(sessionId)}`);
}
+2 -1
View File
@@ -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.
+161
View File
@@ -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
@@ -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':