From aa937486278463ad68e2e0bab29c3b4860f4e1ad Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Fri, 16 Jan 2026 18:56:41 -0500 Subject: [PATCH 01/18] WIP --- crates/goose-server/src/openapi.rs | 5 +- crates/goose/src/agents/agent.rs | 22 + crates/goose/src/agents/apps_extension.rs | 704 +++++++++++++++++++ crates/goose/src/agents/extension.rs | 58 ++ crates/goose/src/agents/extension_manager.rs | 5 + crates/goose/src/agents/mod.rs | 1 + crates/goose/src/conversation/message.rs | 29 + crates/goose/src/goose_apps/mod.rs | 4 + ui/desktop/openapi.json | 8 + ui/desktop/src/api/types.gen.ts | 5 + ui/desktop/src/components/apps/AppsView.tsx | 60 ++ ui/desktop/src/hooks/useChatStream.ts | 33 +- 12 files changed, 926 insertions(+), 8 deletions(-) create mode 100644 crates/goose/src/agents/apps_extension.rs diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 7935a1acf4ac..992ef3fba4bf 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -20,8 +20,9 @@ use goose::config::declarative_providers::{ }; use goose::conversation::message::{ ActionRequired, ActionRequiredData, FrontendToolRequest, Message, MessageContent, - MessageMetadata, RedactedThinkingContent, SystemNotificationContent, SystemNotificationType, - ThinkingContent, TokenState, ToolConfirmationRequest, ToolRequest, ToolResponse, + MessageMetadata, RedactedThinkingContent, SystemNotificationContent, + SystemNotificationType, ThinkingContent, TokenState, ToolConfirmationRequest, ToolRequest, + ToolResponse, }; use crate::routes::recipe_utils::RecipeManifest; diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 42291dfd565a..ae4afdf04861 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -1278,6 +1278,28 @@ impl Agent { ToolStreamItem::Result(output) => { let output = call_tool_result::validate(output); + // Check for platform notification in tool result meta + if let Ok(ref call_result) = output { + if let Some(ref meta) = call_result.meta { + if let Some(notification_data) = meta.0.get("platform_notification") { + // Extract method and params from the notification data + if let Some(method) = notification_data.get("method").and_then(|v| v.as_str()) { + let params = notification_data.get("params").cloned(); + + // Create CustomNotification + let custom_notification = rmcp::model::CustomNotification::new( + method.to_string(), + params, + ); + + // Emit as ServerNotification + let server_notification = rmcp::model::ServerNotification::CustomNotification(custom_notification); + yield AgentEvent::McpNotification((request_id.clone(), server_notification)); + } + } + } + } + if enable_extension_request_ids.contains(&request_id) && output.is_err() { diff --git a/crates/goose/src/agents/apps_extension.rs b/crates/goose/src/agents/apps_extension.rs new file mode 100644 index 000000000000..8a429dcfe865 --- /dev/null +++ b/crates/goose/src/agents/apps_extension.rs @@ -0,0 +1,704 @@ +use crate::agents::extension::PlatformExtensionContext; +use crate::agents::mcp_client::{Error, McpClientTrait, McpMeta}; +use crate::config::paths::Paths; +use crate::conversation::message::Message; +use crate::goose_apps::GooseApp; +use crate::goose_apps::McpAppResource; +use crate::providers::base::Provider; +use async_trait::async_trait; +use rmcp::model::{ + CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListResourcesResult, + ListToolsResult, ProtocolVersion, RawResource, ReadResourceResult, Resource, + ResourceContents, ResourcesCapability, ServerCapabilities, Tool as McpTool, ToolsCapability, +}; +use schemars::{schema_for, JsonSchema}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::fs; +use std::path::PathBuf; +use std::sync::Arc; +use tokio_util::sync::CancellationToken; + +pub static EXTENSION_NAME: &str = "apps"; + +/// Parameters for create_app tool +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +struct CreateAppParams { + /// What the app should do - a description or PRD that will be used to generate the app + prd: String, +} + +/// Parameters for iterate_app tool +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +struct IterateAppParams { + /// Name of the app to iterate on + name: String, + /// Feedback or requested changes to improve the app + feedback: String, +} + +/// Parameters for delete_app tool +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +struct DeleteAppParams { + /// Name of the app to delete + name: String, +} + +pub struct AppsManagerClient { + info: InitializeResult, + context: PlatformExtensionContext, + apps_dir: PathBuf, +} + +impl AppsManagerClient { + pub fn new(context: PlatformExtensionContext) -> Result { + let apps_dir = Paths::in_data_dir("apps"); + + // Ensure apps directory exists + fs::create_dir_all(&apps_dir) + .map_err(|e| format!("Failed to create apps directory: {}", e))?; + + let info = InitializeResult { + protocol_version: ProtocolVersion::V_2025_03_26, + capabilities: ServerCapabilities { + tools: Some(ToolsCapability { + list_changed: Some(false), + }), + resources: Some(ResourcesCapability { + subscribe: Some(false), + list_changed: Some(false), + }), + prompts: None, + completions: None, + experimental: None, + logging: None, + }, + server_info: Implementation { + name: EXTENSION_NAME.to_string(), + title: Some("Apps Manager".to_string()), + version: "1.0.0".to_string(), + icons: None, + website_url: None, + }, + instructions: Some( + "Use this extension to create, manage, and iterate on custom HTML/CSS/JavaScript apps. \ + Apps are stored locally and can be viewed in the Apps section or launched in standalone windows. \ + \n\nTools:\n\ + - create_app: Create a new app\n\ + - update_app: Update an existing app's HTML or metadata\n\ + - delete_app: Delete an app\n\n\ + All apps are automatically exposed as ui://apps/{name} resources." + .to_string(), + ), + }; + + let mut client = Self { + info, + context, + apps_dir, + }; + + if let Err(e) = client.ensure_default_apps() { + tracing::warn!("Failed to create default apps: {}", e); + } + + Ok(client) + } + + /// Ensure default apps exist (like the clock) + fn ensure_default_apps(&mut self) -> Result<(), String> { + let apps = self.list_stored_apps()?; + + // If no apps exist, create the default clock app + if apps.is_empty() { + let clock_html = include_str!("../../resources/clock.html"); + let clock_app = GooseApp { + resource: McpAppResource { + uri: "ui://apps/clock".to_string(), + name: "clock".to_string(), + description: Some("A beautiful clock with multiple design themes (Digital, Analog, Swiss Railway)".to_string()), + mime_type: "text/html;profile=mcp-app".to_string(), + text: Some(clock_html.to_string()), + blob: None, + meta: None, + }, + mcp_server: Some("apps".to_string()), + window_props: None, + prd: Some("A clock app with three iconic design themes: Casio digital, Braun analog, and Swiss Railway. Users can switch between themes.".to_string()), + }; + self.save_app(&clock_app)?; + tracing::info!("Created default clock app"); + } + + Ok(()) + } + + /// List all stored apps + fn list_stored_apps(&self) -> Result, String> { + let mut apps = Vec::new(); + + let entries = fs::read_dir(&self.apps_dir) + .map_err(|e| format!("Failed to read apps directory: {}", e))?; + + for entry in entries { + let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?; + let path = entry.path(); + + if path.extension().and_then(|s| s.to_str()) == Some("json") { + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + apps.push(stem.to_string()); + } + } + } + + apps.sort(); + Ok(apps) + } + + /// Load an app from disk + fn load_app(&self, name: &str) -> Result { + let path = self.apps_dir.join(format!("{}.json", name)); + + if !path.exists() { + return Err(format!("App '{}' not found", name)); + } + + let contents = fs::read_to_string(&path) + .map_err(|e| format!("Failed to read app file: {}", e))?; + + let app: GooseApp = serde_json::from_str(&contents) + .map_err(|e| format!("Failed to parse app JSON: {}", e))?; + + Ok(app) + } + + /// Save an app to disk + fn save_app(&self, app: &GooseApp) -> Result<(), String> { + // Validate app name + let app_name = &app.resource.name; + if !is_valid_app_name(app_name) { + return Err(format!( + "Invalid app name '{}'. Use lowercase letters, numbers, and hyphens only.", + app_name + )); + } + + let path = self.apps_dir.join(format!("{}.json", app_name)); + + let json = serde_json::to_string_pretty(app) + .map_err(|e| format!("Failed to serialize app: {}", e))?; + + fs::write(&path, json) + .map_err(|e| format!("Failed to write app file: {}", e))?; + + Ok(()) + } + + /// Delete an app from disk + fn delete_app(&self, name: &str) -> Result<(), String> { + let path = self.apps_dir.join(format!("{}.json", name)); + + if !path.exists() { + return Err(format!("App '{}' not found", name)); + } + + fs::remove_file(&path) + .map_err(|e| format!("Failed to delete app file: {}", e))?; + + Ok(()) + } + + /// Get provider from extension manager + async fn get_provider(&self) -> Result, String> { + let extension_manager = self + .context + .extension_manager + .as_ref() + .and_then(|weak| weak.upgrade()) + .ok_or("Extension manager not available")?; + + let provider_guard = extension_manager.get_provider().lock().await; + + let provider = provider_guard + .as_ref() + .ok_or("Provider not available")? + .clone(); + + Ok(provider) + } + + /// Tool schema for creating a new app (returns name, description, HTML) + fn create_app_content_tool() -> rmcp::model::Tool { + use rmcp::object; + + rmcp::model::Tool::new( + "create_app_content".to_string(), + "Generate content for a new Goose app. Returns the HTML code, app name, and description.".to_string(), + object!({ + "type": "object", + "required": ["name", "description", "html"], + "properties": { + "name": { + "type": "string", + "description": "App name (lowercase, hyphens allowed, no spaces). Must be unique and not in the existing apps list." + }, + "description": { + "type": "string", + "description": "Brief description of what the app does (1-2 sentences, max 100 chars)" + }, + "html": { + "type": "string", + "description": "Complete HTML code for the app, from to " + } + } + }), + ) + } + + /// Tool schema for updating an existing app (returns description, HTML) + fn update_app_content_tool() -> rmcp::model::Tool { + use rmcp::object; + + rmcp::model::Tool::new( + "update_app_content".to_string(), + "Generate updated content for an existing Goose app. Returns the improved HTML code and updated description.".to_string(), + object!({ + "type": "object", + "required": ["description", "html"], + "properties": { + "description": { + "type": "string", + "description": "Updated description of what the app does (1-2 sentences, max 100 chars)" + }, + "html": { + "type": "string", + "description": "Complete updated HTML code for the app, from to " + } + } + }), + ) + } + + /// Generate content for a new app using the LLM with tool calling + async fn generate_new_app_content(&self, prd: &str) -> Result<(String, String, String), String> { + let provider = self.get_provider().await?; + + // Get list of existing app names + let existing_apps = self.list_stored_apps().unwrap_or_default(); + let existing_names = existing_apps.join(", "); + + let system_prompt = r#"You are an expert HTML/CSS/JavaScript developer. Generate standalone, single-file HTML applications. + +REQUIREMENTS: +- Create a complete, self-contained HTML file with embedded CSS and JavaScript +- Use modern, clean design with good UX +- Make it responsive and work well in different window sizes +- Use semantic HTML5 +- Add appropriate error handling +- Make the app interactive and functional +- Use vanilla JavaScript (no external dependencies unless absolutely necessary) +- If you need external resources (fonts, icons), use CDN links +- The app will be sandboxed with strict CSP, so all scripts must be inline or from trusted CDNs + +You must call the create_app_content tool to return the app name, description, and HTML."#; + + let user_prompt = format!( + "REQUESTED APP:\n{}\n\nEXISTING APPS: {}\n\nGenerate a unique name (lowercase with hyphens, not in existing apps), a brief description, and complete HTML for this app.", + prd, + if existing_names.is_empty() { "none" } else { &existing_names } + ); + + let messages = vec![Message::user().with_text(&user_prompt)]; + let tools = vec![Self::create_app_content_tool()]; + + let (response, _usage) = provider + .complete(system_prompt, &messages, &tools) + .await + .map_err(|e| format!("LLM call failed: {}", e))?; + + // Extract tool call from response + for content in &response.content { + if let crate::conversation::message::MessageContent::ToolRequest(tool_req) = content { + if let Ok(tool_call) = &tool_req.tool_call { + if tool_call.name == "create_app_content" { + let params = tool_call.arguments + .as_ref() + .ok_or("Missing tool call parameters")?; + + let name = params.get("name") + .and_then(|v: &serde_json::Value| v.as_str()) + .ok_or("Missing 'name' in tool call")? + .to_string(); + + let description = params.get("description") + .and_then(|v: &serde_json::Value| v.as_str()) + .ok_or("Missing 'description' in tool call")? + .to_string(); + + let html = params.get("html") + .and_then(|v: &serde_json::Value| v.as_str()) + .ok_or("Missing 'html' in tool call")? + .to_string(); + + return Ok((name, description, html)); + } + } + } + } + + Err("LLM did not call the required tool".to_string()) + } + + /// Generate updated content for an existing app using the LLM with tool calling + async fn generate_updated_app_content(&self, existing_html: &str, existing_prd: &str, feedback: &str) -> Result<(String, String), String> { + let provider = self.get_provider().await?; + + let system_prompt = r#"You are an expert HTML/CSS/JavaScript developer. Generate standalone, single-file HTML applications. + +REQUIREMENTS: +- Create a complete, self-contained HTML file with embedded CSS and JavaScript +- Use modern, clean design with good UX +- Make it responsive and work well in different window sizes +- Use semantic HTML5 +- Add appropriate error handling +- Make the app interactive and functional +- Use vanilla JavaScript (no external dependencies unless absolutely necessary) +- If you need external resources (fonts, icons), use CDN links +- The app will be sandboxed with strict CSP, so all scripts must be inline or from trusted CDNs + +You must call the update_app_content tool to return the updated description and HTML."#; + + let user_prompt = format!( + "ORIGINAL PRD:\n{}\n\nCURRENT APP:\n```html\n{}\n```\n\nFEEDBACK: {}\n\nGenerate an improved version with an updated description and HTML that addresses the feedback while preserving the app's core functionality.", + existing_prd, + existing_html, + feedback + ); + + let messages = vec![Message::user().with_text(&user_prompt)]; + let tools = vec![Self::update_app_content_tool()]; + + let (response, _usage) = provider + .complete(system_prompt, &messages, &tools) + .await + .map_err(|e| format!("LLM call failed: {}", e))?; + + // Extract tool call from response + for content in &response.content { + if let crate::conversation::message::MessageContent::ToolRequest(tool_req) = content { + if let Ok(tool_call) = &tool_req.tool_call { + if tool_call.name == "update_app_content" { + let params = tool_call.arguments + .as_ref() + .ok_or("Missing tool call parameters")?; + + let description = params.get("description") + .and_then(|v: &serde_json::Value| v.as_str()) + .ok_or("Missing 'description' in tool call")? + .to_string(); + + let html = params.get("html") + .and_then(|v: &serde_json::Value| v.as_str()) + .ok_or("Missing 'html' in tool call")? + .to_string(); + + return Ok((description, html)); + } + } + } + } + + Err("LLM did not call the required tool".to_string()) + } + + /// Handle create_app tool call + async fn handle_create_app( + &self, + arguments: Option, + _meta: McpMeta, + ) -> Result { + let args = arguments.ok_or("Missing arguments")?; + let prd = extract_string(&args, "prd")?; + + // Generate app content using LLM with tool calling + let (name, description, html) = self.generate_new_app_content(&prd).await?; + + // Validate the generated name + if !is_valid_app_name(&name) { + return Err(format!( + "LLM generated invalid app name '{}'. App names must be lowercase with hyphens only.", + name + )); + } + + // Check if app already exists + if self.load_app(&name).is_ok() { + return Err(format!( + "App '{}' already exists (generated name conflicts with existing app).", + name + )); + } + + let app = GooseApp { + resource: McpAppResource { + uri: format!("ui://apps/{}", name), + name: name.clone(), + description: Some(description), + mime_type: "text/html;profile=mcp-app".to_string(), + text: Some(html), + blob: None, + meta: None, + }, + mcp_server: Some("apps".to_string()), + window_props: None, + prd: Some(prd), + }; + + self.save_app(&app)?; + + let result = CallToolResult::success(vec![Content::text(format!( + "Created app '{}'. You can view it in the Apps section or open it with the resource uri: ui://apps/{}", + name, name + ))]); + + // Add platform notification + let mut params = serde_json::Map::new(); + params.insert("app_name".to_string(), json!(name)); + + let result = self.context.result_with_platform_notification( + result, + "apps", + "app_created", + params, + ); + + Ok(result) + } + + /// Handle iterate_app tool call + async fn handle_iterate_app( + &self, + arguments: Option, + _meta: McpMeta, + ) -> Result { + let args = arguments.ok_or("Missing arguments")?; + + let name = extract_string(&args, "name")?; + let feedback = extract_string(&args, "feedback")?; + + let mut app = self.load_app(&name)?; + + // Get existing HTML + let existing_html = app + .resource + .text + .as_deref() + .ok_or("App has no HTML content")?; + + // Get existing PRD + let existing_prd = app.prd.as_deref().unwrap_or(""); + + // Generate updated content using LLM with tool calling + let (description, html) = self + .generate_updated_app_content(existing_html, existing_prd, &feedback) + .await?; + + // Update app with new content + app.resource.text = Some(html); + app.resource.description = Some(description); + + // Optionally update PRD with feedback + if let Some(ref mut prd) = app.prd { + prd.push_str(&format!("\n\nIteration feedback: {}", feedback)); + } + + self.save_app(&app)?; + + let result = CallToolResult::success(vec![Content::text(format!( + "Updated app '{}' based on your feedback", + name + ))]); + + // Add platform notification + let mut params = serde_json::Map::new(); + params.insert("app_name".to_string(), json!(name)); + + let result = self.context.result_with_platform_notification( + result, + "apps", + "app_updated", + params, + ); + + Ok(result) + } + + /// Handle delete_app tool call + async fn handle_delete_app( + &self, + arguments: Option, + _meta: McpMeta, + ) -> Result { + let args = arguments.ok_or("Missing arguments")?; + + let name = extract_string(&args, "name")?; + + self.delete_app(&name)?; + + let result = CallToolResult::success(vec![Content::text(format!("Deleted app '{}'", name))]); + + // Add platform notification + let mut params = serde_json::Map::new(); + params.insert("app_name".to_string(), json!(name)); + + let result = self.context.result_with_platform_notification( + result, + "apps", + "app_deleted", + params, + ); + + Ok(result) + } +} + +#[async_trait] +impl McpClientTrait for AppsManagerClient { + async fn list_tools( + &self, + _next_cursor: Option, + _cancel_token: CancellationToken, + ) -> Result { + fn schema() -> JsonObject { + serde_json::to_value(schema_for!(T)) + .map(|v| v.as_object().unwrap().clone()) + .expect("valid schema") + } + + let tools = vec![ + McpTool::new( + "create_app".to_string(), + "Create a new Goose app based on a description or PRD. The extension will use an LLM to generate the HTML/CSS/JavaScript. Apps are sandboxed and run in standalone windows.".to_string(), + schema::(), + ), + McpTool::new( + "iterate_app".to_string(), + "Improve an existing app based on feedback. The extension will use an LLM to update the HTML while preserving the app's intent.".to_string(), + schema::(), + ), + McpTool::new( + "delete_app".to_string(), + "Delete an app permanently".to_string(), + schema::(), + ), + ]; + + Ok(ListToolsResult { + tools, + next_cursor: None, + meta: None, + }) + } + + async fn call_tool( + &self, + name: &str, + arguments: Option, + meta: McpMeta, + _cancel_token: CancellationToken, + ) -> Result { + let result = match name { + "create_app" => self.handle_create_app(arguments, meta).await, + "iterate_app" => self.handle_iterate_app(arguments, meta).await, + "delete_app" => self.handle_delete_app(arguments, meta).await, + _ => Err(format!("Unknown tool: {}", name)), + }; + + match result { + Ok(result) => Ok(result), + Err(error) => Ok(CallToolResult::error(vec![Content::text(format!( + "Error: {}", + error + ))])), + } + } + + async fn list_resources( + &self, + _next_cursor: Option, + _cancel_token: CancellationToken, + ) -> Result { + let app_names = self + .list_stored_apps() + .map_err(|_| Error::TransportClosed)?; + + let mut resources = Vec::new(); + + for name in app_names { + if let Ok(app) = self.load_app(&name) { + let raw_resource = RawResource { + uri: app.resource.uri.clone(), + name: app.resource.name.clone(), + title: None, + description: app.resource.description.clone(), + mime_type: Some(app.resource.mime_type.clone()), + size: None, + icons: None, + meta: None, + }; + resources.push(Resource { + raw: raw_resource, + annotations: None, + }); + } + } + + Ok(ListResourcesResult { + resources, + next_cursor: None, + meta: None, + }) + } + + async fn read_resource( + &self, + uri: &str, + _cancel_token: CancellationToken, + ) -> Result { + // Parse app name from URI (ui://apps/{name}) + let app_name = uri + .strip_prefix("ui://apps/") + .ok_or(Error::TransportClosed)?; + + let app = self.load_app(app_name).map_err(|_| Error::TransportClosed)?; + + let html = app + .resource + .text + .unwrap_or_else(|| String::from("No content")); + + Ok(ReadResourceResult { + contents: vec![ResourceContents::text(html, uri)], + }) + } + + fn get_info(&self) -> Option<&InitializeResult> { + Some(&self.info) + } +} + +/// Validate app name (lowercase, numbers, hyphens only) +fn is_valid_app_name(name: &str) -> bool { + !name.is_empty() + && name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + && !name.starts_with('-') + && !name.ends_with('-') +} + +/// Extract a string from JSON arguments +fn extract_string(args: &JsonObject, key: &str) -> Result { + args.get(key) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| format!("Missing or invalid '{}'", key)) +} diff --git a/crates/goose/src/agents/extension.rs b/crates/goose/src/agents/extension.rs index 8e046924902a..dca1570337b7 100644 --- a/crates/goose/src/agents/extension.rs +++ b/crates/goose/src/agents/extension.rs @@ -1,3 +1,4 @@ +use crate::agents::apps_extension; use crate::agents::chatrecall_extension; use crate::agents::code_execution_extension; use crate::agents::extension_manager_extension; @@ -54,6 +55,17 @@ pub static PLATFORM_EXTENSIONS: Lazy }, ); + map.insert( + apps_extension::EXTENSION_NAME, + PlatformExtensionDef { + name: apps_extension::EXTENSION_NAME, + description: + "Create and manage custom Goose apps through chat. Apps are HTML/CSS/JavaScript and run in sandboxed windows.", + default_enabled: true, + client_factory: |ctx| Box::new(apps_extension::AppsManagerClient::new(ctx).unwrap()), + }, + ); + map.insert( chatrecall_extension::EXTENSION_NAME, PlatformExtensionDef { @@ -111,6 +123,52 @@ pub struct PlatformExtensionContext { pub session_manager: std::sync::Arc, } +impl PlatformExtensionContext { + /// Helper method to attach a platform notification to a tool result. + /// The notification will be emitted as a MessageEvent::Notification to update client state. + /// + /// # Arguments + /// * `result` - The CallToolResult to attach the notification to + /// * `extension_name` - Name of the extension emitting the event (e.g., "apps") + /// * `event_type` - Type of event (e.g., "app_created", "app_updated") + /// * `additional_params` - Additional parameters to include in the notification + pub fn result_with_platform_notification( + &self, + mut result: rmcp::model::CallToolResult, + extension_name: impl Into, + event_type: impl Into, + mut additional_params: serde_json::Map, + ) -> rmcp::model::CallToolResult { + // Add core fields + additional_params.insert("extension".to_string(), extension_name.into().into()); + additional_params.insert("event_type".to_string(), event_type.into().into()); + + // Store notification in meta for agent loop to process + let meta_value = serde_json::json!({ + "platform_notification": { + "method": "platform_event", + "params": additional_params + } + }); + + if let Some(ref mut meta) = result.meta { + // Merge with existing meta + if let Some(obj) = meta_value.as_object() { + for (k, v) in obj { + meta.0.insert(k.clone(), v.clone()); + } + } + } else { + // Create new meta + result.meta = Some(rmcp::model::Meta( + meta_value.as_object().unwrap().clone() + )); + } + + result + } +} + #[derive(Debug, Clone)] pub struct PlatformExtensionDef { pub name: &'static str, diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index ea50600f7587..652265392aab 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -466,6 +466,11 @@ impl ExtensionManager { &self.context } + /// Get the provider + pub fn get_provider(&self) -> &SharedProvider { + &self.provider + } + /// Resolve the working directory for an extension. /// Falls back to current_dir when working_dir is not available. async fn resolve_working_dir(&self) -> PathBuf { diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs index c6bf9706ee09..8298e87e91fa 100644 --- a/crates/goose/src/agents/mod.rs +++ b/crates/goose/src/agents/mod.rs @@ -1,4 +1,5 @@ mod agent; +pub(crate) mod apps_extension; pub(crate) mod chatrecall_extension; pub(crate) mod code_execution_extension; pub mod execute_commands; diff --git a/crates/goose/src/conversation/message.rs b/crates/goose/src/conversation/message.rs index a2bb3402b3cd..bf663168e3ef 100644 --- a/crates/goose/src/conversation/message.rs +++ b/crates/goose/src/conversation/message.rs @@ -171,6 +171,8 @@ pub enum SystemNotificationType { pub struct SystemNotificationContent { pub notification_type: SystemNotificationType, pub msg: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)] @@ -369,6 +371,19 @@ impl MessageContent { MessageContent::SystemNotification(SystemNotificationContent { notification_type, msg: msg.into(), + data: None, + }) + } + + pub fn system_notification_with_data>( + notification_type: SystemNotificationType, + msg: S, + data: serde_json::Value, + ) -> Self { + MessageContent::SystemNotification(SystemNotificationContent { + notification_type, + msg: msg.into(), + data: Some(data), }) } @@ -816,6 +831,20 @@ impl Message { .with_metadata(MessageMetadata::user_only()) } + pub fn with_system_notification_with_data>( + self, + notification_type: SystemNotificationType, + msg: S, + data: serde_json::Value, + ) -> Self { + self.with_content(MessageContent::system_notification_with_data( + notification_type, + msg, + data, + )) + .with_metadata(MessageMetadata::user_only()) + } + /// Set the visibility metadata for the message pub fn with_visibility(mut self, user_visible: bool, agent_visible: bool) -> Self { self.metadata.user_visible = user_visible; diff --git a/crates/goose/src/goose_apps/mod.rs b/crates/goose/src/goose_apps/mod.rs index b93bf27aee08..c22f4c90fa01 100644 --- a/crates/goose/src/goose_apps/mod.rs +++ b/crates/goose/src/goose_apps/mod.rs @@ -31,6 +31,9 @@ pub struct GooseApp { pub mcp_server: Option, #[serde(flatten, skip_serializing_if = "Option::is_none")] pub window_props: Option, + /// Product requirements document for LLM-based iteration + #[serde(skip_serializing_if = "Option::is_none")] + pub prd: Option, } pub struct McpAppCache { @@ -171,6 +174,7 @@ pub async fn fetch_mcp_apps( height: 600, resizable: true, }), + prd: None, }; apps.push(app); diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index d27bdaabd338..742a7ff9a1fa 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -3876,6 +3876,11 @@ "mcpServer": { "type": "string", "nullable": true + }, + "prd": { + "type": "string", + "description": "Product requirements document for LLM-based iteration", + "nullable": true } } } @@ -5930,6 +5935,9 @@ "msg" ], "properties": { + "data": { + "nullable": true + }, "msg": { "type": "string" }, diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 6a2e2e9fc64d..a36f0e456d02 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -369,6 +369,10 @@ export type GetToolsQuery = { */ export type GooseApp = McpAppResource & (WindowProps | null) & { mcpServer?: string | null; + /** + * Product requirements document for LLM-based iteration + */ + prd?: string | null; }; export type Icon = { @@ -1018,6 +1022,7 @@ export type SystemInfo = { }; export type SystemNotificationContent = { + data?: unknown; msg: string; notificationType: SystemNotificationType; }; diff --git a/ui/desktop/src/components/apps/AppsView.tsx b/ui/desktop/src/components/apps/AppsView.tsx index b40487c7d641..f25a694adcff 100644 --- a/ui/desktop/src/components/apps/AppsView.tsx +++ b/ui/desktop/src/components/apps/AppsView.tsx @@ -73,6 +73,66 @@ export default function AppsView() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId]); + // Listen for platform events (app created/updated/deleted) and handle accordingly + useEffect(() => { + const handlePlatformEvent = (event: Event) => { + const customEvent = event as CustomEvent; + const eventData = customEvent.detail; + + if (eventData?.extension === 'apps') { + const { event_type, app_name } = eventData; + const eventSessionId = eventData.sessionId || sessionId; + + // Refresh apps list to get latest state + if (eventSessionId) { + listApps({ + throwOnError: false, + query: { session_id: eventSessionId }, + }).then((response) => { + if (response.data?.apps) { + setApps(response.data.apps); + + // Handle specific event types + const targetApp = response.data.apps.find((app) => app.name === app_name); + + switch (event_type) { + case 'app_created': + // Open the newly created app + if (targetApp) { + window.electron.launchApp(targetApp).catch((err) => { + console.error('Failed to launch newly created app:', err); + }); + } + break; + + case 'app_updated': + // Refresh the app if it's currently open + if (targetApp) { + window.electron.refreshApp(targetApp).catch((err) => { + console.error('Failed to refresh updated app:', err); + }); + } + break; + + case 'app_deleted': + // Close the app if it's currently open + if (app_name) { + window.electron.closeApp(app_name).catch((err) => { + console.error('Failed to close deleted app:', err); + }); + } + break; + } + } + }); + } + } + }; + + window.addEventListener('platform-event', handlePlatformEvent); + return () => window.removeEventListener('platform-event', handlePlatformEvent); + }, [sessionId]); + const loadApps = useCallback(async () => { if (!sessionId) return; diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts index 439f2c7a303a..fcb4bf5cd808 100644 --- a/ui/desktop/src/hooks/useChatStream.ts +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -82,7 +82,8 @@ async function streamFromResponse( updateTokenState: (tokenState: TokenState) => void, updateChatState: (state: ChatState) => void, updateNotifications: (notification: NotificationEvent) => void, - onFinish: (error?: string) => void + onFinish: (error?: string) => void, + sessionId: string ): Promise { let currentMessages = initialMessages; @@ -136,6 +137,21 @@ async function streamFromResponse( } case 'Notification': { updateNotifications(event as NotificationEvent); + + // Check if this is a platform event notification + // NOTE: If we add more notification types beyond platform_event, consider + // implementing a registry pattern to map notification methods to handlers + if (event.message && typeof event.message === 'object' && 'method' in event.message) { + const notification = event.message as { method?: string; params?: unknown }; + if (notification.method === 'platform_event' && notification.params) { + // Dispatch window event with sessionId included + window.dispatchEvent( + new CustomEvent('platform-event', { + detail: { ...notification.params, sessionId }, + }) + ); + } + } break; } case 'Ping': @@ -184,9 +200,12 @@ export function useChatStream({ messagesRef.current = newMessages; }, []); - const updateNotifications = useCallback((notification: NotificationEvent) => { - setNotifications((prev) => [...prev, notification]); - }, []); + const updateNotifications = useCallback( + (notification: NotificationEvent) => { + setNotifications((prev) => [...prev, notification]); + }, + [] + ); const onFinish = useCallback( async (error?: string): Promise => { @@ -351,7 +370,8 @@ export function useChatStream({ setTokenState, setChatState, updateNotifications, - onFinish + onFinish, + sessionId ); } catch (error) { // AbortError is expected when user stops streaming @@ -399,7 +419,8 @@ export function useChatStream({ setTokenState, setChatState, updateNotifications, - onFinish + onFinish, + sessionId ); } catch (error) { if (error instanceof Error && error.name === 'AbortError') { From 620a598fc6f833cc0719e9daad562b2592aa1895 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Sun, 18 Jan 2026 19:59:11 -0500 Subject: [PATCH 02/18] Vibe-dy vibe --- crates/goose-server/src/openapi.rs | 9 +- crates/goose-server/src/routes/agent.rs | 117 +++++ crates/goose/src/agents/apps_extension.rs | 409 ++++++++++-------- crates/goose/src/agents/extension.rs | 4 +- crates/goose/src/goose_apps/mod.rs | 247 ++++++++++- ui/desktop/openapi.json | 137 ++++++ ui/desktop/src/App.tsx | 6 + ui/desktop/src/api/index.ts | 4 +- ui/desktop/src/api/sdk.gen.ts | 13 +- ui/desktop/src/api/types.gen.ts | 72 +++ .../components/GooseSidebar/AppSidebar.tsx | 27 +- ui/desktop/src/components/apps/AppsView.tsx | 131 ++++-- .../src/components/apps/StandaloneAppView.tsx | 3 +- ui/desktop/src/hooks/useChatStream.ts | 26 +- ui/desktop/src/main.ts | 45 +- ui/desktop/src/preload.ts | 4 + ui/desktop/src/utils/conversionUtils.ts | 12 + ui/desktop/src/utils/platform_events.ts | 129 ++++++ 18 files changed, 1104 insertions(+), 291 deletions(-) create mode 100644 ui/desktop/src/utils/platform_events.ts diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 992ef3fba4bf..fb53d76f3e8d 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -20,9 +20,8 @@ use goose::config::declarative_providers::{ }; use goose::conversation::message::{ ActionRequired, ActionRequiredData, FrontendToolRequest, Message, MessageContent, - MessageMetadata, RedactedThinkingContent, SystemNotificationContent, - SystemNotificationType, ThinkingContent, TokenState, ToolConfirmationRequest, ToolRequest, - ToolResponse, + MessageMetadata, RedactedThinkingContent, SystemNotificationContent, SystemNotificationType, + ThinkingContent, TokenState, ToolConfirmationRequest, ToolRequest, ToolResponse, }; use crate::routes::recipe_utils::RecipeManifest; @@ -363,6 +362,8 @@ derive_utoipa!(Icon as IconSchema); super::routes::agent::read_resource, super::routes::agent::call_tool, super::routes::agent::list_apps, + super::routes::agent::export_app, + super::routes::agent::import_app, super::routes::agent::update_from_session, super::routes::agent::agent_add_extension, super::routes::agent::agent_remove_extension, @@ -538,6 +539,8 @@ derive_utoipa!(Icon as IconSchema); super::routes::agent::CallToolResponse, super::routes::agent::ListAppsRequest, super::routes::agent::ListAppsResponse, + super::routes::agent::ImportAppRequest, + super::routes::agent::ImportAppResponse, super::routes::agent::StartAgentRequest, super::routes::agent::ResumeAgentRequest, super::routes::agent::StopAgentRequest, diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index 9bb75ed5d9d8..20c31f7b2ad3 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -1015,6 +1015,121 @@ async fn list_apps( Ok(Json(ListAppsResponse { apps })) } +#[utoipa::path( + get, + path = "/agent/export_app/{name}", + params( + ("name" = String, Path, description = "Name of the app to export") + ), + responses( + (status = 200, description = "App HTML exported successfully", body = String), + (status = 404, description = "App not found", body = ErrorResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ), + security( + ("api_key" = []) + ), + tag = "Agent" +)] +async fn export_app( + axum::extract::Path(name): axum::extract::Path, +) -> Result { + let cache = McpAppCache::new().map_err(|e| ErrorResponse { + message: format!("Failed to access app cache: {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; + + let apps = cache.list_apps().map_err(|e| ErrorResponse { + message: format!("Failed to list apps: {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; + + let app = apps + .into_iter() + .find(|a| a.resource.name == name) + .ok_or_else(|| ErrorResponse { + message: format!("App '{}' not found", name), + status: StatusCode::NOT_FOUND, + })?; + + let html = app.to_html().map_err(|e| ErrorResponse { + message: format!("Failed to generate HTML: {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; + + Ok(html) +} + +#[derive(Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ImportAppRequest { + pub html: String, +} + +#[derive(Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ImportAppResponse { + pub name: String, + pub message: String, +} + +#[utoipa::path( + post, + path = "/agent/import_app", + request_body = ImportAppRequest, + responses( + (status = 201, description = "App imported successfully", body = ImportAppResponse), + (status = 400, description = "Bad request - Invalid HTML", body = ErrorResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ), + security( + ("api_key" = []) + ), + tag = "Agent" +)] +async fn import_app( + Json(body): Json, +) -> Result<(StatusCode, Json), ErrorResponse> { + let cache = McpAppCache::new().map_err(|e| ErrorResponse { + message: format!("Failed to access app cache: {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; + + let mut app = GooseApp::from_html(&body.html).map_err(|e| ErrorResponse { + message: format!("Invalid Goose App HTML: {}", e), + status: StatusCode::BAD_REQUEST, + })?; + + // Handle name conflicts by appending counter + let original_name = app.resource.name.clone(); + let mut counter = 1; + + let existing_apps = cache.list_apps().unwrap_or_default(); + let existing_names: std::collections::HashSet = existing_apps + .iter() + .map(|a| a.resource.name.clone()) + .collect(); + + while existing_names.contains(&app.resource.name) { + app.resource.name = format!("{}_{}", original_name, counter); + app.resource.uri = format!("ui://apps/{}", app.resource.name); + counter += 1; + } + + cache.store_app(&app).map_err(|e| ErrorResponse { + message: format!("Failed to store app: {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; + + Ok(( + StatusCode::CREATED, + Json(ImportAppResponse { + name: app.resource.name.clone(), + message: format!("App '{}' imported successfully", app.resource.name), + }), + )) +} + pub fn routes(state: Arc) -> Router { Router::new() .route("/agent/start", post(start_agent)) @@ -1025,6 +1140,8 @@ pub fn routes(state: Arc) -> Router { .route("/agent/read_resource", post(read_resource)) .route("/agent/call_tool", post(call_tool)) .route("/agent/list_apps", get(list_apps)) + .route("/agent/export_app/{name}", get(export_app)) + .route("/agent/import_app", post(import_app)) .route("/agent/update_provider", post(update_agent_provider)) .route("/agent/update_from_session", post(update_from_session)) .route("/agent/add_extension", post(agent_add_extension)) diff --git a/crates/goose/src/agents/apps_extension.rs b/crates/goose/src/agents/apps_extension.rs index 8a429dcfe865..8d818e8b098c 100644 --- a/crates/goose/src/agents/apps_extension.rs +++ b/crates/goose/src/agents/apps_extension.rs @@ -8,7 +8,7 @@ use crate::providers::base::Provider; use async_trait::async_trait; use rmcp::model::{ CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListResourcesResult, - ListToolsResult, ProtocolVersion, RawResource, ReadResourceResult, Resource, + ListToolsResult, Meta, ProtocolVersion, RawResource, ReadResourceResult, Resource, ResourceContents, ResourcesCapability, ServerCapabilities, Tool as McpTool, ToolsCapability, }; use schemars::{schema_for, JsonSchema}; @@ -44,6 +44,46 @@ struct DeleteAppParams { name: String, } +/// Parameters for list_apps tool +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +struct ListAppsParams { + // No parameters needed - lists all apps +} + +/// Response from create_app_content tool +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +struct CreateAppContentResponse { + /// App name (lowercase, hyphens allowed, no spaces) + name: String, + /// Brief description of what the app does (1-2 sentences, max 100 chars) + description: String, + /// Complete HTML code for the app, from to + html: String, + /// Window width in pixels (recommended: 400-1600) + width: Option, + /// Window height in pixels (recommended: 300-1200) + height: Option, + /// Whether the window should be resizable + resizable: Option, +} + +/// Response from update_app_content tool +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +struct UpdateAppContentResponse { + /// Updated description of what the app does (1-2 sentences, max 100 chars) + description: String, + /// Complete updated HTML code for the app, from to + html: String, + /// Updated PRD reflecting the current state of the app after this iteration + prd: String, + /// Updated window width in pixels (optional - only if size should change) + width: Option, + /// Updated window height in pixels (optional - only if size should change) + height: Option, + /// Updated resizable property (optional - only if it should change) + resizable: Option, +} + pub struct AppsManagerClient { info: InitializeResult, context: PlatformExtensionContext, @@ -81,26 +121,20 @@ impl AppsManagerClient { website_url: None, }, instructions: Some( - "Use this extension to create, manage, and iterate on custom HTML/CSS/JavaScript apps. \ - Apps are stored locally and can be viewed in the Apps section or launched in standalone windows. \ - \n\nTools:\n\ - - create_app: Create a new app\n\ - - update_app: Update an existing app's HTML or metadata\n\ - - delete_app: Delete an app\n\n\ - All apps are automatically exposed as ui://apps/{name} resources." + "Use this extension to create, manage, and iterate on custom HTML/CSS/JavaScript apps." .to_string(), ), }; - let mut client = Self { + let client = Self { info, context, apps_dir, }; - if let Err(e) = client.ensure_default_apps() { - tracing::warn!("Failed to create default apps: {}", e); - } + // if let Err(e) = client.ensure_default_apps() { + // tracing::warn!("Failed to create default apps: {}", e); + // } Ok(client) } @@ -133,7 +167,6 @@ impl AppsManagerClient { Ok(()) } - /// List all stored apps fn list_stored_apps(&self) -> Result, String> { let mut apps = Vec::new(); @@ -144,7 +177,7 @@ impl AppsManagerClient { let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?; let path = entry.path(); - if path.extension().and_then(|s| s.to_str()) == Some("json") { + if path.extension().and_then(|s| s.to_str()) == Some("html") { if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { apps.push(stem.to_string()); } @@ -155,60 +188,33 @@ impl AppsManagerClient { Ok(apps) } - /// Load an app from disk fn load_app(&self, name: &str) -> Result { - let path = self.apps_dir.join(format!("{}.json", name)); - - if !path.exists() { - return Err(format!("App '{}' not found", name)); - } + let path = self.apps_dir.join(format!("{}.html", name)); - let contents = fs::read_to_string(&path) - .map_err(|e| format!("Failed to read app file: {}", e))?; + let html = + fs::read_to_string(&path).map_err(|e| format!("Failed to read app file: {}", e))?; - let app: GooseApp = serde_json::from_str(&contents) - .map_err(|e| format!("Failed to parse app JSON: {}", e))?; - - Ok(app) + GooseApp::from_html(&html) } - /// Save an app to disk fn save_app(&self, app: &GooseApp) -> Result<(), String> { - // Validate app name - let app_name = &app.resource.name; - if !is_valid_app_name(app_name) { - return Err(format!( - "Invalid app name '{}'. Use lowercase letters, numbers, and hyphens only.", - app_name - )); - } + let path = self.apps_dir.join(format!("{}.html", app.resource.name)); - let path = self.apps_dir.join(format!("{}.json", app_name)); + let html_content = app.to_html()?; - let json = serde_json::to_string_pretty(app) - .map_err(|e| format!("Failed to serialize app: {}", e))?; - - fs::write(&path, json) - .map_err(|e| format!("Failed to write app file: {}", e))?; + fs::write(&path, html_content).map_err(|e| format!("Failed to write app file: {}", e))?; Ok(()) } - /// Delete an app from disk fn delete_app(&self, name: &str) -> Result<(), String> { - let path = self.apps_dir.join(format!("{}.json", name)); - - if !path.exists() { - return Err(format!("App '{}' not found", name)); - } + let path = self.apps_dir.join(format!("{}.html", name)); - fs::remove_file(&path) - .map_err(|e| format!("Failed to delete app file: {}", e))?; + fs::remove_file(&path).map_err(|e| format!("Failed to delete app file: {}", e))?; Ok(()) } - /// Get provider from extension manager async fn get_provider(&self) -> Result, String> { let extension_manager = self .context @@ -227,63 +233,34 @@ impl AppsManagerClient { Ok(provider) } - /// Tool schema for creating a new app (returns name, description, HTML) - fn create_app_content_tool() -> rmcp::model::Tool { - use rmcp::object; + fn schema() -> JsonObject { + serde_json::to_value(schema_for!(T)) + .map(|v| v.as_object().unwrap().clone()) + .expect("valid schema") + } + fn create_app_content_tool() -> rmcp::model::Tool { rmcp::model::Tool::new( "create_app_content".to_string(), - "Generate content for a new Goose app. Returns the HTML code, app name, and description.".to_string(), - object!({ - "type": "object", - "required": ["name", "description", "html"], - "properties": { - "name": { - "type": "string", - "description": "App name (lowercase, hyphens allowed, no spaces). Must be unique and not in the existing apps list." - }, - "description": { - "type": "string", - "description": "Brief description of what the app does (1-2 sentences, max 100 chars)" - }, - "html": { - "type": "string", - "description": "Complete HTML code for the app, from to " - } - } - }), + "Generate content for a new Goose app. Returns the HTML code, app name, description, and window properties.".to_string(), + Self::schema::(), ) } - /// Tool schema for updating an existing app (returns description, HTML) fn update_app_content_tool() -> rmcp::model::Tool { - use rmcp::object; - rmcp::model::Tool::new( "update_app_content".to_string(), - "Generate updated content for an existing Goose app. Returns the improved HTML code and updated description.".to_string(), - object!({ - "type": "object", - "required": ["description", "html"], - "properties": { - "description": { - "type": "string", - "description": "Updated description of what the app does (1-2 sentences, max 100 chars)" - }, - "html": { - "type": "string", - "description": "Complete updated HTML code for the app, from to " - } - } - }), + "Generate updated content for an existing Goose app. Returns the improved HTML code, updated description, and optionally updated window properties.".to_string(), + Self::schema::(), ) } - /// Generate content for a new app using the LLM with tool calling - async fn generate_new_app_content(&self, prd: &str) -> Result<(String, String, String), String> { + async fn generate_new_app_content( + &self, + prd: &str, + ) -> Result { let provider = self.get_provider().await?; - // Get list of existing app names let existing_apps = self.list_stored_apps().unwrap_or_default(); let existing_names = existing_apps.join(", "); @@ -300,10 +277,15 @@ REQUIREMENTS: - If you need external resources (fonts, icons), use CDN links - The app will be sandboxed with strict CSP, so all scripts must be inline or from trusted CDNs -You must call the create_app_content tool to return the app name, description, and HTML."#; +WINDOW SIZING: +- Choose appropriate width and height based on the app's content and layout +- Typical sizes: small utilities (400x300), standard apps (800x600), large apps (1200x800) +- Set resizable to false for fixed-size apps, true for flexible layouts + +You must call the create_app_content tool to return the app name, description, HTML, and window properties."#; let user_prompt = format!( - "REQUESTED APP:\n{}\n\nEXISTING APPS: {}\n\nGenerate a unique name (lowercase with hyphens, not in existing apps), a brief description, and complete HTML for this app.", + "REQUESTED APP:\n{}\n\nEXISTING APPS: {}\n\nGenerate a unique name (lowercase with hyphens, not in existing apps), a brief description, complete HTML, and appropriate window size for this app.", prd, if existing_names.is_empty() { "none" } else { &existing_names } ); @@ -321,26 +303,16 @@ You must call the create_app_content tool to return the app name, description, a if let crate::conversation::message::MessageContent::ToolRequest(tool_req) = content { if let Ok(tool_call) = &tool_req.tool_call { if tool_call.name == "create_app_content" { - let params = tool_call.arguments + let params = tool_call + .arguments .as_ref() .ok_or("Missing tool call parameters")?; - let name = params.get("name") - .and_then(|v: &serde_json::Value| v.as_str()) - .ok_or("Missing 'name' in tool call")? - .to_string(); - - let description = params.get("description") - .and_then(|v: &serde_json::Value| v.as_str()) - .ok_or("Missing 'description' in tool call")? - .to_string(); + let response: CreateAppContentResponse = + serde_json::from_value(serde_json::Value::Object(params.clone())) + .map_err(|e| format!("Failed to parse tool response: {}", e))?; - let html = params.get("html") - .and_then(|v: &serde_json::Value| v.as_str()) - .ok_or("Missing 'html' in tool call")? - .to_string(); - - return Ok((name, description, html)); + return Ok(response); } } } @@ -350,7 +322,12 @@ You must call the create_app_content tool to return the app name, description, a } /// Generate updated content for an existing app using the LLM with tool calling - async fn generate_updated_app_content(&self, existing_html: &str, existing_prd: &str, feedback: &str) -> Result<(String, String), String> { + async fn generate_updated_app_content( + &self, + existing_html: &str, + existing_prd: &str, + feedback: &str, + ) -> Result { let provider = self.get_provider().await?; let system_prompt = r#"You are an expert HTML/CSS/JavaScript developer. Generate standalone, single-file HTML applications. @@ -366,10 +343,21 @@ REQUIREMENTS: - If you need external resources (fonts, icons), use CDN links - The app will be sandboxed with strict CSP, so all scripts must be inline or from trusted CDNs -You must call the update_app_content tool to return the updated description and HTML."#; +WINDOW SIZING: +- Optionally update width/height if the changes warrant a different window size +- Only include size properties if they should change +- Set resizable to false for fixed-size apps, true for flexible layouts + +PRD UPDATE: +- Update the PRD to reflect the current state of the app after implementing the feedback +- Keep the core requirements but add/update sections based on what was actually changed +- Document new features, changed behavior, or updated requirements +- Keep the PRD concise and focused on what the app should do, not implementation details + +You must call the update_app_content tool to return the updated description, HTML, updated PRD, and optionally updated window properties."#; let user_prompt = format!( - "ORIGINAL PRD:\n{}\n\nCURRENT APP:\n```html\n{}\n```\n\nFEEDBACK: {}\n\nGenerate an improved version with an updated description and HTML that addresses the feedback while preserving the app's core functionality.", + "ORIGINAL PRD:\n{}\n\nCURRENT APP:\n```html\n{}\n```\n\nFEEDBACK: {}\n\nImplement the requested changes and return:\n1. Updated description\n2. Updated HTML implementing the feedback\n3. Updated PRD reflecting the current state of the app\n4. Optionally updated window size if appropriate", existing_prd, existing_html, feedback @@ -388,21 +376,16 @@ You must call the update_app_content tool to return the updated description and if let crate::conversation::message::MessageContent::ToolRequest(tool_req) = content { if let Ok(tool_call) = &tool_req.tool_call { if tool_call.name == "update_app_content" { - let params = tool_call.arguments + let params = tool_call + .arguments .as_ref() .ok_or("Missing tool call parameters")?; - let description = params.get("description") - .and_then(|v: &serde_json::Value| v.as_str()) - .ok_or("Missing 'description' in tool call")? - .to_string(); - - let html = params.get("html") - .and_then(|v: &serde_json::Value| v.as_str()) - .ok_or("Missing 'html' in tool call")? - .to_string(); + let response: UpdateAppContentResponse = + serde_json::from_value(serde_json::Value::Object(params.clone())) + .map_err(|e| format!("Failed to parse tool response: {}", e))?; - return Ok((description, html)); + return Ok(response); } } } @@ -411,7 +394,50 @@ You must call the update_app_content tool to return the updated description and Err("LLM did not call the required tool".to_string()) } - /// Handle create_app tool call + /// Handle list_apps tool call + async fn handle_list_apps( + &self, + _arguments: Option, + _meta: McpMeta, + ) -> Result { + let app_names = self.list_stored_apps()?; + + if app_names.is_empty() { + return Ok(CallToolResult::success(vec![Content::text( + "No apps found. Create your first app with the create_app tool!".to_string(), + )])); + } + + let mut apps_info = vec![format!("Found {} app(s):\n", app_names.len())]; + + for name in app_names { + match self.load_app(&name) { + Ok(app) => { + let description = app + .resource + .description + .as_deref() + .unwrap_or("No description"); + + let size = if let Some(ref props) = app.window_props { + format!(" ({}x{})", props.width, props.height) + } else { + String::new() + }; + + apps_info.push(format!("- {}{}: {}", name, size, description)); + } + Err(e) => { + apps_info.push(format!("- {}: (error loading: {})", name, e)); + } + } + } + + Ok(CallToolResult::success(vec![Content::text( + apps_info.join("\n"), + )])) + } + async fn handle_create_app( &self, arguments: Option, @@ -421,56 +447,52 @@ You must call the update_app_content tool to return the updated description and let prd = extract_string(&args, "prd")?; // Generate app content using LLM with tool calling - let (name, description, html) = self.generate_new_app_content(&prd).await?; - - // Validate the generated name - if !is_valid_app_name(&name) { - return Err(format!( - "LLM generated invalid app name '{}'. App names must be lowercase with hyphens only.", - name - )); - } + let content = self.generate_new_app_content(&prd).await?; + tracing::info!("LLM generated app name: '{}'", content.name); // Check if app already exists - if self.load_app(&name).is_ok() { + if self.load_app(&content.name).is_ok() { return Err(format!( "App '{}' already exists (generated name conflicts with existing app).", - name + content.name )); } let app = GooseApp { resource: McpAppResource { - uri: format!("ui://apps/{}", name), - name: name.clone(), - description: Some(description), + uri: format!("ui://apps/{}", content.name), + name: content.name.clone(), + description: Some(content.description), mime_type: "text/html;profile=mcp-app".to_string(), - text: Some(html), + text: Some(content.html), blob: None, meta: None, }, mcp_server: Some("apps".to_string()), - window_props: None, + window_props: Some(crate::goose_apps::WindowProps { + width: content.width.unwrap_or(800), + height: content.height.unwrap_or(600), + resizable: content.resizable.unwrap_or(true), + }), prd: Some(prd), }; self.save_app(&app)?; + tracing::info!("Saved app with name: '{}'", content.name); let result = CallToolResult::success(vec![Content::text(format!( - "Created app '{}'. You can view it in the Apps section or open it with the resource uri: ui://apps/{}", - name, name + "Created app '{}'! It should have automatically opened in a new window. You can always find it again in the [Apps] tab.", + content.name ))]); // Add platform notification let mut params = serde_json::Map::new(); - params.insert("app_name".to_string(), json!(name)); + params.insert("app_name".to_string(), json!(content.name)); + tracing::info!("Sending platform notification for app: '{}'", content.name); - let result = self.context.result_with_platform_notification( - result, - "apps", - "app_created", - params, - ); + let result = + self.context + .result_with_platform_notification(result, "apps", "app_created", params); Ok(result) } @@ -499,17 +521,29 @@ You must call the update_app_content tool to return the updated description and let existing_prd = app.prd.as_deref().unwrap_or(""); // Generate updated content using LLM with tool calling - let (description, html) = self + let content = self .generate_updated_app_content(existing_html, existing_prd, &feedback) .await?; // Update app with new content - app.resource.text = Some(html); - app.resource.description = Some(description); - - // Optionally update PRD with feedback - if let Some(ref mut prd) = app.prd { - prd.push_str(&format!("\n\nIteration feedback: {}", feedback)); + app.resource.text = Some(content.html); + app.resource.description = Some(content.description); + + // Update PRD from LLM response - keeps HTML and PRD in sync + app.prd = Some(content.prd); + + // Update window properties if provided + if content.width.is_some() || content.height.is_some() || content.resizable.is_some() { + let current_props = app.window_props.as_ref(); + let default_width = current_props.map(|p| p.width).unwrap_or(800); + let default_height = current_props.map(|p| p.height).unwrap_or(600); + let default_resizable = current_props.map(|p| p.resizable).unwrap_or(true); + + app.window_props = Some(crate::goose_apps::WindowProps { + width: content.width.unwrap_or(default_width), + height: content.height.unwrap_or(default_height), + resizable: content.resizable.unwrap_or(default_resizable), + }); } self.save_app(&app)?; @@ -523,12 +557,9 @@ You must call the update_app_content tool to return the updated description and let mut params = serde_json::Map::new(); params.insert("app_name".to_string(), json!(name)); - let result = self.context.result_with_platform_notification( - result, - "apps", - "app_updated", - params, - ); + let result = + self.context + .result_with_platform_notification(result, "apps", "app_updated", params); Ok(result) } @@ -545,18 +576,16 @@ You must call the update_app_content tool to return the updated description and self.delete_app(&name)?; - let result = CallToolResult::success(vec![Content::text(format!("Deleted app '{}'", name))]); + let result = + CallToolResult::success(vec![Content::text(format!("Deleted app '{}'", name))]); // Add platform notification let mut params = serde_json::Map::new(); params.insert("app_name".to_string(), json!(name)); - let result = self.context.result_with_platform_notification( - result, - "apps", - "app_deleted", - params, - ); + let result = + self.context + .result_with_platform_notification(result, "apps", "app_deleted", params); Ok(result) } @@ -576,6 +605,11 @@ impl McpClientTrait for AppsManagerClient { } let tools = vec![ + McpTool::new( + "list_apps".to_string(), + "List all available Goose apps with their names and descriptions. Use this to see what apps exist before creating or modifying apps.".to_string(), + schema::(), + ), McpTool::new( "create_app".to_string(), "Create a new Goose app based on a description or PRD. The extension will use an LLM to generate the HTML/CSS/JavaScript. Apps are sandboxed and run in standalone windows.".to_string(), @@ -608,6 +642,7 @@ impl McpClientTrait for AppsManagerClient { _cancel_token: CancellationToken, ) -> Result { let result = match name { + "list_apps" => self.handle_list_apps(arguments, meta).await, "create_app" => self.handle_create_app(arguments, meta).await, "iterate_app" => self.handle_iterate_app(arguments, meta).await, "delete_app" => self.handle_delete_app(arguments, meta).await, @@ -636,6 +671,22 @@ impl McpClientTrait for AppsManagerClient { for name in app_names { if let Ok(app) = self.load_app(&name) { + // Build meta with window properties if available + let meta = if let Some(ref window_props) = app.window_props { + let mut meta_obj = Meta::new(); + meta_obj.insert( + "window".to_string(), + json!({ + "width": window_props.width, + "height": window_props.height, + "resizable": window_props.resizable, + }), + ); + Some(meta_obj) + } else { + None + }; + let raw_resource = RawResource { uri: app.resource.uri.clone(), name: app.resource.name.clone(), @@ -644,7 +695,7 @@ impl McpClientTrait for AppsManagerClient { mime_type: Some(app.resource.mime_type.clone()), size: None, icons: None, - meta: None, + meta, }; resources.push(Resource { raw: raw_resource, @@ -670,8 +721,12 @@ impl McpClientTrait for AppsManagerClient { .strip_prefix("ui://apps/") .ok_or(Error::TransportClosed)?; - let app = self.load_app(app_name).map_err(|_| Error::TransportClosed)?; + let app = self + .load_app(app_name) + .map_err(|_| Error::TransportClosed)?; + // Return the clean HTML without embedded metadata + // The metadata (window props, PRD) is exposed via list_resources meta field let html = app .resource .text @@ -687,14 +742,6 @@ impl McpClientTrait for AppsManagerClient { } } -/// Validate app name (lowercase, numbers, hyphens only) -fn is_valid_app_name(name: &str) -> bool { - !name.is_empty() - && name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') - && !name.starts_with('-') - && !name.ends_with('-') -} - /// Extract a string from JSON arguments fn extract_string(args: &JsonObject, key: &str) -> Result { args.get(key) diff --git a/crates/goose/src/agents/extension.rs b/crates/goose/src/agents/extension.rs index dca1570337b7..59290c1039a5 100644 --- a/crates/goose/src/agents/extension.rs +++ b/crates/goose/src/agents/extension.rs @@ -160,9 +160,7 @@ impl PlatformExtensionContext { } } else { // Create new meta - result.meta = Some(rmcp::model::Meta( - meta_value.as_object().unwrap().clone() - )); + result.meta = Some(rmcp::model::Meta(meta_value.as_object().unwrap().clone())); } result diff --git a/crates/goose/src/goose_apps/mod.rs b/crates/goose/src/goose_apps/mod.rs index c22f4c90fa01..8bf0c079e07d 100644 --- a/crates/goose/src/goose_apps/mod.rs +++ b/crates/goose/src/goose_apps/mod.rs @@ -36,6 +36,187 @@ pub struct GooseApp { pub prd: Option, } +impl GooseApp { + const METADATA_SCRIPT_TYPE: &'static str = "application/ld+json"; + const PRD_SCRIPT_TYPE: &'static str = "application/x-goose-prd"; + const GOOSE_APP_TYPE: &'static str = "GooseApp"; + const GOOSE_SCHEMA_CONTEXT: &'static str = "https://goose.ai/schema"; + + /// Parse a GooseApp from HTML with embedded metadata + pub fn from_html(html: &str) -> Result { + use regex::Regex; + + let metadata_re = Regex::new(&format!( + r#"(?s)"#, + regex::escape(Self::METADATA_SCRIPT_TYPE) + )) + .map_err(|e| format!("Regex error: {}", e))?; + + let prd_re = Regex::new(&format!( + r#"(?s)"#, + regex::escape(Self::PRD_SCRIPT_TYPE) + )) + .map_err(|e| format!("Regex error: {}", e))?; + + // Extract metadata JSON + let json_str = metadata_re + .captures(html) + .and_then(|cap| cap.get(1)) + .ok_or_else(|| "No GooseApp JSON-LD metadata found in HTML".to_string())? + .as_str(); + + let metadata: serde_json::Value = serde_json::from_str(json_str) + .map_err(|e| format!("Failed to parse metadata JSON: {}", e))?; + + // Extract fields from metadata + let name = metadata + .get("name") + .and_then(|v| v.as_str()) + .ok_or("Missing 'name' in metadata")? + .to_string(); + + let description = metadata + .get("description") + .and_then(|v| v.as_str()) + .map(String::from); + + let width = metadata + .get("width") + .and_then(|v| v.as_u64()) + .map(|v| v as u32); + let height = metadata + .get("height") + .and_then(|v| v.as_u64()) + .map(|v| v as u32); + let resizable = metadata.get("resizable").and_then(|v| v.as_bool()); + + let window_props = if width.is_some() || height.is_some() || resizable.is_some() { + Some(WindowProps { + width: width.unwrap_or(800), + height: height.unwrap_or(600), + resizable: resizable.unwrap_or(true), + }) + } else { + None + }; + + let mcp_server = metadata + .get("mcpServer") + .and_then(|v| v.as_str()) + .map(String::from); + + // Extract PRD + let prd = prd_re + .captures(html) + .and_then(|cap| cap.get(1)) + .map(|m| m.as_str().trim().to_string()); + + // Strip metadata and PRD scripts from HTML + let clean_html = metadata_re.replace(html, ""); + let clean_html = prd_re.replace(&clean_html, "").to_string(); + + Ok(GooseApp { + resource: McpAppResource { + uri: format!("ui://apps/{}", name), + name, + description, + mime_type: "text/html;profile=mcp-app".to_string(), + text: Some(clean_html), + blob: None, + meta: None, + }, + mcp_server, + window_props, + prd, + }) + } + + /// Convert GooseApp to HTML with embedded metadata + pub fn to_html(&self) -> Result { + let html = self + .resource + .text + .as_ref() + .ok_or("App has no HTML content")?; + + // Build metadata JSON + let mut metadata = serde_json::json!({ + "@context": Self::GOOSE_SCHEMA_CONTEXT, + "@type": Self::GOOSE_APP_TYPE, + "name": self.resource.name, + }); + + if let Some(ref desc) = self.resource.description { + metadata["description"] = serde_json::json!(desc); + } + + if let Some(ref props) = self.window_props { + metadata["width"] = serde_json::json!(props.width); + metadata["height"] = serde_json::json!(props.height); + metadata["resizable"] = serde_json::json!(props.resizable); + } + + if let Some(ref server) = self.mcp_server { + metadata["mcpServer"] = serde_json::json!(server); + } + + let metadata_json = serde_json::to_string_pretty(&metadata) + .map_err(|e| format!("Failed to serialize metadata: {}", e))?; + + let metadata_script = format!( + " ", + Self::METADATA_SCRIPT_TYPE, + metadata_json + ); + + let prd_script = if let Some(ref prd) = self.prd { + if !prd.is_empty() { + format!( + " ", + Self::PRD_SCRIPT_TYPE, + prd + ) + } else { + String::new() + } + } else { + String::new() + }; + + let scripts = if prd_script.is_empty() { + format!("{}\n", metadata_script) + } else { + format!("{}\n{}\n", metadata_script, prd_script) + }; + + // Insert scripts into HTML + let result = if let Some(head_pos) = html.find("") { + let mut result = html.clone(); + result.insert_str(head_pos, &scripts); + result + } else if let Some(html_pos) = html.find("')) + .map(|p| html_pos + p + 1); + if let Some(pos) = after_html { + let mut result = html.clone(); + result.insert_str(pos, &format!("\n\n{}", scripts)); + result + } else { + format!("\n{}\n{}", scripts, html) + } + } else { + format!( + "\n\n{}\n\n{}\n\n", + scripts, html + ) + }; + + Ok(result) + } +} + pub struct McpAppCache { cache_dir: PathBuf, } @@ -158,7 +339,7 @@ pub async fn fetch_mcp_apps( if !html.is_empty() { let mcp_resource = McpAppResource { uri: resource.uri.clone(), - name: format_resource_name(resource.name.clone()), + name: resource.name.clone(), description: resource.description.clone(), mime_type: "text/html;profile=mcp-app".to_string(), text: Some(html), @@ -166,14 +347,54 @@ pub async fn fetch_mcp_apps( meta: None, }; - let app = GooseApp { - resource: mcp_resource, - mcp_server: Some(extension_name), - window_props: Some(WindowProps { + // Extract window properties from resource meta.window if present + let window_props = if let Some(ref meta) = resource.meta { + if let Some(window_obj) = meta.get("window").and_then(|v| v.as_object()) { + if let (Some(width), Some(height), Some(resizable)) = ( + window_obj + .get("width") + .and_then(|v| v.as_u64()) + .map(|v| v as u32), + window_obj + .get("height") + .and_then(|v| v.as_u64()) + .map(|v| v as u32), + window_obj.get("resizable").and_then(|v| v.as_bool()), + ) { + Some(WindowProps { + width, + height, + resizable, + }) + } else { + // Window object exists but doesn't have complete props + Some(WindowProps { + width: 800, + height: 600, + resizable: true, + }) + } + } else { + // Meta exists but no window object - use defaults + Some(WindowProps { + width: 800, + height: 600, + resizable: true, + }) + } + } else { + // No meta - use defaults + Some(WindowProps { width: 800, height: 600, resizable: true, - }), + }) + }; + + let app = GooseApp { + resource: mcp_resource, + mcp_server: Some(extension_name), + window_props, prd: None, }; @@ -191,17 +412,3 @@ pub async fn fetch_mcp_apps( Ok(apps) } - -fn format_resource_name(name: String) -> String { - name.replace('_', " ") - .split_whitespace() - .map(|word| { - let mut chars = word.chars(); - match chars.next() { - None => String::new(), - Some(first) => first.to_uppercase().chain(chars).collect(), - } - }) - .collect::>() - .join(" ") -} diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 742a7ff9a1fa..0199065f5afc 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -128,6 +128,117 @@ } } }, + "/agent/export_app/{name}": { + "get": { + "tags": [ + "Agent" + ], + "operationId": "export_app", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Name of the app to export", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "App HTML exported successfully", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "App not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, + "/agent/import_app": { + "post": { + "tags": [ + "Agent" + ], + "operationId": "import_app", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportAppRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "App imported successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportAppResponse" + } + } + } + }, + "400": { + "description": "Bad request - Invalid HTML", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, "/agent/list_apps": { "get": { "tags": [ @@ -3936,6 +4047,32 @@ } } }, + "ImportAppRequest": { + "type": "object", + "required": [ + "html" + ], + "properties": { + "html": { + "type": "string" + } + } + }, + "ImportAppResponse": { + "type": "object", + "required": [ + "name", + "message" + ], + "properties": { + "message": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "ImportSessionRequest": { "type": "object", "required": [ diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 2c2cfba8a142..9c02d73ee041 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -47,6 +47,7 @@ import { errorMessage } from './utils/conversionUtils'; import { getInitialWorkingDir } from './utils/workingDir'; import { usePageViewTracking } from './hooks/useAnalytics'; import { trackOnboardingCompleted, trackErrorWithContext } from './utils/analytics'; +import { registerPlatformEventHandlers } from './utils/platform_events'; function PageViewTracker() { usePageViewTracking(); @@ -553,6 +554,11 @@ export function AppInner() { }; }, [navigate]); + // Register platform event handlers for app lifecycle management + useEffect(() => { + return registerPlatformEventHandlers(); + }, []); + if (fatalError) { return ; } diff --git a/ui/desktop/src/api/index.ts b/ui/desktop/src/api/index.ts index afff8c37ec1f..f0a432d2b24e 100644 --- a/ui/desktop/src/api/index.ts +++ b/ui/desktop/src/api/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { addExtension, agentAddExtension, agentRemoveExtension, backupConfig, callTool, checkProvider, confirmToolAction, createCustomProvider, createRecipe, createSchedule, decodeRecipe, deleteRecipe, deleteSchedule, deleteSession, detectProvider, diagnostics, editMessage, encodeRecipe, exportSession, getCustomProvider, getExtensions, getPricing, getProviderModels, getSession, getSessionExtensions, getSessionInsights, getSlashCommands, getTools, getTunnelStatus, importSession, initConfig, inspectRunningJob, killRunningJob, listApps, listRecipes, listSchedules, listSessions, mcpUiProxy, type Options, parseRecipe, pauseSchedule, providers, readAllConfig, readConfig, readResource, recipeToYaml, recoverConfig, removeConfig, removeCustomProvider, removeExtension, reply, restartAgent, resumeAgent, runNowHandler, saveRecipe, scanRecipe, scheduleRecipe, sendTelemetryEvent, sessionsHandler, setConfigProvider, setRecipeSlashCommand, startAgent, startOpenrouterSetup, startTetrateSetup, startTunnel, status, stopAgent, stopTunnel, systemInfo, unpauseSchedule, updateAgentProvider, updateCustomProvider, updateFromSession, updateSchedule, updateSessionName, updateSessionUserRecipeValues, updateWorkingDir, upsertConfig, upsertPermissions, validateConfig } from './sdk.gen'; -export type { ActionRequired, ActionRequiredData, AddExtensionData, AddExtensionErrors, AddExtensionRequest, AddExtensionResponse, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponse, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponse, AgentRemoveExtensionResponses, Annotations, Author, AuthorRequest, BackupConfigData, BackupConfigErrors, BackupConfigResponse, BackupConfigResponses, CallToolData, CallToolErrors, CallToolRequest, CallToolResponse, CallToolResponse2, CallToolResponses, ChatRequest, CheckProviderData, CheckProviderRequest, ClientOptions, CommandType, ConfigKey, ConfigKeyQuery, ConfigResponse, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionRequest, ConfirmToolActionResponses, Content, Conversation, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponse, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeRequest, CreateRecipeResponse, CreateRecipeResponse2, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleRequest, CreateScheduleResponse, CreateScheduleResponses, CspMetadata, DeclarativeProviderConfig, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeRequest, DecodeRecipeResponse, DecodeRecipeResponse2, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeRequest, DeleteRecipeResponse, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponse, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderRequest, DetectProviderResponse, DetectProviderResponse2, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponse, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageRequest, EditMessageResponse, EditMessageResponse2, EditMessageResponses, EditType, EmbeddedResource, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeRequest, EncodeRecipeResponse, EncodeRecipeResponse2, EncodeRecipeResponses, Envs, ErrorResponse, ExportSessionData, ExportSessionErrors, ExportSessionResponse, ExportSessionResponses, ExtensionConfig, ExtensionData, ExtensionEntry, ExtensionLoadResult, ExtensionQuery, ExtensionResponse, FrontendToolRequest, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponse, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponse, GetExtensionsResponses, GetPricingData, GetPricingResponse, GetPricingResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponse, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponse, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponse, GetSessionInsightsResponses, GetSessionResponse, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponse, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsQuery, GetToolsResponse, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponse, GetTunnelStatusResponses, GooseApp, Icon, ImageContent, ImportSessionData, ImportSessionErrors, ImportSessionRequest, ImportSessionResponse, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponse, InitConfigResponses, InspectJobResponse, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponse, InspectRunningJobResponses, JsonObject, KillJobResponse, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsError, ListAppsErrors, ListAppsRequest, ListAppsResponse, ListAppsResponse2, ListAppsResponses, ListRecipeResponse, ListRecipesData, ListRecipesErrors, ListRecipesResponse, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponse, ListSchedulesResponse2, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponse, ListSessionsResponses, LoadedProvider, McpAppResource, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, Message, MessageContent, MessageEvent, MessageMetadata, ModelConfig, ModelInfo, ParseRecipeData, ParseRecipeError, ParseRecipeErrors, ParseRecipeRequest, ParseRecipeResponse, ParseRecipeResponse2, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponse, PauseScheduleResponses, PermissionLevel, PricingData, PricingQuery, PricingResponse, PrincipalType, ProviderDetails, ProviderEngine, ProviderMetadata, ProvidersData, ProvidersResponse, ProvidersResponse2, ProvidersResponses, ProviderType, RawAudioContent, RawEmbeddedResource, RawImageContent, RawResource, RawTextContent, ReadAllConfigData, ReadAllConfigResponse, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceRequest, ReadResourceResponse, ReadResourceResponse2, ReadResourceResponses, Recipe, RecipeManifest, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement, RecipeToYamlData, RecipeToYamlError, RecipeToYamlErrors, RecipeToYamlRequest, RecipeToYamlResponse, RecipeToYamlResponse2, RecipeToYamlResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponse, RecoverConfigResponses, RedactedThinkingContent, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponse, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponse, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionRequest, RemoveExtensionResponse, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponse, ReplyResponses, ResourceContents, ResourceMetadata, Response, RestartAgentData, RestartAgentErrors, RestartAgentRequest, RestartAgentResponse, RestartAgentResponse2, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentRequest, ResumeAgentResponse, ResumeAgentResponse2, ResumeAgentResponses, RetryConfig, Role, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponse, RunNowHandlerResponses, RunNowResponse, SaveRecipeData, SaveRecipeError, SaveRecipeErrors, SaveRecipeRequest, SaveRecipeResponse, SaveRecipeResponse2, SaveRecipeResponses, ScanRecipeData, ScanRecipeRequest, ScanRecipeResponse, ScanRecipeResponse2, ScanRecipeResponses, ScheduledJob, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeRequest, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, Session, SessionDisplayInfo, SessionExtensionsResponse, SessionInsights, SessionListResponse, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponse, SessionsHandlerResponses, SessionsQuery, SessionType, SetConfigProviderData, SetProviderRequest, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, SetSlashCommandRequest, Settings, SetupResponse, SlashCommand, SlashCommandsResponse, StartAgentData, StartAgentError, StartAgentErrors, StartAgentRequest, StartAgentResponse, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponse, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponse, StartTetrateSetupResponses, StartTunnelData, StartTunnelError, StartTunnelErrors, StartTunnelResponse, StartTunnelResponses, StatusData, StatusResponse, StatusResponses, StopAgentData, StopAgentErrors, StopAgentRequest, StopAgentResponse, StopAgentResponses, StopTunnelData, StopTunnelError, StopTunnelErrors, StopTunnelResponses, SubRecipe, SuccessCheck, SystemInfo, SystemInfoData, SystemInfoResponse, SystemInfoResponses, SystemNotificationContent, SystemNotificationType, TelemetryEventRequest, TextContent, ThinkingContent, TokenState, Tool, ToolAnnotations, ToolConfirmationRequest, ToolInfo, ToolPermission, ToolRequest, ToolResponse, TunnelInfo, TunnelState, UiMetadata, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponse, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderRequest, UpdateCustomProviderResponse, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionRequest, UpdateFromSessionResponses, UpdateProviderRequest, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleRequest, UpdateScheduleResponse, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameRequest, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesError, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesRequest, UpdateSessionUserRecipeValuesResponse, UpdateSessionUserRecipeValuesResponse2, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirRequest, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigQuery, UpsertConfigResponse, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsQuery, UpsertPermissionsResponse, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponse, ValidateConfigResponses, WindowProps } from './types.gen'; +export { addExtension, agentAddExtension, agentRemoveExtension, backupConfig, callTool, checkProvider, confirmToolAction, createCustomProvider, createRecipe, createSchedule, decodeRecipe, deleteRecipe, deleteSchedule, deleteSession, detectProvider, diagnostics, editMessage, encodeRecipe, exportApp, exportSession, getCustomProvider, getExtensions, getPricing, getProviderModels, getSession, getSessionExtensions, getSessionInsights, getSlashCommands, getTools, getTunnelStatus, importApp, importSession, initConfig, inspectRunningJob, killRunningJob, listApps, listRecipes, listSchedules, listSessions, mcpUiProxy, type Options, parseRecipe, pauseSchedule, providers, readAllConfig, readConfig, readResource, recipeToYaml, recoverConfig, removeConfig, removeCustomProvider, removeExtension, reply, restartAgent, resumeAgent, runNowHandler, saveRecipe, scanRecipe, scheduleRecipe, sendTelemetryEvent, sessionsHandler, setConfigProvider, setRecipeSlashCommand, startAgent, startOpenrouterSetup, startTetrateSetup, startTunnel, status, stopAgent, stopTunnel, systemInfo, unpauseSchedule, updateAgentProvider, updateCustomProvider, updateFromSession, updateSchedule, updateSessionName, updateSessionUserRecipeValues, updateWorkingDir, upsertConfig, upsertPermissions, validateConfig } from './sdk.gen'; +export type { ActionRequired, ActionRequiredData, AddExtensionData, AddExtensionErrors, AddExtensionRequest, AddExtensionResponse, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponse, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponse, AgentRemoveExtensionResponses, Annotations, Author, AuthorRequest, BackupConfigData, BackupConfigErrors, BackupConfigResponse, BackupConfigResponses, CallToolData, CallToolErrors, CallToolRequest, CallToolResponse, CallToolResponse2, CallToolResponses, ChatRequest, CheckProviderData, CheckProviderRequest, ClientOptions, CommandType, ConfigKey, ConfigKeyQuery, ConfigResponse, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionRequest, ConfirmToolActionResponses, Content, Conversation, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponse, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeRequest, CreateRecipeResponse, CreateRecipeResponse2, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleRequest, CreateScheduleResponse, CreateScheduleResponses, CspMetadata, DeclarativeProviderConfig, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeRequest, DecodeRecipeResponse, DecodeRecipeResponse2, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeRequest, DeleteRecipeResponse, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponse, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderRequest, DetectProviderResponse, DetectProviderResponse2, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponse, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageRequest, EditMessageResponse, EditMessageResponse2, EditMessageResponses, EditType, EmbeddedResource, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeRequest, EncodeRecipeResponse, EncodeRecipeResponse2, EncodeRecipeResponses, Envs, ErrorResponse, ExportAppData, ExportAppError, ExportAppErrors, ExportAppResponse, ExportAppResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponse, ExportSessionResponses, ExtensionConfig, ExtensionData, ExtensionEntry, ExtensionLoadResult, ExtensionQuery, ExtensionResponse, FrontendToolRequest, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponse, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponse, GetExtensionsResponses, GetPricingData, GetPricingResponse, GetPricingResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponse, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponse, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponse, GetSessionInsightsResponses, GetSessionResponse, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponse, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsQuery, GetToolsResponse, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponse, GetTunnelStatusResponses, GooseApp, Icon, ImageContent, ImportAppData, ImportAppError, ImportAppErrors, ImportAppRequest, ImportAppResponse, ImportAppResponse2, ImportAppResponses, ImportSessionData, ImportSessionErrors, ImportSessionRequest, ImportSessionResponse, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponse, InitConfigResponses, InspectJobResponse, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponse, InspectRunningJobResponses, JsonObject, KillJobResponse, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsError, ListAppsErrors, ListAppsRequest, ListAppsResponse, ListAppsResponse2, ListAppsResponses, ListRecipeResponse, ListRecipesData, ListRecipesErrors, ListRecipesResponse, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponse, ListSchedulesResponse2, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponse, ListSessionsResponses, LoadedProvider, McpAppResource, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, Message, MessageContent, MessageEvent, MessageMetadata, ModelConfig, ModelInfo, ParseRecipeData, ParseRecipeError, ParseRecipeErrors, ParseRecipeRequest, ParseRecipeResponse, ParseRecipeResponse2, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponse, PauseScheduleResponses, PermissionLevel, PricingData, PricingQuery, PricingResponse, PrincipalType, ProviderDetails, ProviderEngine, ProviderMetadata, ProvidersData, ProvidersResponse, ProvidersResponse2, ProvidersResponses, ProviderType, RawAudioContent, RawEmbeddedResource, RawImageContent, RawResource, RawTextContent, ReadAllConfigData, ReadAllConfigResponse, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceRequest, ReadResourceResponse, ReadResourceResponse2, ReadResourceResponses, Recipe, RecipeManifest, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement, RecipeToYamlData, RecipeToYamlError, RecipeToYamlErrors, RecipeToYamlRequest, RecipeToYamlResponse, RecipeToYamlResponse2, RecipeToYamlResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponse, RecoverConfigResponses, RedactedThinkingContent, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponse, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponse, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionRequest, RemoveExtensionResponse, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponse, ReplyResponses, ResourceContents, ResourceMetadata, Response, RestartAgentData, RestartAgentErrors, RestartAgentRequest, RestartAgentResponse, RestartAgentResponse2, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentRequest, ResumeAgentResponse, ResumeAgentResponse2, ResumeAgentResponses, RetryConfig, Role, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponse, RunNowHandlerResponses, RunNowResponse, SaveRecipeData, SaveRecipeError, SaveRecipeErrors, SaveRecipeRequest, SaveRecipeResponse, SaveRecipeResponse2, SaveRecipeResponses, ScanRecipeData, ScanRecipeRequest, ScanRecipeResponse, ScanRecipeResponse2, ScanRecipeResponses, ScheduledJob, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeRequest, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, Session, SessionDisplayInfo, SessionExtensionsResponse, SessionInsights, SessionListResponse, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponse, SessionsHandlerResponses, SessionsQuery, SessionType, SetConfigProviderData, SetProviderRequest, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, SetSlashCommandRequest, Settings, SetupResponse, SlashCommand, SlashCommandsResponse, StartAgentData, StartAgentError, StartAgentErrors, StartAgentRequest, StartAgentResponse, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponse, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponse, StartTetrateSetupResponses, StartTunnelData, StartTunnelError, StartTunnelErrors, StartTunnelResponse, StartTunnelResponses, StatusData, StatusResponse, StatusResponses, StopAgentData, StopAgentErrors, StopAgentRequest, StopAgentResponse, StopAgentResponses, StopTunnelData, StopTunnelError, StopTunnelErrors, StopTunnelResponses, SubRecipe, SuccessCheck, SystemInfo, SystemInfoData, SystemInfoResponse, SystemInfoResponses, SystemNotificationContent, SystemNotificationType, TelemetryEventRequest, TextContent, ThinkingContent, TokenState, Tool, ToolAnnotations, ToolConfirmationRequest, ToolInfo, ToolPermission, ToolRequest, ToolResponse, TunnelInfo, TunnelState, UiMetadata, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponse, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderRequest, UpdateCustomProviderResponse, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionRequest, UpdateFromSessionResponses, UpdateProviderRequest, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleRequest, UpdateScheduleResponse, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameRequest, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesError, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesRequest, UpdateSessionUserRecipeValuesResponse, UpdateSessionUserRecipeValuesResponse2, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirRequest, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigQuery, UpsertConfigResponse, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsQuery, UpsertPermissionsResponse, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponse, ValidateConfigResponses, WindowProps } from './types.gen'; diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index c420e84bd65a..6f53db0c919b 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CallToolData, CallToolErrors, CallToolResponses, CheckProviderData, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetPricingData, GetPricingResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsErrors, ListAppsResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecipeToYamlData, RecipeToYamlErrors, RecipeToYamlResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, RestartAgentData, RestartAgentErrors, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopAgentData, StopAgentErrors, StopAgentResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, SystemInfoData, SystemInfoResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; +import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CallToolData, CallToolErrors, CallToolResponses, CheckProviderData, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportAppData, ExportAppErrors, ExportAppResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetPricingData, GetPricingResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportAppData, ImportAppErrors, ImportAppResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsErrors, ListAppsResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecipeToYamlData, RecipeToYamlErrors, RecipeToYamlResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, RestartAgentData, RestartAgentErrors, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopAgentData, StopAgentErrors, StopAgentResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, SystemInfoData, SystemInfoResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; export type Options = Options2 & { /** @@ -45,6 +45,17 @@ export const callTool = (options: Options< } }); +export const exportApp = (options: Options) => (options.client ?? client).get({ url: '/agent/export_app/{name}', ...options }); + +export const importApp = (options: Options) => (options.client ?? client).post({ + url: '/agent/import_app', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + export const listApps = (options?: Options) => (options?.client ?? client).get({ url: '/agent/list_apps', ...options }); export const readResource = (options: Options) => (options.client ?? client).post({ diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index a36f0e456d02..a0f062b68cc1 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -392,6 +392,15 @@ export type ImageContent = { mimeType: string; }; +export type ImportAppRequest = { + html: string; +}; + +export type ImportAppResponse = { + message: string; + name: string; +}; + export type ImportSessionRequest = { json: string; }; @@ -1314,6 +1323,69 @@ export type CallToolResponses = { export type CallToolResponse2 = CallToolResponses[keyof CallToolResponses]; +export type ExportAppData = { + body?: never; + path: { + /** + * Name of the app to export + */ + name: string; + }; + query?: never; + url: '/agent/export_app/{name}'; +}; + +export type ExportAppErrors = { + /** + * App not found + */ + 404: ErrorResponse; + /** + * Internal server error + */ + 500: ErrorResponse; +}; + +export type ExportAppError = ExportAppErrors[keyof ExportAppErrors]; + +export type ExportAppResponses = { + /** + * App HTML exported successfully + */ + 200: string; +}; + +export type ExportAppResponse = ExportAppResponses[keyof ExportAppResponses]; + +export type ImportAppData = { + body: ImportAppRequest; + path?: never; + query?: never; + url: '/agent/import_app'; +}; + +export type ImportAppErrors = { + /** + * Bad request - Invalid HTML + */ + 400: ErrorResponse; + /** + * Internal server error + */ + 500: ErrorResponse; +}; + +export type ImportAppError = ImportAppErrors[keyof ImportAppErrors]; + +export type ImportAppResponses = { + /** + * App imported successfully + */ + 201: ImportAppResponse; +}; + +export type ImportAppResponse2 = ImportAppResponses[keyof ImportAppResponses]; + export type ListAppsData = { body?: never; path?: never; diff --git a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx b/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx index 02787717ff7f..4cb902d793f8 100644 --- a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx +++ b/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef } from 'react'; import { FileText, Clock, Home, Puzzle, History, AppWindow } from 'lucide-react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { @@ -16,7 +16,7 @@ import { ViewOptions, View } from '../../utils/navigationUtils'; import { useChatContext } from '../../contexts/ChatContext'; import { DEFAULT_CHAT_TITLE } from '../../contexts/ChatContext'; import EnvironmentBadge from './EnvironmentBadge'; -import { listApps } from '../../api'; +import { useConfig } from '../ConfigContext'; interface SidebarProps { onSelectSession: (sessionId: string) => void; @@ -106,9 +106,13 @@ const AppSidebar: React.FC = ({ currentPath }) => { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const chatContext = useChatContext(); + const configContext = useConfig(); const lastSessionIdRef = useRef(null); const currentSessionId = currentPath === '/pair' ? searchParams.get('resumeSessionId') : null; - const [hasApps, setHasApps] = useState(false); + + // Check if apps extension is enabled from config context + const appsExtensionEnabled = + configContext.extensionsList.find((ext) => ext.name === 'apps')?.enabled ?? false; useEffect(() => { if (currentSessionId) { @@ -116,21 +120,6 @@ const AppSidebar: React.FC = ({ currentPath }) => { } }, [currentSessionId]); - useEffect(() => { - const checkApps = async () => { - try { - const response = await listApps({ - throwOnError: true, - }); - setHasApps((response.data?.apps || []).length > 0); - } catch (err) { - console.warn('Failed to check for apps:', err); - } - }; - - checkApps(); - }, [currentPath]); - useEffect(() => { const currentItem = menuItems.find( (item) => item.type === 'item' && item.path === currentPath @@ -197,7 +186,7 @@ const AppSidebar: React.FC = ({ currentPath }) => { const visibleMenuItems = menuItems.filter((entry) => { if (entry.type === 'item' && entry.path === '/apps') { - return hasApps; + return appsExtensionEnabled; } return true; }); diff --git a/ui/desktop/src/components/apps/AppsView.tsx b/ui/desktop/src/components/apps/AppsView.tsx index f25a694adcff..8f22320c39fd 100644 --- a/ui/desktop/src/components/apps/AppsView.tsx +++ b/ui/desktop/src/components/apps/AppsView.tsx @@ -1,9 +1,10 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { MainPanelLayout } from '../Layout/MainPanelLayout'; import { Button } from '../ui/button'; -import { Play } from 'lucide-react'; -import { GooseApp, listApps } from '../../api'; +import { Download, Play, Upload } from 'lucide-react'; +import { exportApp, GooseApp, importApp, listApps } from '../../api'; import { useChatContext } from '../../contexts/ChatContext'; +import { formatAppName } from '../../utils/conversionUtils'; const GridLayout = ({ children }: { children: React.ReactNode }) => { return ( @@ -73,14 +74,14 @@ export default function AppsView() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId]); - // Listen for platform events (app created/updated/deleted) and handle accordingly + // Listen for platform events to refresh the displayed apps list + // Note: App window lifecycle (launch/refresh/close) is handled globally in AppInner useEffect(() => { const handlePlatformEvent = (event: Event) => { const customEvent = event as CustomEvent; const eventData = customEvent.detail; if (eventData?.extension === 'apps') { - const { event_type, app_name } = eventData; const eventSessionId = eventData.sessionId || sessionId; // Refresh apps list to get latest state @@ -91,38 +92,6 @@ export default function AppsView() { }).then((response) => { if (response.data?.apps) { setApps(response.data.apps); - - // Handle specific event types - const targetApp = response.data.apps.find((app) => app.name === app_name); - - switch (event_type) { - case 'app_created': - // Open the newly created app - if (targetApp) { - window.electron.launchApp(targetApp).catch((err) => { - console.error('Failed to launch newly created app:', err); - }); - } - break; - - case 'app_updated': - // Refresh the app if it's currently open - if (targetApp) { - window.electron.refreshApp(targetApp).catch((err) => { - console.error('Failed to refresh updated app:', err); - }); - } - break; - - case 'app_deleted': - // Close the app if it's currently open - if (app_name) { - window.electron.closeApp(app_name).catch((err) => { - console.error('Failed to close deleted app:', err); - }); - } - break; - } } }); } @@ -164,6 +133,62 @@ export default function AppsView() { } }; + const handleDownloadApp = async (app: GooseApp) => { + try { + const response = await exportApp({ + throwOnError: true, + path: { name: app.name }, + }); + + if (response.data) { + const blob = new Blob([response.data as string], { type: 'text/html' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${app.name}.html`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + } catch (err) { + console.error('Failed to export app:', err); + setError(err instanceof Error ? err.message : 'Failed to export app'); + } + }; + + const fileInputRef = useRef(null); + + const handleImportClick = () => { + fileInputRef.current?.click(); + }; + + const handleUploadApp = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + try { + const text = await file.text(); + await importApp({ + throwOnError: true, + body: { html: text }, + }); + + // Refresh from cache (import already wrote to cache) + const response = await listApps({ + throwOnError: true, + }); + setApps(response.data?.apps || []); + setError(null); + } catch (err) { + console.error('Failed to import app:', err); + setError(err instanceof Error ? err.message : 'Failed to import app'); + } + + // Reset file input + event.target.value = ''; + }; + // Only show error-only UI if we have no apps to display if (error && apps.length === 0) { return ( @@ -179,10 +204,26 @@ export default function AppsView() { return (
+

Apps

+

Applications from your MCP servers that can run in standalone windows. @@ -200,7 +241,9 @@ export default function AppsView() {

No apps available

- Install MCP servers that provide UI resources to see apps here. + Open a chat and ask goose for the app you want to have. It can build one for you + and that will appear here. Or if somebody shared an, app you can import it using + the button above.i

@@ -212,7 +255,9 @@ export default function AppsView() { className="flex flex-col p-4 border border-border-muted rounded-lg bg-background-panel hover:border-border-default transition-colors" >
-

{app.name}

+

+ {formatAppName(app.name)} +

{app.description && (

{app.description}

)} @@ -232,6 +277,14 @@ export default function AppsView() { Launch +
))} diff --git a/ui/desktop/src/components/apps/StandaloneAppView.tsx b/ui/desktop/src/components/apps/StandaloneAppView.tsx index c5acd3d115cf..efdfe952772f 100644 --- a/ui/desktop/src/components/apps/StandaloneAppView.tsx +++ b/ui/desktop/src/components/apps/StandaloneAppView.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import McpAppRenderer from '../McpApps/McpAppRenderer'; import { startAgent, resumeAgent, listApps, stopAgent } from '../../api'; +import { formatAppName } from '../../utils/conversionUtils'; export default function StandaloneAppView() { const [searchParams] = useSearchParams(); @@ -88,7 +89,7 @@ export default function StandaloneAppView() { useEffect(() => { if (appName) { - document.title = appName; + document.title = formatAppName(appName); } }, [appName]); diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts index fcb4bf5cd808..22218659645c 100644 --- a/ui/desktop/src/hooks/useChatStream.ts +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -22,6 +22,7 @@ import { } from '../types/message'; import { errorMessage } from '../utils/conversionUtils'; import { showExtensionLoadResults } from '../utils/extensionErrorUtils'; +import { maybe_handle_platform_event } from '../utils/platform_events'; const resultsCache = new Map(); @@ -137,21 +138,7 @@ async function streamFromResponse( } case 'Notification': { updateNotifications(event as NotificationEvent); - - // Check if this is a platform event notification - // NOTE: If we add more notification types beyond platform_event, consider - // implementing a registry pattern to map notification methods to handlers - if (event.message && typeof event.message === 'object' && 'method' in event.message) { - const notification = event.message as { method?: string; params?: unknown }; - if (notification.method === 'platform_event' && notification.params) { - // Dispatch window event with sessionId included - window.dispatchEvent( - new CustomEvent('platform-event', { - detail: { ...notification.params, sessionId }, - }) - ); - } - } + maybe_handle_platform_event(event.message, sessionId); break; } case 'Ping': @@ -200,12 +187,9 @@ export function useChatStream({ messagesRef.current = newMessages; }, []); - const updateNotifications = useCallback( - (notification: NotificationEvent) => { - setNotifications((prev) => [...prev, notification]); - }, - [] - ); + const updateNotifications = useCallback((notification: NotificationEvent) => { + setNotifications((prev) => [...prev, notification]); + }, []); const onFinish = useCallback( async (error?: string): Promise => { diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 6b4a51a2a1ed..0299b161f792 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -29,6 +29,7 @@ import { expandTilde } from './utils/pathUtils'; import log from './utils/logger'; import { ensureWinShims } from './utils/winShims'; import { addRecentDir, loadRecentDirs } from './utils/recentDirs'; +import { formatAppName } from './utils/conversionUtils'; import { EnvToggles, loadSettings, @@ -516,6 +517,8 @@ let appConfig = { const windowMap = new Map(); const goosedClients = new Map(); +// Track app windows by app name for refresh/close operations +const appWindows = new Map(); // appName -> BrowserWindow // Track power save blockers per window const windowPowerSaveBlockers = new Map(); // windowId -> blockerId @@ -2456,7 +2459,7 @@ async function appMain() { const baseUrl = new URL(currentUrl).origin; const appWindow = new BrowserWindow({ - title: gooseApp.name, + title: formatAppName(gooseApp.name), width: gooseApp.width ?? 800, height: gooseApp.height ?? 600, resizable: gooseApp.resizable ?? true, @@ -2470,9 +2473,11 @@ async function appMain() { }); goosedClients.set(appWindow.id, launchingClient); + appWindows.set(gooseApp.name, appWindow); appWindow.on('close', () => { goosedClients.delete(appWindow.id); + appWindows.delete(gooseApp.name); }); const workingDir = app.getPath('home'); @@ -2491,6 +2496,44 @@ async function appMain() { throw error; } }); + + ipcMain.handle('refresh-app', async (_event, gooseApp: GooseApp) => { + try { + const appWindow = appWindows.get(gooseApp.name); + if (!appWindow || appWindow.isDestroyed()) { + console.log(`App window for '${gooseApp.name}' not found or destroyed, skipping refresh`); + return; + } + + // Bring to front first + if (appWindow.isMinimized()) { + appWindow.restore(); + } + appWindow.show(); + appWindow.focus(); + + // Then reload + await appWindow.webContents.reload(); + } catch (error) { + console.error('Failed to refresh app:', error); + throw error; + } + }); + + ipcMain.handle('close-app', async (_event, appName: string) => { + try { + const appWindow = appWindows.get(appName); + if (!appWindow || appWindow.isDestroyed()) { + console.log(`App window for '${appName}' not found or destroyed, skipping close`); + return; + } + + appWindow.close(); + } catch (error) { + console.error('Failed to close app:', error); + throw error; + } + }); } app.whenReady().then(async () => { diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 3ac6ce0b843f..2f93efcad6f6 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -138,6 +138,8 @@ type ElectronAPI = { recordRecipeHash: (recipe: Recipe) => Promise; openDirectoryInExplorer: (directoryPath: string) => Promise; launchApp: (app: GooseApp) => Promise; + refreshApp: (app: GooseApp) => Promise; + closeApp: (appName: string) => Promise; addRecentDir: (dir: string) => Promise; }; @@ -278,6 +280,8 @@ const electronAPI: ElectronAPI = { openDirectoryInExplorer: (directoryPath: string) => ipcRenderer.invoke('open-directory-in-explorer', directoryPath), launchApp: (app: GooseApp) => ipcRenderer.invoke('launch-app', app), + refreshApp: (app: GooseApp) => ipcRenderer.invoke('refresh-app', app), + closeApp: (appName: string) => ipcRenderer.invoke('close-app', appName), addRecentDir: (dir: string) => ipcRenderer.invoke('add-recent-dir', dir), }; diff --git a/ui/desktop/src/utils/conversionUtils.ts b/ui/desktop/src/utils/conversionUtils.ts index 51b9cb30accf..69b36ff9599f 100644 --- a/ui/desktop/src/utils/conversionUtils.ts +++ b/ui/desktop/src/utils/conversionUtils.ts @@ -21,3 +21,15 @@ export function errorMessage(err: Error | unknown, default_value?: string) { return default_value || String(err); } } + +/** + * Format app names for display. + * Converts names like "countdown-timer" or "my_cool_app" to "Countdown Timer" or "My Cool App" + */ +export function formatAppName(name: string): string { + return name + .split(/[-_\s]+/) // Split on hyphens, underscores, and spaces + .filter((word) => word.length > 0) // Remove empty strings + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) // Capitalize first letter + .join(' '); // Join with spaces +} diff --git a/ui/desktop/src/utils/platform_events.ts b/ui/desktop/src/utils/platform_events.ts new file mode 100644 index 000000000000..583f9359730d --- /dev/null +++ b/ui/desktop/src/utils/platform_events.ts @@ -0,0 +1,129 @@ +import { listApps, GooseApp } from '../api'; + +/** + * Platform Events Module + * + * Handles platform event notifications from the backend via MCP streaming. + * Backend sends Notification events which get converted to window CustomEvents, + * then routed to extension-specific handlers. + */ + +// Type definitions for platform events +interface PlatformEventData { + extension: string; + sessionId?: string; + [key: string]: unknown; +} + +interface AppsEventData extends PlatformEventData { + app_name?: string; + sessionId: string; +} + +type PlatformEventHandler = (eventType: string, data: PlatformEventData) => Promise; + +// Extension-specific event handlers + +async function handleAppsEvent(eventType: string, eventData: PlatformEventData): Promise { + const { app_name, sessionId } = eventData as AppsEventData; + + console.log(`[platform_events] Handling apps event: ${eventType}, app_name: '${app_name}'`); + + if (!sessionId) { + console.warn('No sessionId in apps platform event, skipping'); + return; + } + + // Fetch fresh apps list to get latest state + const response = await listApps({ + throwOnError: false, + query: { session_id: sessionId }, + }); + + const apps = response.data?.apps || []; + console.log( + `[platform_events] Fetched ${apps.length} apps:`, + apps.map((a: GooseApp) => a.name) + ); + + const targetApp = apps.find((app: GooseApp) => app.name === app_name); + console.log(`[platform_events] Target app found:`, targetApp ? 'YES' : 'NO'); + + switch (eventType) { + case 'app_created': + // Open the newly created app + if (targetApp) { + await window.electron.launchApp(targetApp).catch((err) => { + console.error('Failed to launch newly created app:', err); + }); + } + break; + + case 'app_updated': + // Refresh the app if it's currently open + if (targetApp) { + await window.electron.refreshApp(targetApp).catch((err) => { + console.error('Failed to refresh updated app:', err); + }); + } + break; + + case 'app_deleted': + // Close the app if it's currently open + if (app_name) { + await window.electron.closeApp(app_name).catch((err) => { + console.error('Failed to close deleted app:', err); + }); + } + break; + + default: + console.warn(`Unknown apps event type: ${eventType}`); + } +} + +// Registry mapping extension name to handler function +const EXTENSION_HANDLERS: Record = { + apps: handleAppsEvent, + // Future extensions can register handlers here +}; + +/** + * Check if a notification is a platform event and dispatch it as a window CustomEvent. + * Called from useChatStream when receiving Notification MessageEvents. + */ +export function maybe_handle_platform_event(notification: unknown, sessionId: string): void { + if (notification && typeof notification === 'object' && 'method' in notification) { + const msg = notification as { method?: string; params?: unknown }; + if (msg.method === 'platform_event' && msg.params) { + // Dispatch window event with sessionId included + window.dispatchEvent( + new CustomEvent('platform-event', { + detail: { ...msg.params, sessionId }, + }) + ); + } + } +} + +/** + * Register global platform event handlers. + * Call this from AppInner to set up listeners that are always active. + * Returns cleanup function to remove listeners. + */ +export function registerPlatformEventHandlers(): () => void { + const handler = (event: Event) => { + const customEvent = event as CustomEvent; + const { extension, event_type, ...data } = customEvent.detail; + + const extensionHandler = EXTENSION_HANDLERS[extension]; + if (extensionHandler) { + extensionHandler(event_type, { ...data, extension }).catch((err) => { + console.error(`Platform event handler failed for ${extension}:`, err); + }); + } + }; + + window.addEventListener('platform-event', handler); + return () => window.removeEventListener('platform-event', handler); +} From 4882f4f9b6f029171d7541ca95bffdb6884d84e6 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Tue, 20 Jan 2026 13:09:56 -0500 Subject: [PATCH 03/18] Add the clock! --- crates/goose/src/agents/apps_extension.rs | 18 +- crates/goose/src/goose_apps/clock.html | 243 ++++++++++++++++++++++ 2 files changed, 252 insertions(+), 9 deletions(-) create mode 100644 crates/goose/src/goose_apps/clock.html diff --git a/crates/goose/src/agents/apps_extension.rs b/crates/goose/src/agents/apps_extension.rs index 8d818e8b098c..e356680913c9 100644 --- a/crates/goose/src/agents/apps_extension.rs +++ b/crates/goose/src/agents/apps_extension.rs @@ -126,15 +126,15 @@ impl AppsManagerClient { ), }; - let client = Self { + let mut client = Self { info, context, apps_dir, }; - // if let Err(e) = client.ensure_default_apps() { - // tracing::warn!("Failed to create default apps: {}", e); - // } + if let Err(e) = client.ensure_default_apps() { + tracing::warn!("Failed to create default apps: {}", e); + } Ok(client) } @@ -143,14 +143,14 @@ impl AppsManagerClient { fn ensure_default_apps(&mut self) -> Result<(), String> { let apps = self.list_stored_apps()?; - // If no apps exist, create the default clock app - if apps.is_empty() { - let clock_html = include_str!("../../resources/clock.html"); + // Always ensure the clock app exists + if !apps.contains(&"clock".to_string()) { + let clock_html = include_str!("../goose_apps/clock.html"); let clock_app = GooseApp { resource: McpAppResource { uri: "ui://apps/clock".to_string(), name: "clock".to_string(), - description: Some("A beautiful clock with multiple design themes (Digital, Analog, Swiss Railway)".to_string()), + description: Some("Swiss Railway Clock".to_string()), mime_type: "text/html;profile=mcp-app".to_string(), text: Some(clock_html.to_string()), blob: None, @@ -158,7 +158,7 @@ impl AppsManagerClient { }, mcp_server: Some("apps".to_string()), window_props: None, - prd: Some("A clock app with three iconic design themes: Casio digital, Braun analog, and Swiss Railway. Users can switch between themes.".to_string()), + prd: Some("An analog clock widget inspired by the iconic Swiss railway clock (Hans Hilfiker design). Features smooth-sweeping hands with the characteristic pause-and-jump behavior at 12 o'clock.".to_string()), }; self.save_app(&clock_app)?; tracing::info!("Created default clock app"); diff --git a/crates/goose/src/goose_apps/clock.html b/crates/goose/src/goose_apps/clock.html new file mode 100644 index 000000000000..6c390d2a970a --- /dev/null +++ b/crates/goose/src/goose_apps/clock.html @@ -0,0 +1,243 @@ + + + + + Clock + + + + + +
+ +
+ + + + From 858e5977e133cf25ab54d4875d8d30198fc58368 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Tue, 20 Jan 2026 13:19:18 -0500 Subject: [PATCH 04/18] use the html meta --- crates/goose/src/agents/apps_extension.rs | 26 ++++------------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/crates/goose/src/agents/apps_extension.rs b/crates/goose/src/agents/apps_extension.rs index e356680913c9..0a9fce1f07ef 100644 --- a/crates/goose/src/agents/apps_extension.rs +++ b/crates/goose/src/agents/apps_extension.rs @@ -139,31 +139,13 @@ impl AppsManagerClient { Ok(client) } - /// Ensure default apps exist (like the clock) fn ensure_default_apps(&mut self) -> Result<(), String> { - let apps = self.list_stored_apps()?; - - // Always ensure the clock app exists - if !apps.contains(&"clock".to_string()) { + let clock_path = self.apps_dir.join("clock.html"); + if !clock_path.exists() { let clock_html = include_str!("../goose_apps/clock.html"); - let clock_app = GooseApp { - resource: McpAppResource { - uri: "ui://apps/clock".to_string(), - name: "clock".to_string(), - description: Some("Swiss Railway Clock".to_string()), - mime_type: "text/html;profile=mcp-app".to_string(), - text: Some(clock_html.to_string()), - blob: None, - meta: None, - }, - mcp_server: Some("apps".to_string()), - window_props: None, - prd: Some("An analog clock widget inspired by the iconic Swiss railway clock (Hans Hilfiker design). Features smooth-sweeping hands with the characteristic pause-and-jump behavior at 12 o'clock.".to_string()), - }; - self.save_app(&clock_app)?; - tracing::info!("Created default clock app"); + fs::write(&clock_path, clock_html) + .map_err(|e| format!("Failed to write clock.html: {}", e))?; } - Ok(()) } From 38c96ea6b74d920fe7eb22cd9aee23ca6aec8f34 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Tue, 20 Jan 2026 15:14:53 -0500 Subject: [PATCH 05/18] Clean up --- crates/goose-server/src/routes/agent.rs | 7 ++-- crates/goose/src/agents/agent.rs | 8 ++--- crates/goose/src/agents/apps_extension.rs | 40 ++++------------------- crates/goose/src/agents/extension.rs | 10 ------ 4 files changed, 12 insertions(+), 53 deletions(-) diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index 3d62a542167a..636007306be4 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -32,7 +32,7 @@ use goose::{ use rmcp::model::{CallToolRequestParam, Content}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::atomic::Ordering; use std::sync::Arc; @@ -1001,7 +1001,7 @@ async fn list_apps( })?; if let Some(cache) = cache.as_ref() { - let active_extensions: std::collections::HashSet = apps + let active_extensions: HashSet = apps .iter() .filter_map(|app| app.mcp_server.clone()) .collect(); @@ -1110,12 +1110,11 @@ async fn import_app( status: StatusCode::BAD_REQUEST, })?; - // Handle name conflicts by appending counter let original_name = app.resource.name.clone(); let mut counter = 1; let existing_apps = cache.list_apps().unwrap_or_default(); - let existing_names: std::collections::HashSet = existing_apps + let existing_names: HashSet = existing_apps .iter() .map(|a| a.resource.name.clone()) .collect(); diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index ae4afdf04861..e097e9efb9f1 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -1278,21 +1278,19 @@ impl Agent { ToolStreamItem::Result(output) => { let output = call_tool_result::validate(output); - // Check for platform notification in tool result meta + // Platform extensions use meta as a way to publish notifications. Ideally we'd + // send the notificiations directly, but the current plumbing does support that + // only badly: if let Ok(ref call_result) = output { if let Some(ref meta) = call_result.meta { if let Some(notification_data) = meta.0.get("platform_notification") { - // Extract method and params from the notification data if let Some(method) = notification_data.get("method").and_then(|v| v.as_str()) { let params = notification_data.get("params").cloned(); - - // Create CustomNotification let custom_notification = rmcp::model::CustomNotification::new( method.to_string(), params, ); - // Emit as ServerNotification let server_notification = rmcp::model::ServerNotification::CustomNotification(custom_notification); yield AgentEvent::McpNotification((request_id.clone(), server_notification)); } diff --git a/crates/goose/src/agents/apps_extension.rs b/crates/goose/src/agents/apps_extension.rs index 0a9fce1f07ef..a6af63c7089e 100644 --- a/crates/goose/src/agents/apps_extension.rs +++ b/crates/goose/src/agents/apps_extension.rs @@ -21,7 +21,6 @@ use tokio_util::sync::CancellationToken; pub static EXTENSION_NAME: &str = "apps"; -/// Parameters for create_app tool #[derive(Debug, Serialize, Deserialize, JsonSchema)] struct CreateAppParams { /// What the app should do - a description or PRD that will be used to generate the app @@ -92,9 +91,8 @@ pub struct AppsManagerClient { impl AppsManagerClient { pub fn new(context: PlatformExtensionContext) -> Result { - let apps_dir = Paths::in_data_dir("apps"); + let apps_dir = Paths::in_data_dir(EXTENSION_NAME); - // Ensure apps directory exists fs::create_dir_all(&apps_dir) .map_err(|e| format!("Failed to create apps directory: {}", e))?; @@ -111,6 +109,7 @@ impl AppsManagerClient { prompts: None, completions: None, experimental: None, + tasks: None, logging: None, }, server_info: Implementation { @@ -280,7 +279,6 @@ You must call the create_app_content tool to return the app name, description, H .await .map_err(|e| format!("LLM call failed: {}", e))?; - // Extract tool call from response for content in &response.content { if let crate::conversation::message::MessageContent::ToolRequest(tool_req) = content { if let Ok(tool_call) = &tool_req.tool_call { @@ -303,7 +301,6 @@ You must call the create_app_content tool to return the app name, description, H Err("LLM did not call the required tool".to_string()) } - /// Generate updated content for an existing app using the LLM with tool calling async fn generate_updated_app_content( &self, existing_html: &str, @@ -353,7 +350,6 @@ You must call the update_app_content tool to return the updated description, HTM .await .map_err(|e| format!("LLM call failed: {}", e))?; - // Extract tool call from response for content in &response.content { if let crate::conversation::message::MessageContent::ToolRequest(tool_req) = content { if let Ok(tool_call) = &tool_req.tool_call { @@ -376,7 +372,6 @@ You must call the update_app_content tool to return the updated description, HTM Err("LLM did not call the required tool".to_string()) } - /// Handle list_apps tool call async fn handle_list_apps( &self, _arguments: Option, @@ -428,11 +423,8 @@ You must call the update_app_content tool to return the updated description, HTM let args = arguments.ok_or("Missing arguments")?; let prd = extract_string(&args, "prd")?; - // Generate app content using LLM with tool calling let content = self.generate_new_app_content(&prd).await?; - tracing::info!("LLM generated app name: '{}'", content.name); - // Check if app already exists if self.load_app(&content.name).is_ok() { return Err(format!( "App '{}' already exists (generated name conflicts with existing app).", @@ -450,7 +442,7 @@ You must call the update_app_content tool to return the updated description, HTM blob: None, meta: None, }, - mcp_server: Some("apps".to_string()), + mcp_server: Some(EXTENSION_NAME.to_string()), window_props: Some(crate::goose_apps::WindowProps { width: content.width.unwrap_or(800), height: content.height.unwrap_or(600), @@ -460,26 +452,22 @@ You must call the update_app_content tool to return the updated description, HTM }; self.save_app(&app)?; - tracing::info!("Saved app with name: '{}'", content.name); let result = CallToolResult::success(vec![Content::text(format!( "Created app '{}'! It should have automatically opened in a new window. You can always find it again in the [Apps] tab.", content.name ))]); - // Add platform notification let mut params = serde_json::Map::new(); params.insert("app_name".to_string(), json!(content.name)); - tracing::info!("Sending platform notification for app: '{}'", content.name); let result = self.context - .result_with_platform_notification(result, "apps", "app_created", params); + .result_with_platform_notification(result, EXTENSION_NAME, "app_created", params); Ok(result) } - /// Handle iterate_app tool call async fn handle_iterate_app( &self, arguments: Option, @@ -492,29 +480,21 @@ You must call the update_app_content tool to return the updated description, HTM let mut app = self.load_app(&name)?; - // Get existing HTML let existing_html = app .resource .text .as_deref() .ok_or("App has no HTML content")?; - // Get existing PRD let existing_prd = app.prd.as_deref().unwrap_or(""); - // Generate updated content using LLM with tool calling let content = self .generate_updated_app_content(existing_html, existing_prd, &feedback) .await?; - // Update app with new content app.resource.text = Some(content.html); app.resource.description = Some(content.description); - - // Update PRD from LLM response - keeps HTML and PRD in sync app.prd = Some(content.prd); - - // Update window properties if provided if content.width.is_some() || content.height.is_some() || content.resizable.is_some() { let current_props = app.window_props.as_ref(); let default_width = current_props.map(|p| p.width).unwrap_or(800); @@ -535,18 +515,16 @@ You must call the update_app_content tool to return the updated description, HTM name ))]); - // Add platform notification let mut params = serde_json::Map::new(); params.insert("app_name".to_string(), json!(name)); let result = self.context - .result_with_platform_notification(result, "apps", "app_updated", params); + .result_with_platform_notification(result, EXTENSION_NAME, "app_updated", params); Ok(result) } - /// Handle delete_app tool call async fn handle_delete_app( &self, arguments: Option, @@ -561,13 +539,12 @@ You must call the update_app_content tool to return the updated description, HTM let result = CallToolResult::success(vec![Content::text(format!("Deleted app '{}'", name))]); - // Add platform notification let mut params = serde_json::Map::new(); params.insert("app_name".to_string(), json!(name)); let result = self.context - .result_with_platform_notification(result, "apps", "app_deleted", params); + .result_with_platform_notification(result, EXTENSION_NAME, "app_deleted", params); Ok(result) } @@ -653,7 +630,6 @@ impl McpClientTrait for AppsManagerClient { for name in app_names { if let Ok(app) = self.load_app(&name) { - // Build meta with window properties if available let meta = if let Some(ref window_props) = app.window_props { let mut meta_obj = Meta::new(); meta_obj.insert( @@ -698,7 +674,6 @@ impl McpClientTrait for AppsManagerClient { uri: &str, _cancel_token: CancellationToken, ) -> Result { - // Parse app name from URI (ui://apps/{name}) let app_name = uri .strip_prefix("ui://apps/") .ok_or(Error::TransportClosed)?; @@ -707,8 +682,6 @@ impl McpClientTrait for AppsManagerClient { .load_app(app_name) .map_err(|_| Error::TransportClosed)?; - // Return the clean HTML without embedded metadata - // The metadata (window props, PRD) is exposed via list_resources meta field let html = app .resource .text @@ -724,7 +697,6 @@ impl McpClientTrait for AppsManagerClient { } } -/// Extract a string from JSON arguments fn extract_string(args: &JsonObject, key: &str) -> Result { args.get(key) .and_then(|v| v.as_str()) diff --git a/crates/goose/src/agents/extension.rs b/crates/goose/src/agents/extension.rs index 59290c1039a5..8dea80f08b74 100644 --- a/crates/goose/src/agents/extension.rs +++ b/crates/goose/src/agents/extension.rs @@ -126,12 +126,6 @@ pub struct PlatformExtensionContext { impl PlatformExtensionContext { /// Helper method to attach a platform notification to a tool result. /// The notification will be emitted as a MessageEvent::Notification to update client state. - /// - /// # Arguments - /// * `result` - The CallToolResult to attach the notification to - /// * `extension_name` - Name of the extension emitting the event (e.g., "apps") - /// * `event_type` - Type of event (e.g., "app_created", "app_updated") - /// * `additional_params` - Additional parameters to include in the notification pub fn result_with_platform_notification( &self, mut result: rmcp::model::CallToolResult, @@ -139,11 +133,9 @@ impl PlatformExtensionContext { event_type: impl Into, mut additional_params: serde_json::Map, ) -> rmcp::model::CallToolResult { - // Add core fields additional_params.insert("extension".to_string(), extension_name.into().into()); additional_params.insert("event_type".to_string(), event_type.into().into()); - // Store notification in meta for agent loop to process let meta_value = serde_json::json!({ "platform_notification": { "method": "platform_event", @@ -152,14 +144,12 @@ impl PlatformExtensionContext { }); if let Some(ref mut meta) = result.meta { - // Merge with existing meta if let Some(obj) = meta_value.as_object() { for (k, v) in obj { meta.0.insert(k.clone(), v.clone()); } } } else { - // Create new meta result.meta = Some(rmcp::model::Meta(meta_value.as_object().unwrap().clone())); } From 652fb1b91791ca942c611637936bdae0d7fd7e75 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Tue, 20 Jan 2026 15:26:21 -0500 Subject: [PATCH 06/18] Split --- crates/goose/src/goose_apps/app.rs | 298 +++++++++++++++++++ crates/goose/src/goose_apps/cache.rs | 103 +++++++ crates/goose/src/goose_apps/mod.rs | 415 +-------------------------- 3 files changed, 405 insertions(+), 411 deletions(-) create mode 100644 crates/goose/src/goose_apps/app.rs create mode 100644 crates/goose/src/goose_apps/cache.rs diff --git a/crates/goose/src/goose_apps/app.rs b/crates/goose/src/goose_apps/app.rs new file mode 100644 index 000000000000..676ce7640728 --- /dev/null +++ b/crates/goose/src/goose_apps/app.rs @@ -0,0 +1,298 @@ +use crate::agents::ExtensionManager; +use rmcp::model::ErrorData; +use serde::{Deserialize, Serialize}; +use tokio_util::sync::CancellationToken; +use tracing::warn; +use utoipa::ToSchema; + +use super::resource::McpAppResource; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct WindowProps { + pub width: u32, + pub height: u32, + pub resizable: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct GooseApp { + #[serde(flatten)] + pub resource: McpAppResource, + #[serde(skip_serializing_if = "Option::is_none")] + pub mcp_server: Option, + #[serde(flatten, skip_serializing_if = "Option::is_none")] + pub window_props: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub prd: Option, +} + +impl GooseApp { + const METADATA_SCRIPT_TYPE: &'static str = "application/ld+json"; + const PRD_SCRIPT_TYPE: &'static str = "application/x-goose-prd"; + const GOOSE_APP_TYPE: &'static str = "GooseApp"; + const GOOSE_SCHEMA_CONTEXT: &'static str = "https://goose.ai/schema"; + + pub fn from_html(html: &str) -> Result { + use regex::Regex; + + let metadata_re = Regex::new(&format!( + r#"(?s)"#, + regex::escape(Self::METADATA_SCRIPT_TYPE) + )) + .map_err(|e| format!("Regex error: {}", e))?; + + let prd_re = Regex::new(&format!( + r#"(?s)"#, + regex::escape(Self::PRD_SCRIPT_TYPE) + )) + .map_err(|e| format!("Regex error: {}", e))?; + + let json_str = metadata_re + .captures(html) + .and_then(|cap| cap.get(1)) + .ok_or_else(|| "No GooseApp JSON-LD metadata found in HTML".to_string())? + .as_str(); + + let metadata: serde_json::Value = serde_json::from_str(json_str) + .map_err(|e| format!("Failed to parse metadata JSON: {}", e))?; + + let name = metadata + .get("name") + .and_then(|v| v.as_str()) + .ok_or("Missing 'name' in metadata")? + .to_string(); + + let description = metadata + .get("description") + .and_then(|v| v.as_str()) + .map(String::from); + + let width = metadata + .get("width") + .and_then(|v| v.as_u64()) + .map(|v| v as u32); + let height = metadata + .get("height") + .and_then(|v| v.as_u64()) + .map(|v| v as u32); + let resizable = metadata.get("resizable").and_then(|v| v.as_bool()); + + let window_props = if width.is_some() || height.is_some() || resizable.is_some() { + Some(WindowProps { + width: width.unwrap_or(800), + height: height.unwrap_or(600), + resizable: resizable.unwrap_or(true), + }) + } else { + None + }; + + let mcp_server = metadata + .get("mcpServer") + .and_then(|v| v.as_str()) + .map(String::from); + + let prd = prd_re + .captures(html) + .and_then(|cap| cap.get(1)) + .map(|m| m.as_str().trim().to_string()); + + let clean_html = metadata_re.replace(html, ""); + let clean_html = prd_re.replace(&clean_html, "").to_string(); + + Ok(GooseApp { + resource: McpAppResource { + uri: format!("ui://apps/{}", name), + name, + description, + mime_type: "text/html;profile=mcp-app".to_string(), + text: Some(clean_html), + blob: None, + meta: None, + }, + mcp_server, + window_props, + prd, + }) + } + + pub fn to_html(&self) -> Result { + let html = self + .resource + .text + .as_ref() + .ok_or("App has no HTML content")?; + + let mut metadata = serde_json::json!({ + "@context": Self::GOOSE_SCHEMA_CONTEXT, + "@type": Self::GOOSE_APP_TYPE, + "name": self.resource.name, + }); + + if let Some(ref desc) = self.resource.description { + metadata["description"] = serde_json::json!(desc); + } + + if let Some(ref props) = self.window_props { + metadata["width"] = serde_json::json!(props.width); + metadata["height"] = serde_json::json!(props.height); + metadata["resizable"] = serde_json::json!(props.resizable); + } + + if let Some(ref server) = self.mcp_server { + metadata["mcpServer"] = serde_json::json!(server); + } + + let metadata_json = serde_json::to_string_pretty(&metadata) + .map_err(|e| format!("Failed to serialize metadata: {}", e))?; + + let metadata_script = format!( + " ", + Self::METADATA_SCRIPT_TYPE, + metadata_json + ); + + let prd_script = if let Some(ref prd) = self.prd { + if !prd.is_empty() { + format!( + " ", + Self::PRD_SCRIPT_TYPE, + prd + ) + } else { + String::new() + } + } else { + String::new() + }; + + let scripts = if prd_script.is_empty() { + format!("{}\n", metadata_script) + } else { + format!("{}\n{}\n", metadata_script, prd_script) + }; + + let result = if let Some(head_pos) = html.find("") { + let mut result = html.clone(); + result.insert_str(head_pos, &scripts); + result + } else if let Some(html_pos) = html.find("')) + .map(|p| html_pos + p + 1); + if let Some(pos) = after_html { + let mut result = html.clone(); + result.insert_str(pos, &format!("\n\n{}", scripts)); + result + } else { + format!("\n{}\n{}", scripts, html) + } + } else { + format!( + "\n\n{}\n\n{}\n\n", + scripts, html + ) + }; + + Ok(result) + } +} + +pub async fn fetch_mcp_apps( + extension_manager: &ExtensionManager, +) -> Result, ErrorData> { + let mut apps = Vec::new(); + + let ui_resources = extension_manager.get_ui_resources().await?; + + for (extension_name, resource) in ui_resources { + match extension_manager + .read_resource(&resource.uri, &extension_name, CancellationToken::default()) + .await + { + Ok(read_result) => { + let mut html = String::new(); + for content in read_result.contents { + if let rmcp::model::ResourceContents::TextResourceContents { text, .. } = + content + { + html = text; + break; + } + } + + if !html.is_empty() { + let mcp_resource = McpAppResource { + uri: resource.uri.clone(), + name: resource.name.clone(), + description: resource.description.clone(), + mime_type: "text/html;profile=mcp-app".to_string(), + text: Some(html), + blob: None, + meta: None, + }; + + let window_props = if let Some(ref meta) = resource.meta { + if let Some(window_obj) = meta.get("window").and_then(|v| v.as_object()) { + if let (Some(width), Some(height), Some(resizable)) = ( + window_obj + .get("width") + .and_then(|v| v.as_u64()) + .map(|v| v as u32), + window_obj + .get("height") + .and_then(|v| v.as_u64()) + .map(|v| v as u32), + window_obj.get("resizable").and_then(|v| v.as_bool()), + ) { + Some(WindowProps { + width, + height, + resizable, + }) + } else { + Some(WindowProps { + width: 800, + height: 600, + resizable: true, + }) + } + } else { + Some(WindowProps { + width: 800, + height: 600, + resizable: true, + }) + } + } else { + Some(WindowProps { + width: 800, + height: 600, + resizable: true, + }) + }; + + let app = GooseApp { + resource: mcp_resource, + mcp_server: Some(extension_name), + window_props, + prd: None, + }; + + apps.push(app); + } + } + Err(e) => { + warn!( + "Failed to read resource {} from {}: {}", + resource.uri, extension_name, e + ); + } + } + } + + Ok(apps) +} diff --git a/crates/goose/src/goose_apps/cache.rs b/crates/goose/src/goose_apps/cache.rs new file mode 100644 index 000000000000..852ff9f88489 --- /dev/null +++ b/crates/goose/src/goose_apps/cache.rs @@ -0,0 +1,103 @@ +use crate::config::paths::Paths; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::PathBuf; +use tracing::warn; + +use super::app::GooseApp; + +pub struct McpAppCache { + cache_dir: PathBuf, +} + +impl McpAppCache { + pub fn new() -> Result { + let config_dir = Paths::config_dir(); + let cache_dir = config_dir.join("mcp-apps-cache"); + Ok(Self { cache_dir }) + } + + fn cache_key(extension_name: &str, resource_uri: &str) -> String { + let input = format!("{}::{}", extension_name, resource_uri); + let hash = Sha256::digest(input.as_bytes()); + format!("{}_{:x}", extension_name, hash) + } + + pub fn list_apps(&self) -> Result, std::io::Error> { + let mut apps = Vec::new(); + + if !self.cache_dir.exists() { + return Ok(apps); + } + + for entry in fs::read_dir(&self.cache_dir)? { + let entry = entry?; + let path = entry.path(); + + if path.extension().and_then(|s| s.to_str()) == Some("json") { + match fs::read_to_string(&path) { + Ok(content) => match serde_json::from_str::(&content) { + Ok(app) => apps.push(app), + Err(e) => warn!("Failed to parse cached app from {:?}: {}", path, e), + }, + Err(e) => warn!("Failed to read cached app from {:?}: {}", path, e), + } + } + } + + Ok(apps) + } + + pub fn store_app(&self, app: &GooseApp) -> Result<(), std::io::Error> { + fs::create_dir_all(&self.cache_dir)?; + + if let Some(ref extension_name) = app.mcp_server { + let cache_key = Self::cache_key(extension_name, &app.resource.uri); + let app_path = self.cache_dir.join(format!("{}.json", cache_key)); + let json = serde_json::to_string_pretty(app).map_err(std::io::Error::other)?; + fs::write(app_path, json)?; + } + + Ok(()) + } + + pub fn get_app(&self, extension_name: &str, resource_uri: &str) -> Option { + let cache_key = Self::cache_key(extension_name, resource_uri); + let app_path = self.cache_dir.join(format!("{}.json", cache_key)); + + if !app_path.exists() { + return None; + } + + fs::read_to_string(&app_path) + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()) + } + + pub fn delete_extension_apps(&self, extension_name: &str) -> Result { + let mut deleted_count = 0; + + if !self.cache_dir.exists() { + return Ok(0); + } + + for entry in fs::read_dir(&self.cache_dir)? { + let entry = entry?; + let path = entry.path(); + + if path.extension().and_then(|s| s.to_str()) == Some("json") { + if let Ok(content) = fs::read_to_string(&path) { + if let Ok(app) = serde_json::from_str::(&content) { + if app.mcp_server.as_deref() == Some(extension_name) + && fs::remove_file(&path).is_ok() + { + deleted_count += 1; + } + } + } + } + } + + Ok(deleted_count) + } +} diff --git a/crates/goose/src/goose_apps/mod.rs b/crates/goose/src/goose_apps/mod.rs index 8bf0c079e07d..327b0e1bf93e 100644 --- a/crates/goose/src/goose_apps/mod.rs +++ b/crates/goose/src/goose_apps/mod.rs @@ -1,414 +1,7 @@ +pub mod app; +pub mod cache; pub mod resource; -use crate::agents::ExtensionManager; -use crate::config::paths::Paths; -use rmcp::model::ErrorData; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::fs; -use std::path::PathBuf; -use tokio_util::sync::CancellationToken; -use tracing::warn; -use utoipa::ToSchema; - +pub use app::{fetch_mcp_apps, GooseApp, WindowProps}; +pub use cache::McpAppCache; pub use resource::{CspMetadata, McpAppResource, ResourceMetadata, UiMetadata}; - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct WindowProps { - pub width: u32, - pub height: u32, - pub resizable: bool, -} - -/// A Goose App combining MCP resource data with Goose-specific metadata -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct GooseApp { - #[serde(flatten)] - pub resource: McpAppResource, - #[serde(skip_serializing_if = "Option::is_none")] - pub mcp_server: Option, - #[serde(flatten, skip_serializing_if = "Option::is_none")] - pub window_props: Option, - /// Product requirements document for LLM-based iteration - #[serde(skip_serializing_if = "Option::is_none")] - pub prd: Option, -} - -impl GooseApp { - const METADATA_SCRIPT_TYPE: &'static str = "application/ld+json"; - const PRD_SCRIPT_TYPE: &'static str = "application/x-goose-prd"; - const GOOSE_APP_TYPE: &'static str = "GooseApp"; - const GOOSE_SCHEMA_CONTEXT: &'static str = "https://goose.ai/schema"; - - /// Parse a GooseApp from HTML with embedded metadata - pub fn from_html(html: &str) -> Result { - use regex::Regex; - - let metadata_re = Regex::new(&format!( - r#"(?s)"#, - regex::escape(Self::METADATA_SCRIPT_TYPE) - )) - .map_err(|e| format!("Regex error: {}", e))?; - - let prd_re = Regex::new(&format!( - r#"(?s)"#, - regex::escape(Self::PRD_SCRIPT_TYPE) - )) - .map_err(|e| format!("Regex error: {}", e))?; - - // Extract metadata JSON - let json_str = metadata_re - .captures(html) - .and_then(|cap| cap.get(1)) - .ok_or_else(|| "No GooseApp JSON-LD metadata found in HTML".to_string())? - .as_str(); - - let metadata: serde_json::Value = serde_json::from_str(json_str) - .map_err(|e| format!("Failed to parse metadata JSON: {}", e))?; - - // Extract fields from metadata - let name = metadata - .get("name") - .and_then(|v| v.as_str()) - .ok_or("Missing 'name' in metadata")? - .to_string(); - - let description = metadata - .get("description") - .and_then(|v| v.as_str()) - .map(String::from); - - let width = metadata - .get("width") - .and_then(|v| v.as_u64()) - .map(|v| v as u32); - let height = metadata - .get("height") - .and_then(|v| v.as_u64()) - .map(|v| v as u32); - let resizable = metadata.get("resizable").and_then(|v| v.as_bool()); - - let window_props = if width.is_some() || height.is_some() || resizable.is_some() { - Some(WindowProps { - width: width.unwrap_or(800), - height: height.unwrap_or(600), - resizable: resizable.unwrap_or(true), - }) - } else { - None - }; - - let mcp_server = metadata - .get("mcpServer") - .and_then(|v| v.as_str()) - .map(String::from); - - // Extract PRD - let prd = prd_re - .captures(html) - .and_then(|cap| cap.get(1)) - .map(|m| m.as_str().trim().to_string()); - - // Strip metadata and PRD scripts from HTML - let clean_html = metadata_re.replace(html, ""); - let clean_html = prd_re.replace(&clean_html, "").to_string(); - - Ok(GooseApp { - resource: McpAppResource { - uri: format!("ui://apps/{}", name), - name, - description, - mime_type: "text/html;profile=mcp-app".to_string(), - text: Some(clean_html), - blob: None, - meta: None, - }, - mcp_server, - window_props, - prd, - }) - } - - /// Convert GooseApp to HTML with embedded metadata - pub fn to_html(&self) -> Result { - let html = self - .resource - .text - .as_ref() - .ok_or("App has no HTML content")?; - - // Build metadata JSON - let mut metadata = serde_json::json!({ - "@context": Self::GOOSE_SCHEMA_CONTEXT, - "@type": Self::GOOSE_APP_TYPE, - "name": self.resource.name, - }); - - if let Some(ref desc) = self.resource.description { - metadata["description"] = serde_json::json!(desc); - } - - if let Some(ref props) = self.window_props { - metadata["width"] = serde_json::json!(props.width); - metadata["height"] = serde_json::json!(props.height); - metadata["resizable"] = serde_json::json!(props.resizable); - } - - if let Some(ref server) = self.mcp_server { - metadata["mcpServer"] = serde_json::json!(server); - } - - let metadata_json = serde_json::to_string_pretty(&metadata) - .map_err(|e| format!("Failed to serialize metadata: {}", e))?; - - let metadata_script = format!( - " ", - Self::METADATA_SCRIPT_TYPE, - metadata_json - ); - - let prd_script = if let Some(ref prd) = self.prd { - if !prd.is_empty() { - format!( - " ", - Self::PRD_SCRIPT_TYPE, - prd - ) - } else { - String::new() - } - } else { - String::new() - }; - - let scripts = if prd_script.is_empty() { - format!("{}\n", metadata_script) - } else { - format!("{}\n{}\n", metadata_script, prd_script) - }; - - // Insert scripts into HTML - let result = if let Some(head_pos) = html.find("") { - let mut result = html.clone(); - result.insert_str(head_pos, &scripts); - result - } else if let Some(html_pos) = html.find("')) - .map(|p| html_pos + p + 1); - if let Some(pos) = after_html { - let mut result = html.clone(); - result.insert_str(pos, &format!("\n\n{}", scripts)); - result - } else { - format!("\n{}\n{}", scripts, html) - } - } else { - format!( - "\n\n{}\n\n{}\n\n", - scripts, html - ) - }; - - Ok(result) - } -} - -pub struct McpAppCache { - cache_dir: PathBuf, -} - -impl McpAppCache { - pub fn new() -> Result { - let config_dir = Paths::config_dir(); - let cache_dir = config_dir.join("mcp-apps-cache"); - Ok(Self { cache_dir }) - } - - fn cache_key(extension_name: &str, resource_uri: &str) -> String { - let input = format!("{}::{}", extension_name, resource_uri); - let hash = Sha256::digest(input.as_bytes()); - format!("{}_{:x}", extension_name, hash) - } - - pub fn list_apps(&self) -> Result, std::io::Error> { - let mut apps = Vec::new(); - - if !self.cache_dir.exists() { - return Ok(apps); - } - - for entry in fs::read_dir(&self.cache_dir)? { - let entry = entry?; - let path = entry.path(); - - if path.extension().and_then(|s| s.to_str()) == Some("json") { - match fs::read_to_string(&path) { - Ok(content) => match serde_json::from_str::(&content) { - Ok(app) => apps.push(app), - Err(e) => warn!("Failed to parse cached app from {:?}: {}", path, e), - }, - Err(e) => warn!("Failed to read cached app from {:?}: {}", path, e), - } - } - } - - Ok(apps) - } - - pub fn store_app(&self, app: &GooseApp) -> Result<(), std::io::Error> { - fs::create_dir_all(&self.cache_dir)?; - - if let Some(ref extension_name) = app.mcp_server { - let cache_key = Self::cache_key(extension_name, &app.resource.uri); - let app_path = self.cache_dir.join(format!("{}.json", cache_key)); - let json = serde_json::to_string_pretty(app).map_err(std::io::Error::other)?; - fs::write(app_path, json)?; - } - - Ok(()) - } - - pub fn get_app(&self, extension_name: &str, resource_uri: &str) -> Option { - let cache_key = Self::cache_key(extension_name, resource_uri); - let app_path = self.cache_dir.join(format!("{}.json", cache_key)); - - if !app_path.exists() { - return None; - } - - fs::read_to_string(&app_path) - .ok() - .and_then(|content| serde_json::from_str::(&content).ok()) - } - - pub fn delete_extension_apps(&self, extension_name: &str) -> Result { - let mut deleted_count = 0; - - if !self.cache_dir.exists() { - return Ok(0); - } - - for entry in fs::read_dir(&self.cache_dir)? { - let entry = entry?; - let path = entry.path(); - - if path.extension().and_then(|s| s.to_str()) == Some("json") { - if let Ok(content) = fs::read_to_string(&path) { - if let Ok(app) = serde_json::from_str::(&content) { - if app.mcp_server.as_deref() == Some(extension_name) - && fs::remove_file(&path).is_ok() - { - deleted_count += 1; - } - } - } - } - } - - Ok(deleted_count) - } -} - -pub async fn fetch_mcp_apps( - extension_manager: &ExtensionManager, -) -> Result, ErrorData> { - let mut apps = Vec::new(); - - let ui_resources = extension_manager.get_ui_resources().await?; - - for (extension_name, resource) in ui_resources { - match extension_manager - .read_resource(&resource.uri, &extension_name, CancellationToken::default()) - .await - { - Ok(read_result) => { - let mut html = String::new(); - for content in read_result.contents { - if let rmcp::model::ResourceContents::TextResourceContents { text, .. } = - content - { - html = text; - break; - } - } - - if !html.is_empty() { - let mcp_resource = McpAppResource { - uri: resource.uri.clone(), - name: resource.name.clone(), - description: resource.description.clone(), - mime_type: "text/html;profile=mcp-app".to_string(), - text: Some(html), - blob: None, - meta: None, - }; - - // Extract window properties from resource meta.window if present - let window_props = if let Some(ref meta) = resource.meta { - if let Some(window_obj) = meta.get("window").and_then(|v| v.as_object()) { - if let (Some(width), Some(height), Some(resizable)) = ( - window_obj - .get("width") - .and_then(|v| v.as_u64()) - .map(|v| v as u32), - window_obj - .get("height") - .and_then(|v| v.as_u64()) - .map(|v| v as u32), - window_obj.get("resizable").and_then(|v| v.as_bool()), - ) { - Some(WindowProps { - width, - height, - resizable, - }) - } else { - // Window object exists but doesn't have complete props - Some(WindowProps { - width: 800, - height: 600, - resizable: true, - }) - } - } else { - // Meta exists but no window object - use defaults - Some(WindowProps { - width: 800, - height: 600, - resizable: true, - }) - } - } else { - // No meta - use defaults - Some(WindowProps { - width: 800, - height: 600, - resizable: true, - }) - }; - - let app = GooseApp { - resource: mcp_resource, - mcp_server: Some(extension_name), - window_props, - prd: None, - }; - - apps.push(app); - } - } - Err(e) => { - warn!( - "Failed to read resource {} from {}: {}", - resource.uri, extension_name, e - ); - } - } - } - - Ok(apps) -} From e449f8c152f5ee879ec3eaff8adb11e8130621b3 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Tue, 20 Jan 2026 18:39:52 -0500 Subject: [PATCH 07/18] Refactor --- crates/goose/src/agents/apps_extension.rs | 224 ++++++++-------------- crates/goose/src/goose_apps/cache.rs | 20 +- crates/goose/src/goose_apps/clock.html | 13 +- crates/goose/src/prompt_template.rs | 8 + crates/goose/src/prompts/apps_create.md | 19 ++ crates/goose/src/prompts/apps_iterate.md | 25 +++ 6 files changed, 161 insertions(+), 148 deletions(-) create mode 100644 crates/goose/src/prompts/apps_create.md create mode 100644 crates/goose/src/prompts/apps_iterate.md diff --git a/crates/goose/src/agents/apps_extension.rs b/crates/goose/src/agents/apps_extension.rs index a6af63c7089e..9631fa14aae4 100644 --- a/crates/goose/src/agents/apps_extension.rs +++ b/crates/goose/src/agents/apps_extension.rs @@ -2,8 +2,9 @@ use crate::agents::extension::PlatformExtensionContext; use crate::agents::mcp_client::{Error, McpClientTrait, McpMeta}; use crate::config::paths::Paths; use crate::conversation::message::Message; -use crate::goose_apps::GooseApp; +use crate::goose_apps::{GooseApp, WindowProps}; use crate::goose_apps::McpAppResource; +use crate::prompt_template::render_template; use crate::providers::base::Provider; use async_trait::async_trait; use rmcp::model::{ @@ -14,6 +15,7 @@ use rmcp::model::{ use schemars::{schema_for, JsonSchema}; use serde::{Deserialize, Serialize}; use serde_json::json; +use std::collections::HashMap; use std::fs; use std::path::PathBuf; use std::sync::Arc; @@ -21,6 +23,12 @@ use tokio_util::sync::CancellationToken; pub static EXTENSION_NAME: &str = "apps"; +const DEFAULT_WINDOW_PROPS: WindowProps = WindowProps { + width: 800, + height: 600, + resizable: true, +}; + #[derive(Debug, Serialize, Deserialize, JsonSchema)] struct CreateAppParams { /// What the app should do - a description or PRD that will be used to generate the app @@ -125,27 +133,11 @@ impl AppsManagerClient { ), }; - let mut client = Self { + Ok(Self { info, context, apps_dir, - }; - - if let Err(e) = client.ensure_default_apps() { - tracing::warn!("Failed to create default apps: {}", e); - } - - Ok(client) - } - - fn ensure_default_apps(&mut self) -> Result<(), String> { - let clock_path = self.apps_dir.join("clock.html"); - if !clock_path.exists() { - let clock_html = include_str!("../goose_apps/clock.html"); - fs::write(&clock_path, clock_html) - .map_err(|e| format!("Failed to write clock.html: {}", e))?; - } - Ok(()) + }) } fn list_stored_apps(&self) -> Result, String> { @@ -196,6 +188,18 @@ impl AppsManagerClient { Ok(()) } + fn with_platform_notification( + &self, + result: CallToolResult, + event_type: &str, + app_name: &str, + ) -> CallToolResult { + let mut params = serde_json::Map::new(); + params.insert("app_name".to_string(), json!(app_name)); + self.context + .result_with_platform_notification(result, EXTENSION_NAME, event_type, params) + } + async fn get_provider(&self) -> Result, String> { let extension_manager = self .context @@ -245,25 +249,9 @@ impl AppsManagerClient { let existing_apps = self.list_stored_apps().unwrap_or_default(); let existing_names = existing_apps.join(", "); - let system_prompt = r#"You are an expert HTML/CSS/JavaScript developer. Generate standalone, single-file HTML applications. - -REQUIREMENTS: -- Create a complete, self-contained HTML file with embedded CSS and JavaScript -- Use modern, clean design with good UX -- Make it responsive and work well in different window sizes -- Use semantic HTML5 -- Add appropriate error handling -- Make the app interactive and functional -- Use vanilla JavaScript (no external dependencies unless absolutely necessary) -- If you need external resources (fonts, icons), use CDN links -- The app will be sandboxed with strict CSP, so all scripts must be inline or from trusted CDNs - -WINDOW SIZING: -- Choose appropriate width and height based on the app's content and layout -- Typical sizes: small utilities (400x300), standard apps (800x600), large apps (1200x800) -- Set resizable to false for fixed-size apps, true for flexible layouts - -You must call the create_app_content tool to return the app name, description, HTML, and window properties."#; + let context: HashMap<&str, &str> = HashMap::new(); + let system_prompt = render_template("apps_create.md", &context) + .map_err(|e| format!("Failed to render template: {}", e))?; let user_prompt = format!( "REQUESTED APP:\n{}\n\nEXISTING APPS: {}\n\nGenerate a unique name (lowercase with hyphens, not in existing apps), a brief description, complete HTML, and appropriate window size for this app.", @@ -275,30 +263,11 @@ You must call the create_app_content tool to return the app name, description, H let tools = vec![Self::create_app_content_tool()]; let (response, _usage) = provider - .complete(system_prompt, &messages, &tools) + .complete(&system_prompt, &messages, &tools) .await .map_err(|e| format!("LLM call failed: {}", e))?; - for content in &response.content { - if let crate::conversation::message::MessageContent::ToolRequest(tool_req) = content { - if let Ok(tool_call) = &tool_req.tool_call { - if tool_call.name == "create_app_content" { - let params = tool_call - .arguments - .as_ref() - .ok_or("Missing tool call parameters")?; - - let response: CreateAppContentResponse = - serde_json::from_value(serde_json::Value::Object(params.clone())) - .map_err(|e| format!("Failed to parse tool response: {}", e))?; - - return Ok(response); - } - } - } - } - - Err("LLM did not call the required tool".to_string()) + extract_tool_response(&response, "create_app_content") } async fn generate_updated_app_content( @@ -309,31 +278,9 @@ You must call the create_app_content tool to return the app name, description, H ) -> Result { let provider = self.get_provider().await?; - let system_prompt = r#"You are an expert HTML/CSS/JavaScript developer. Generate standalone, single-file HTML applications. - -REQUIREMENTS: -- Create a complete, self-contained HTML file with embedded CSS and JavaScript -- Use modern, clean design with good UX -- Make it responsive and work well in different window sizes -- Use semantic HTML5 -- Add appropriate error handling -- Make the app interactive and functional -- Use vanilla JavaScript (no external dependencies unless absolutely necessary) -- If you need external resources (fonts, icons), use CDN links -- The app will be sandboxed with strict CSP, so all scripts must be inline or from trusted CDNs - -WINDOW SIZING: -- Optionally update width/height if the changes warrant a different window size -- Only include size properties if they should change -- Set resizable to false for fixed-size apps, true for flexible layouts - -PRD UPDATE: -- Update the PRD to reflect the current state of the app after implementing the feedback -- Keep the core requirements but add/update sections based on what was actually changed -- Document new features, changed behavior, or updated requirements -- Keep the PRD concise and focused on what the app should do, not implementation details - -You must call the update_app_content tool to return the updated description, HTML, updated PRD, and optionally updated window properties."#; + let context: HashMap<&str, &str> = HashMap::new(); + let system_prompt = render_template("apps_iterate.md", &context) + .map_err(|e| format!("Failed to render template: {}", e))?; let user_prompt = format!( "ORIGINAL PRD:\n{}\n\nCURRENT APP:\n```html\n{}\n```\n\nFEEDBACK: {}\n\nImplement the requested changes and return:\n1. Updated description\n2. Updated HTML implementing the feedback\n3. Updated PRD reflecting the current state of the app\n4. Optionally updated window size if appropriate", @@ -346,30 +293,11 @@ You must call the update_app_content tool to return the updated description, HTM let tools = vec![Self::update_app_content_tool()]; let (response, _usage) = provider - .complete(system_prompt, &messages, &tools) + .complete(&system_prompt, &messages, &tools) .await .map_err(|e| format!("LLM call failed: {}", e))?; - for content in &response.content { - if let crate::conversation::message::MessageContent::ToolRequest(tool_req) = content { - if let Ok(tool_call) = &tool_req.tool_call { - if tool_call.name == "update_app_content" { - let params = tool_call - .arguments - .as_ref() - .ok_or("Missing tool call parameters")?; - - let response: UpdateAppContentResponse = - serde_json::from_value(serde_json::Value::Object(params.clone())) - .map_err(|e| format!("Failed to parse tool response: {}", e))?; - - return Ok(response); - } - } - } - } - - Err("LLM did not call the required tool".to_string()) + extract_tool_response(&response, "update_app_content") } async fn handle_list_apps( @@ -443,10 +371,10 @@ You must call the update_app_content tool to return the updated description, HTM meta: None, }, mcp_server: Some(EXTENSION_NAME.to_string()), - window_props: Some(crate::goose_apps::WindowProps { - width: content.width.unwrap_or(800), - height: content.height.unwrap_or(600), - resizable: content.resizable.unwrap_or(true), + window_props: Some(WindowProps { + width: content.width.unwrap_or(DEFAULT_WINDOW_PROPS.width), + height: content.height.unwrap_or(DEFAULT_WINDOW_PROPS.height), + resizable: content.resizable.unwrap_or(DEFAULT_WINDOW_PROPS.resizable), }), prd: Some(prd), }; @@ -458,14 +386,7 @@ You must call the update_app_content tool to return the updated description, HTM content.name ))]); - let mut params = serde_json::Map::new(); - params.insert("app_name".to_string(), json!(content.name)); - - let result = - self.context - .result_with_platform_notification(result, EXTENSION_NAME, "app_created", params); - - Ok(result) + Ok(self.with_platform_notification(result, "app_created", &content.name)) } async fn handle_iterate_app( @@ -497,11 +418,17 @@ You must call the update_app_content tool to return the updated description, HTM app.prd = Some(content.prd); if content.width.is_some() || content.height.is_some() || content.resizable.is_some() { let current_props = app.window_props.as_ref(); - let default_width = current_props.map(|p| p.width).unwrap_or(800); - let default_height = current_props.map(|p| p.height).unwrap_or(600); - let default_resizable = current_props.map(|p| p.resizable).unwrap_or(true); - - app.window_props = Some(crate::goose_apps::WindowProps { + let default_width = current_props + .map(|p| p.width) + .unwrap_or(DEFAULT_WINDOW_PROPS.width); + let default_height = current_props + .map(|p| p.height) + .unwrap_or(DEFAULT_WINDOW_PROPS.height); + let default_resizable = current_props + .map(|p| p.resizable) + .unwrap_or(DEFAULT_WINDOW_PROPS.resizable); + + app.window_props = Some(WindowProps { width: content.width.unwrap_or(default_width), height: content.height.unwrap_or(default_height), resizable: content.resizable.unwrap_or(default_resizable), @@ -515,14 +442,7 @@ You must call the update_app_content tool to return the updated description, HTM name ))]); - let mut params = serde_json::Map::new(); - params.insert("app_name".to_string(), json!(name)); - - let result = - self.context - .result_with_platform_notification(result, EXTENSION_NAME, "app_updated", params); - - Ok(result) + Ok(self.with_platform_notification(result, "app_updated", &name)) } async fn handle_delete_app( @@ -539,14 +459,7 @@ You must call the update_app_content tool to return the updated description, HTM let result = CallToolResult::success(vec![Content::text(format!("Deleted app '{}'", name))]); - let mut params = serde_json::Map::new(); - params.insert("app_name".to_string(), json!(name)); - - let result = - self.context - .result_with_platform_notification(result, EXTENSION_NAME, "app_deleted", params); - - Ok(result) + Ok(self.with_platform_notification(result, "app_deleted", &name)) } } @@ -557,12 +470,6 @@ impl McpClientTrait for AppsManagerClient { _next_cursor: Option, _cancel_token: CancellationToken, ) -> Result { - fn schema() -> JsonObject { - serde_json::to_value(schema_for!(T)) - .map(|v| v.as_object().unwrap().clone()) - .expect("valid schema") - } - let tools = vec![ McpTool::new( "list_apps".to_string(), @@ -697,9 +604,38 @@ impl McpClientTrait for AppsManagerClient { } } +fn schema() -> JsonObject { + serde_json::to_value(schema_for!(T)) + .map(|v| v.as_object().unwrap().clone()) + .expect("valid schema") +} + fn extract_string(args: &JsonObject, key: &str) -> Result { args.get(key) .and_then(|v| v.as_str()) .map(|s| s.to_string()) .ok_or_else(|| format!("Missing or invalid '{}'", key)) } + +fn extract_tool_response( + response: &Message, + tool_name: &str, +) -> Result { + for content in &response.content { + if let crate::conversation::message::MessageContent::ToolRequest(tool_req) = content { + if let Ok(tool_call) = &tool_req.tool_call { + if tool_call.name == tool_name { + let params = tool_call + .arguments + .as_ref() + .ok_or("Missing tool call parameters")?; + + return serde_json::from_value(serde_json::Value::Object(params.clone())) + .map_err(|e| format!("Failed to parse tool response: {}", e)); + } + } + } + } + + Err(format!("LLM did not call the required tool: {}", tool_name)) +} diff --git a/crates/goose/src/goose_apps/cache.rs b/crates/goose/src/goose_apps/cache.rs index 852ff9f88489..da67c22f0ec2 100644 --- a/crates/goose/src/goose_apps/cache.rs +++ b/crates/goose/src/goose_apps/cache.rs @@ -6,6 +6,9 @@ use tracing::warn; use super::app::GooseApp; +static CLOCK_HTML: &str = include_str!("../goose_apps/clock.html"); +const APPS_EXTENSION_NAME: &str = "apps"; + pub struct McpAppCache { cache_dir: PathBuf, } @@ -14,7 +17,22 @@ impl McpAppCache { pub fn new() -> Result { let config_dir = Paths::config_dir(); let cache_dir = config_dir.join("mcp-apps-cache"); - Ok(Self { cache_dir }) + let cache = Self { cache_dir }; + + // Ensure default apps are cached on initialization + cache.ensure_default_apps(); + + Ok(cache) + } + + fn ensure_default_apps(&self) { + // Check if clock app is already cached + if self.get_app(APPS_EXTENSION_NAME, "apps://clock").is_none() { + if let Ok(mut clock_app) = GooseApp::from_html(CLOCK_HTML) { + clock_app.mcp_server = Some(APPS_EXTENSION_NAME.to_string()); + let _ = self.store_app(&clock_app); + } + } } fn cache_key(extension_name: &str, resource_uri: &str) -> String { diff --git a/crates/goose/src/goose_apps/clock.html b/crates/goose/src/goose_apps/clock.html index 6c390d2a970a..2792090861c3 100644 --- a/crates/goose/src/goose_apps/clock.html +++ b/crates/goose/src/goose_apps/clock.html @@ -55,10 +55,15 @@ - Cleans up animation loop when widget closes diff --git a/crates/goose/src/prompt_template.rs b/crates/goose/src/prompt_template.rs index 9215c4509de6..1231f4c6da56 100644 --- a/crates/goose/src/prompt_template.rs +++ b/crates/goose/src/prompt_template.rs @@ -23,6 +23,14 @@ static TEMPLATE_REGISTRY: &[(&str, &str)] = &[ "recipe.md", "Prompt for generating recipe files from conversations", ), + ( + "apps_create.md", + "Prompt for generating new Goose apps based on the user instructions", + ), + ( + "apps_iterate.md", + "Prompt for updating existing Goose apps based on feedback", + ), ( "permission_judge.md", "Prompt for analyzing tool operations for read-only detection", diff --git a/crates/goose/src/prompts/apps_create.md b/crates/goose/src/prompts/apps_create.md new file mode 100644 index 000000000000..5802cd37ba25 --- /dev/null +++ b/crates/goose/src/prompts/apps_create.md @@ -0,0 +1,19 @@ +You are an expert HTML/CSS/JavaScript developer. Generate standalone, single-file HTML applications. + +REQUIREMENTS: +- Create a complete, self-contained HTML file with embedded CSS and JavaScript +- Use modern, clean design with good UX +- Make it responsive and work well in different window sizes +- Use semantic HTML5 +- Add appropriate error handling +- Make the app interactive and functional +- Use vanilla JavaScript (no external dependencies unless absolutely necessary) +- If you need external resources (fonts, icons), use CDN links +- The app will be sandboxed with strict CSP, so all scripts must be inline or from trusted CDNs + +WINDOW SIZING: +- Choose appropriate width and height based on the app's content and layout +- Typical sizes: small utilities (400x300), standard apps (800x600), large apps (1200x800) +- Set resizable to false for fixed-size apps, true for flexible layouts + +You must call the create_app_content tool to return the app name, description, HTML, and window properties. diff --git a/crates/goose/src/prompts/apps_iterate.md b/crates/goose/src/prompts/apps_iterate.md new file mode 100644 index 000000000000..8b22af00a29e --- /dev/null +++ b/crates/goose/src/prompts/apps_iterate.md @@ -0,0 +1,25 @@ +You are an expert HTML/CSS/JavaScript developer. Generate standalone, single-file HTML applications. + +REQUIREMENTS: +- Create a complete, self-contained HTML file with embedded CSS and JavaScript +- Use modern, clean design with good UX +- Make it responsive and work well in different window sizes +- Use semantic HTML5 +- Add appropriate error handling +- Make the app interactive and functional +- Use vanilla JavaScript (no external dependencies unless absolutely necessary) +- If you need external resources (fonts, icons), use CDN links +- The app will be sandboxed with strict CSP, so all scripts must be inline or from trusted CDNs + +WINDOW SIZING: +- Optionally update width/height if the changes warrant a different window size +- Only include size properties if they should change +- Set resizable to false for fixed-size apps, true for flexible layouts + +PRD UPDATE: +- Update the PRD to reflect the current state of the app after implementing the feedback +- Keep the core requirements but add/update sections based on what was actually changed +- Document new features, changed behavior, or updated requirements +- Keep the PRD concise and focused on what the app should do, not implementation details + +You must call the update_app_content tool to return the updated description, HTML, updated PRD, and optionally updated window properties. From b00c038db193770a27831d18faaa0bd0f60cc845 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Tue, 20 Jan 2026 19:10:24 -0500 Subject: [PATCH 08/18] Inner --- ui/desktop/src/main.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 0299b161f792..81edcee74912 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -2463,6 +2463,7 @@ async function appMain() { width: gooseApp.width ?? 800, height: gooseApp.height ?? 600, resizable: gooseApp.resizable ?? true, + useContentSize: true, webPreferences: { preload: path.join(__dirname, 'preload.js'), nodeIntegration: false, From 9af9e967765fd4c3e3cb93fb9e929d772b9e9c71 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Tue, 20 Jan 2026 19:14:35 -0500 Subject: [PATCH 09/18] Update crates/goose/src/agents/agent.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- crates/goose/src/agents/agent.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index e097e9efb9f1..6a89a4929121 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -1279,8 +1279,8 @@ impl Agent { let output = call_tool_result::validate(output); // Platform extensions use meta as a way to publish notifications. Ideally we'd - // send the notificiations directly, but the current plumbing does support that - // only badly: + // send the notifications directly, but the current plumbing doesn't support that + // well: if let Ok(ref call_result) = output { if let Some(ref meta) = call_result.meta { if let Some(notification_data) = meta.0.get("platform_notification") { From b515fe119b92608f5d1e6445989f6abdd46f5c55 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Tue, 20 Jan 2026 19:16:34 -0500 Subject: [PATCH 10/18] Update ui/desktop/src/components/apps/AppsView.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ui/desktop/src/components/apps/AppsView.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/desktop/src/components/apps/AppsView.tsx b/ui/desktop/src/components/apps/AppsView.tsx index 8f22320c39fd..90c1ca208747 100644 --- a/ui/desktop/src/components/apps/AppsView.tsx +++ b/ui/desktop/src/components/apps/AppsView.tsx @@ -242,8 +242,8 @@ export default function AppsView() {

No apps available

Open a chat and ask goose for the app you want to have. It can build one for you - and that will appear here. Or if somebody shared an, app you can import it using - the button above.i + and that will appear here. Or if somebody shared an app, you can import it using + the button above.

From d29c7db5ff1d4a09e5110c34943bbe7ecc4058d1 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Tue, 20 Jan 2026 19:29:27 -0500 Subject: [PATCH 11/18] fmt etc --- crates/goose-server/src/routes/agent.rs | 2 ++ crates/goose/src/agents/apps_extension.rs | 2 +- crates/goose/src/goose_apps/clock.html | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index 47ca9f372706..211c64c57d49 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -1125,6 +1125,8 @@ async fn import_app( counter += 1; } + app.mcp_server = Some("apps".to_string()); + cache.store_app(&app).map_err(|e| ErrorResponse { message: format!("Failed to store app: {}", e), status: StatusCode::INTERNAL_SERVER_ERROR, diff --git a/crates/goose/src/agents/apps_extension.rs b/crates/goose/src/agents/apps_extension.rs index 9631fa14aae4..320cd032c10d 100644 --- a/crates/goose/src/agents/apps_extension.rs +++ b/crates/goose/src/agents/apps_extension.rs @@ -2,8 +2,8 @@ use crate::agents::extension::PlatformExtensionContext; use crate::agents::mcp_client::{Error, McpClientTrait, McpMeta}; use crate::config::paths::Paths; use crate::conversation::message::Message; -use crate::goose_apps::{GooseApp, WindowProps}; use crate::goose_apps::McpAppResource; +use crate::goose_apps::{GooseApp, WindowProps}; use crate::prompt_template::render_template; use crate::providers::base::Provider; use async_trait::async_trait; diff --git a/crates/goose/src/goose_apps/clock.html b/crates/goose/src/goose_apps/clock.html index 2792090861c3..4da4f19a73d5 100644 --- a/crates/goose/src/goose_apps/clock.html +++ b/crates/goose/src/goose_apps/clock.html @@ -7,7 +7,7 @@ { "@context": "https://goose.ai/schema", "@type": "GooseApp", - "name": "Clock", + "name": "clock", "description": "Swiss Railway Clock", "width": 300, "height": 300, From 66d49f1af26028214b36b7ab5c689a28dd749e54 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Tue, 20 Jan 2026 19:33:52 -0500 Subject: [PATCH 12/18] Simplify --- ui/desktop/src/components/GooseSidebar/AppSidebar.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx b/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx index 4368dd457853..b81518eddac6 100644 --- a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx +++ b/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx @@ -111,9 +111,8 @@ const AppSidebar: React.FC = ({ currentPath }) => { const lastSessionIdRef = useRef(null); const currentSessionId = currentPath === '/pair' ? searchParams.get('resumeSessionId') : null; - // Check if apps extension is enabled from config context - const appsExtensionEnabled = - configContext.extensionsList.find((ext) => ext.name === 'apps')?.enabled ?? false; + const appsExtensionEnabled = !!configContext.extensionsList?.find((ext) => ext.name === 'apps') + ?.enabled; useEffect(() => { if (currentSessionId) { From 138b6fefae53a0a79fdba9ccfc4284019c8e8e53 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Thu, 22 Jan 2026 07:25:39 +1100 Subject: [PATCH 13/18] gotta put Apps at top --- .../src/components/GooseSidebar/AppSidebar.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx b/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx index b81518eddac6..a98b7167e7a8 100644 --- a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx +++ b/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx @@ -65,6 +65,13 @@ const menuItems: NavigationEntry[] = [ tooltip: 'View your session history', }, { type: 'separator' }, + { + type: 'item', + path: '/apps', + label: 'Apps', + icon: AppWindow, + tooltip: 'Browse and launch MCP apps', + }, { type: 'item', path: '/recipes', @@ -86,13 +93,6 @@ const menuItems: NavigationEntry[] = [ icon: Puzzle, tooltip: 'Manage your extensions', }, - { - type: 'item', - path: '/apps', - label: 'Apps', - icon: AppWindow, - tooltip: 'Browse and launch MCP apps', - }, { type: 'separator' }, { type: 'item', From bd3ae8456159d054da1569eb9108f71065bf990b Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Thu, 22 Jan 2026 08:29:17 -0500 Subject: [PATCH 14/18] Make it multiple mcp servers --- crates/goose-server/src/routes/agent.rs | 4 +- crates/goose/src/agents/apps_extension.rs | 6 +- crates/goose/src/goose_apps/app.rs | 25 +++--- crates/goose/src/goose_apps/cache.rs | 7 +- crates/goose/src/prompts/apps_create.md | 6 +- crates/goose/src/prompts/apps_iterate.md | 6 +- ui/desktop/openapi.json | 8 +- ui/desktop/src/api/types.gen.ts | 2 +- ui/desktop/src/components/apps/AppsView.tsx | 81 ++++++++++--------- .../src/components/apps/StandaloneAppView.tsx | 2 +- ui/desktop/src/main.ts | 2 +- 11 files changed, 81 insertions(+), 68 deletions(-) diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index 211c64c57d49..e62feb4ce451 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -1003,7 +1003,7 @@ async fn list_apps( if let Some(cache) = cache.as_ref() { let active_extensions: HashSet = apps .iter() - .filter_map(|app| app.mcp_server.clone()) + .flat_map(|app| app.mcp_servers.iter().cloned()) .collect(); for extension_name in active_extensions { @@ -1125,7 +1125,7 @@ async fn import_app( counter += 1; } - app.mcp_server = Some("apps".to_string()); + app.mcp_servers = vec!["apps".to_string()]; cache.store_app(&app).map_err(|e| ErrorResponse { message: format!("Failed to store app: {}", e), diff --git a/crates/goose/src/agents/apps_extension.rs b/crates/goose/src/agents/apps_extension.rs index 320cd032c10d..efef79add78b 100644 --- a/crates/goose/src/agents/apps_extension.rs +++ b/crates/goose/src/agents/apps_extension.rs @@ -220,8 +220,8 @@ impl AppsManagerClient { fn schema() -> JsonObject { serde_json::to_value(schema_for!(T)) - .map(|v| v.as_object().unwrap().clone()) - .expect("valid schema") + .map(|v| v.as_object().expect("schema_for!(T) must serialize to a JSON object").clone()) + .expect("Schema serialization must succeed") } fn create_app_content_tool() -> rmcp::model::Tool { @@ -370,7 +370,7 @@ impl AppsManagerClient { blob: None, meta: None, }, - mcp_server: Some(EXTENSION_NAME.to_string()), + mcp_servers: vec![EXTENSION_NAME.to_string()], window_props: Some(WindowProps { width: content.width.unwrap_or(DEFAULT_WINDOW_PROPS.width), height: content.height.unwrap_or(DEFAULT_WINDOW_PROPS.height), diff --git a/crates/goose/src/goose_apps/app.rs b/crates/goose/src/goose_apps/app.rs index 676ce7640728..7ebfe3daf9f9 100644 --- a/crates/goose/src/goose_apps/app.rs +++ b/crates/goose/src/goose_apps/app.rs @@ -20,8 +20,8 @@ pub struct WindowProps { pub struct GooseApp { #[serde(flatten)] pub resource: McpAppResource, - #[serde(skip_serializing_if = "Option::is_none")] - pub mcp_server: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub mcp_servers: Vec, #[serde(flatten, skip_serializing_if = "Option::is_none")] pub window_props: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -89,10 +89,15 @@ impl GooseApp { None }; - let mcp_server = metadata - .get("mcpServer") - .and_then(|v| v.as_str()) - .map(String::from); + let mcp_servers = metadata + .get("mcpServers") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); let prd = prd_re .captures(html) @@ -112,7 +117,7 @@ impl GooseApp { blob: None, meta: None, }, - mcp_server, + mcp_servers, window_props, prd, }) @@ -141,8 +146,8 @@ impl GooseApp { metadata["resizable"] = serde_json::json!(props.resizable); } - if let Some(ref server) = self.mcp_server { - metadata["mcpServer"] = serde_json::json!(server); + if !self.mcp_servers.is_empty() { + metadata["mcpServers"] = serde_json::json!(self.mcp_servers); } let metadata_json = serde_json::to_string_pretty(&metadata) @@ -277,7 +282,7 @@ pub async fn fetch_mcp_apps( let app = GooseApp { resource: mcp_resource, - mcp_server: Some(extension_name), + mcp_servers: vec![extension_name], window_props, prd: None, }; diff --git a/crates/goose/src/goose_apps/cache.rs b/crates/goose/src/goose_apps/cache.rs index da67c22f0ec2..55904f085f37 100644 --- a/crates/goose/src/goose_apps/cache.rs +++ b/crates/goose/src/goose_apps/cache.rs @@ -29,7 +29,7 @@ impl McpAppCache { // Check if clock app is already cached if self.get_app(APPS_EXTENSION_NAME, "apps://clock").is_none() { if let Ok(mut clock_app) = GooseApp::from_html(CLOCK_HTML) { - clock_app.mcp_server = Some(APPS_EXTENSION_NAME.to_string()); + clock_app.mcp_servers = vec![APPS_EXTENSION_NAME.to_string()]; let _ = self.store_app(&clock_app); } } @@ -69,7 +69,8 @@ impl McpAppCache { pub fn store_app(&self, app: &GooseApp) -> Result<(), std::io::Error> { fs::create_dir_all(&self.cache_dir)?; - if let Some(ref extension_name) = app.mcp_server { + // Store the app once for each MCP server it's associated with + for extension_name in &app.mcp_servers { let cache_key = Self::cache_key(extension_name, &app.resource.uri); let app_path = self.cache_dir.join(format!("{}.json", cache_key)); let json = serde_json::to_string_pretty(app).map_err(std::io::Error::other)?; @@ -106,7 +107,7 @@ impl McpAppCache { if path.extension().and_then(|s| s.to_str()) == Some("json") { if let Ok(content) = fs::read_to_string(&path) { if let Ok(app) = serde_json::from_str::(&content) { - if app.mcp_server.as_deref() == Some(extension_name) + if app.mcp_servers.contains(&extension_name.to_string()) && fs::remove_file(&path).is_ok() { deleted_count += 1; diff --git a/crates/goose/src/prompts/apps_create.md b/crates/goose/src/prompts/apps_create.md index 5802cd37ba25..ec553f9c3268 100644 --- a/crates/goose/src/prompts/apps_create.md +++ b/crates/goose/src/prompts/apps_create.md @@ -7,9 +7,9 @@ REQUIREMENTS: - Use semantic HTML5 - Add appropriate error handling - Make the app interactive and functional -- Use vanilla JavaScript (no external dependencies unless absolutely necessary) -- If you need external resources (fonts, icons), use CDN links -- The app will be sandboxed with strict CSP, so all scripts must be inline or from trusted CDNs +- Use vanilla JavaScript; do not load external JavaScript libraries (no JS dependencies from CDNs or packages) +- If you need external resources (fonts, icons, or CSS only), use CDN links from well-known, trusted providers +- The app will be sandboxed with strict CSP, so all JavaScript must be inline; only non-script assets (fonts, icons, CSS) may be loaded from trusted CDNs WINDOW SIZING: - Choose appropriate width and height based on the app's content and layout diff --git a/crates/goose/src/prompts/apps_iterate.md b/crates/goose/src/prompts/apps_iterate.md index 8b22af00a29e..f248b3eee0de 100644 --- a/crates/goose/src/prompts/apps_iterate.md +++ b/crates/goose/src/prompts/apps_iterate.md @@ -7,9 +7,9 @@ REQUIREMENTS: - Use semantic HTML5 - Add appropriate error handling - Make the app interactive and functional -- Use vanilla JavaScript (no external dependencies unless absolutely necessary) -- If you need external resources (fonts, icons), use CDN links -- The app will be sandboxed with strict CSP, so all scripts must be inline or from trusted CDNs +- Use vanilla JavaScript; do not load external JavaScript libraries (no JS dependencies from CDNs or packages) +- If you need external resources (fonts, icons, or CSS only), use CDN links from well-known, trusted providers +- The app will be sandboxed with strict CSP, so all JavaScript must be inline; only non-script assets (fonts, icons, CSS) may be loaded from trusted CDNs WINDOW SIZING: - Optionally update width/height if the changes warrant a different window size diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 18f5fa994988..6aac499ac62c 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -4118,9 +4118,11 @@ { "type": "object", "properties": { - "mcpServer": { - "type": "string", - "nullable": true + "mcpServers": { + "type": "array", + "items": { + "type": "string" + } }, "prd": { "type": "string", diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 4e633e481a78..b6a2ac74d9f5 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -365,7 +365,7 @@ export type GetToolsQuery = { }; export type GooseApp = McpAppResource & (WindowProps | null) & { - mcpServer?: string | null; + mcpServers?: Array; prd?: string | null; }; diff --git a/ui/desktop/src/components/apps/AppsView.tsx b/ui/desktop/src/components/apps/AppsView.tsx index 90c1ca208747..14e5332c1b0b 100644 --- a/ui/desktop/src/components/apps/AppsView.tsx +++ b/ui/desktop/src/components/apps/AppsView.tsx @@ -249,45 +249,50 @@ export default function AppsView() { ) : ( - {apps.map((app) => ( -
-
-

- {formatAppName(app.name)} -

- {app.description && ( -

{app.description}

- )} - {app.mcpServer && ( - - {app.mcpServer} - - )} + {apps.map((app) => { + const isCustomApp = app.mcpServers?.includes('apps') ?? false; + return ( +
+
+

+ {formatAppName(app.name)} +

+ {app.description && ( +

{app.description}

+ )} + {app.mcpServers && app.mcpServers.length > 0 && ( + + {isCustomApp ? 'Custom app' : app.mcpServers.join(', ')} + + )} +
+
+ + {isCustomApp && ( + + )} +
-
- - -
-
- ))} + ); + })} )}
diff --git a/ui/desktop/src/components/apps/StandaloneAppView.tsx b/ui/desktop/src/components/apps/StandaloneAppView.tsx index efdfe952772f..77ba0120369e 100644 --- a/ui/desktop/src/components/apps/StandaloneAppView.tsx +++ b/ui/desktop/src/components/apps/StandaloneAppView.tsx @@ -36,7 +36,7 @@ export default function StandaloneAppView() { const apps = response.data?.apps || []; const cachedApp = apps.find( - (app) => app.uri === resourceUri && app.mcpServer === extensionName + (app) => app.uri === resourceUri && app.mcpServers?.includes(extensionName) ); if (cachedApp?.text) { diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 81edcee74912..47fd7564c3df 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -2482,7 +2482,7 @@ async function appMain() { }); const workingDir = app.getPath('home'); - const extensionName = gooseApp.mcpServer ?? ''; + const extensionName = gooseApp.mcpServers?.[0] ?? ''; const standaloneUrl = `${baseUrl}/#/standalone-app?` + `resourceUri=${encodeURIComponent(gooseApp.uri)}` + From 7bcdd33b5dcd964e3107746b478c2eea73a1efee Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Thu, 22 Jan 2026 08:36:33 -0500 Subject: [PATCH 15/18] Add a warning --- ui/desktop/src/components/apps/AppsView.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/ui/desktop/src/components/apps/AppsView.tsx b/ui/desktop/src/components/apps/AppsView.tsx index 14e5332c1b0b..76b0f73fed02 100644 --- a/ui/desktop/src/components/apps/AppsView.tsx +++ b/ui/desktop/src/components/apps/AppsView.tsx @@ -225,9 +225,14 @@ export default function AppsView() { Import App -

- Applications from your MCP servers that can run in standalone windows. -

+
+

+ Applications from your MCP servers that can run in standalone windows. +

+

+ ⚠️ Experimental feature - may change or be removed at any time +

+
From 2a894cd2a8c81ac1918e249111b87b37bec0b5de Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Thu, 22 Jan 2026 08:54:13 -0500 Subject: [PATCH 16/18] Update crates/goose/src/goose_apps/app.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- crates/goose/src/goose_apps/app.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/goose/src/goose_apps/app.rs b/crates/goose/src/goose_apps/app.rs index 7ebfe3daf9f9..d13032b57c2d 100644 --- a/crates/goose/src/goose_apps/app.rs +++ b/crates/goose/src/goose_apps/app.rs @@ -32,7 +32,7 @@ impl GooseApp { const METADATA_SCRIPT_TYPE: &'static str = "application/ld+json"; const PRD_SCRIPT_TYPE: &'static str = "application/x-goose-prd"; const GOOSE_APP_TYPE: &'static str = "GooseApp"; - const GOOSE_SCHEMA_CONTEXT: &'static str = "https://goose.ai/schema"; + const GOOSE_SCHEMA_CONTEXT: &'static str = "urn:goose.ai:schema"; pub fn from_html(html: &str) -> Result { use regex::Regex; From eb3916728f4d66fb009dd2b8356e3305964a9386 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Thu, 22 Jan 2026 09:50:44 -0500 Subject: [PATCH 17/18] Final clean up --- crates/goose/src/agents/extension.rs | 2 -- crates/goose/src/agents/extension_manager.rs | 1 - crates/goose/src/conversation/message.rs | 6 ---- .../components/GooseSidebar/AppSidebar.tsx | 17 +++++---- ui/desktop/src/components/apps/AppsView.tsx | 5 --- ui/desktop/src/hooks/useChatStream.ts | 4 +-- ui/desktop/src/main.ts | 4 +-- ui/desktop/src/utils/conversionUtils.ts | 12 +++---- ui/desktop/src/utils/platform_events.ts | 36 +------------------ 9 files changed, 16 insertions(+), 71 deletions(-) diff --git a/crates/goose/src/agents/extension.rs b/crates/goose/src/agents/extension.rs index 8dea80f08b74..aebb4fddae39 100644 --- a/crates/goose/src/agents/extension.rs +++ b/crates/goose/src/agents/extension.rs @@ -124,8 +124,6 @@ pub struct PlatformExtensionContext { } impl PlatformExtensionContext { - /// Helper method to attach a platform notification to a tool result. - /// The notification will be emitted as a MessageEvent::Notification to update client state. pub fn result_with_platform_notification( &self, mut result: rmcp::model::CallToolResult, diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index 3a28817550e8..ef7d577cbcd2 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -466,7 +466,6 @@ impl ExtensionManager { &self.context } - /// Get the provider pub fn get_provider(&self) -> &SharedProvider { &self.provider } diff --git a/crates/goose/src/conversation/message.rs b/crates/goose/src/conversation/message.rs index b0045a109089..5e9de889834e 100644 --- a/crates/goose/src/conversation/message.rs +++ b/crates/goose/src/conversation/message.rs @@ -845,39 +845,33 @@ impl Message { .with_metadata(MessageMetadata::user_only()) } - /// Set the visibility metadata for the message pub fn with_visibility(mut self, user_visible: bool, agent_visible: bool) -> Self { self.metadata.user_visible = user_visible; self.metadata.agent_visible = agent_visible; self } - /// Set the entire metadata for the message pub fn with_metadata(mut self, metadata: MessageMetadata) -> Self { self.metadata = metadata; self } - /// Mark the message as only visible to the user (not the agent) pub fn user_only(mut self) -> Self { self.metadata.user_visible = true; self.metadata.agent_visible = false; self } - /// Mark the message as only visible to the agent (not the user) pub fn agent_only(mut self) -> Self { self.metadata.user_visible = false; self.metadata.agent_visible = true; self } - /// Check if the message is visible to the user pub fn is_user_visible(&self) -> bool { self.metadata.user_visible } - /// Check if the message is visible to the agent pub fn is_agent_visible(&self) -> bool { self.metadata.agent_visible } diff --git a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx b/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx index 7e0702d8e153..123db12ae7a0 100644 --- a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx +++ b/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx @@ -54,13 +54,6 @@ interface NavigationSeparator { type NavigationEntry = NavigationItem | NavigationSeparator; const menuItems: NavigationEntry[] = [ - { - type: 'item', - path: '/apps', - label: 'Apps', - icon: AppWindow, - tooltip: 'Browse and launch MCP apps', - }, { type: 'item', path: '/recipes', @@ -68,6 +61,13 @@ const menuItems: NavigationEntry[] = [ icon: FileText, tooltip: 'Browse your saved recipes', }, + { + type: 'item', + path: '/apps', + label: 'Apps', + icon: AppWindow, + tooltip: 'MCP and custom apps', + }, { type: 'item', path: '/schedules', @@ -477,7 +477,7 @@ const AppSidebar: React.FC = ({ currentPath }) => { <> - {/* Home, Apps, and New Chat */} + {/* Home and New Chat */}
@@ -534,7 +534,6 @@ const AppSidebar: React.FC = ({ currentPath }) => { - {/* Other menu items (Apps is filtered based on extension state) */} {visibleMenuItems.map((entry, index) => renderMenuItem(entry, index))} diff --git a/ui/desktop/src/components/apps/AppsView.tsx b/ui/desktop/src/components/apps/AppsView.tsx index c4dc5499a1cc..69863b649364 100644 --- a/ui/desktop/src/components/apps/AppsView.tsx +++ b/ui/desktop/src/components/apps/AppsView.tsx @@ -74,8 +74,6 @@ export default function AppsView() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId]); - // Listen for platform events to refresh the displayed apps list - // Note: App window lifecycle (launch/refresh/close) is handled globally in AppInner useEffect(() => { const handlePlatformEvent = (event: Event) => { const customEvent = event as CustomEvent; @@ -174,7 +172,6 @@ export default function AppsView() { body: { html: text }, }); - // Refresh from cache (import already wrote to cache) const response = await listApps({ throwOnError: true, }); @@ -184,8 +181,6 @@ export default function AppsView() { console.error('Failed to import app:', err); setError(err instanceof Error ? err.message : 'Failed to import app'); } - - // Reset file input event.target.value = ''; }; diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts index 577e168b67be..12d84a9e705a 100644 --- a/ui/desktop/src/hooks/useChatStream.ts +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -24,7 +24,7 @@ import { } from '../types/message'; import { errorMessage } from '../utils/conversionUtils'; import { showExtensionLoadResults } from '../utils/extensionErrorUtils'; -import { maybe_handle_platform_event } from '../utils/platform_events'; +import { maybeHandlePlatformEvent } from '../utils/platform_events'; const resultsCache = new Map(); @@ -251,7 +251,7 @@ async function streamFromResponse( } case 'Notification': { dispatch({ type: 'ADD_NOTIFICATION', payload: event as NotificationEvent }); - maybe_handle_platform_event(event.message, sessionId); + maybeHandlePlatformEvent(event.message, sessionId); break; } case 'Ping': diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index b6ad1cdbf1ae..1d7e399dfdb8 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -517,10 +517,8 @@ let appConfig = { const windowMap = new Map(); const goosedClients = new Map(); -// Track app windows by app name for refresh/close operations -const appWindows = new Map(); // appName -> BrowserWindow +const appWindows = new Map(); -// Track power save blockers per window const windowPowerSaveBlockers = new Map(); // windowId -> blockerId // Track pending initial messages per window const pendingInitialMessages = new Map(); // windowId -> initialMessage diff --git a/ui/desktop/src/utils/conversionUtils.ts b/ui/desktop/src/utils/conversionUtils.ts index 69b36ff9599f..792df32a4e88 100644 --- a/ui/desktop/src/utils/conversionUtils.ts +++ b/ui/desktop/src/utils/conversionUtils.ts @@ -22,14 +22,10 @@ export function errorMessage(err: Error | unknown, default_value?: string) { } } -/** - * Format app names for display. - * Converts names like "countdown-timer" or "my_cool_app" to "Countdown Timer" or "My Cool App" - */ export function formatAppName(name: string): string { return name - .split(/[-_\s]+/) // Split on hyphens, underscores, and spaces - .filter((word) => word.length > 0) // Remove empty strings - .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) // Capitalize first letter - .join(' '); // Join with spaces + .split(/[-_\s]+/) + .filter((word) => word.length > 0) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' '); } diff --git a/ui/desktop/src/utils/platform_events.ts b/ui/desktop/src/utils/platform_events.ts index 583f9359730d..8cb5053e5a44 100644 --- a/ui/desktop/src/utils/platform_events.ts +++ b/ui/desktop/src/utils/platform_events.ts @@ -1,14 +1,5 @@ import { listApps, GooseApp } from '../api'; -/** - * Platform Events Module - * - * Handles platform event notifications from the backend via MCP streaming. - * Backend sends Notification events which get converted to window CustomEvents, - * then routed to extension-specific handlers. - */ - -// Type definitions for platform events interface PlatformEventData { extension: string; sessionId?: string; @@ -22,36 +13,25 @@ interface AppsEventData extends PlatformEventData { type PlatformEventHandler = (eventType: string, data: PlatformEventData) => Promise; -// Extension-specific event handlers - async function handleAppsEvent(eventType: string, eventData: PlatformEventData): Promise { const { app_name, sessionId } = eventData as AppsEventData; - console.log(`[platform_events] Handling apps event: ${eventType}, app_name: '${app_name}'`); - if (!sessionId) { console.warn('No sessionId in apps platform event, skipping'); return; } - // Fetch fresh apps list to get latest state const response = await listApps({ throwOnError: false, query: { session_id: sessionId }, }); const apps = response.data?.apps || []; - console.log( - `[platform_events] Fetched ${apps.length} apps:`, - apps.map((a: GooseApp) => a.name) - ); const targetApp = apps.find((app: GooseApp) => app.name === app_name); - console.log(`[platform_events] Target app found:`, targetApp ? 'YES' : 'NO'); switch (eventType) { case 'app_created': - // Open the newly created app if (targetApp) { await window.electron.launchApp(targetApp).catch((err) => { console.error('Failed to launch newly created app:', err); @@ -60,7 +40,6 @@ async function handleAppsEvent(eventType: string, eventData: PlatformEventData): break; case 'app_updated': - // Refresh the app if it's currently open if (targetApp) { await window.electron.refreshApp(targetApp).catch((err) => { console.error('Failed to refresh updated app:', err); @@ -69,7 +48,6 @@ async function handleAppsEvent(eventType: string, eventData: PlatformEventData): break; case 'app_deleted': - // Close the app if it's currently open if (app_name) { await window.electron.closeApp(app_name).catch((err) => { console.error('Failed to close deleted app:', err); @@ -82,21 +60,14 @@ async function handleAppsEvent(eventType: string, eventData: PlatformEventData): } } -// Registry mapping extension name to handler function const EXTENSION_HANDLERS: Record = { apps: handleAppsEvent, - // Future extensions can register handlers here }; -/** - * Check if a notification is a platform event and dispatch it as a window CustomEvent. - * Called from useChatStream when receiving Notification MessageEvents. - */ -export function maybe_handle_platform_event(notification: unknown, sessionId: string): void { +export function maybeHandlePlatformEvent(notification: unknown, sessionId: string): void { if (notification && typeof notification === 'object' && 'method' in notification) { const msg = notification as { method?: string; params?: unknown }; if (msg.method === 'platform_event' && msg.params) { - // Dispatch window event with sessionId included window.dispatchEvent( new CustomEvent('platform-event', { detail: { ...msg.params, sessionId }, @@ -106,11 +77,6 @@ export function maybe_handle_platform_event(notification: unknown, sessionId: st } } -/** - * Register global platform event handlers. - * Call this from AppInner to set up listeners that are always active. - * Returns cleanup function to remove listeners. - */ export function registerPlatformEventHandlers(): () => void { const handler = (event: Event) => { const customEvent = event as CustomEvent; From b5aca5fd5442edc3d0c2b0ec3af75295b9f2e1da Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Thu, 22 Jan 2026 12:22:03 -0500 Subject: [PATCH 18/18] fmt --- crates/goose/src/agents/apps_extension.rs | 6 +++++- crates/goose/src/goose_apps/app.rs | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/goose/src/agents/apps_extension.rs b/crates/goose/src/agents/apps_extension.rs index a40055eaec99..76a6ba776fcf 100644 --- a/crates/goose/src/agents/apps_extension.rs +++ b/crates/goose/src/agents/apps_extension.rs @@ -241,7 +241,11 @@ impl AppsManagerClient { fn schema() -> JsonObject { serde_json::to_value(schema_for!(T)) - .map(|v| v.as_object().expect("schema_for!(T) must serialize to a JSON object").clone()) + .map(|v| { + v.as_object() + .expect("schema_for!(T) must serialize to a JSON object") + .clone() + }) .expect("Schema serialization must succeed") } diff --git a/crates/goose/src/goose_apps/app.rs b/crates/goose/src/goose_apps/app.rs index e2e123fada4a..adef080fc7b2 100644 --- a/crates/goose/src/goose_apps/app.rs +++ b/crates/goose/src/goose_apps/app.rs @@ -216,7 +216,12 @@ pub async fn fetch_mcp_apps( for (extension_name, resource) in ui_resources { match extension_manager - .read_resource(session_id, &resource.uri, &extension_name, CancellationToken::default()) + .read_resource( + session_id, + &resource.uri, + &extension_name, + CancellationToken::default(), + ) .await { Ok(read_result) => {