diff --git a/README.md b/README.md
index 1f6566a..6bd7d26 100644
--- a/README.md
+++ b/README.md
@@ -75,6 +75,17 @@ Add your public URL + /mcp to ChatGPT's `"Settings > Connectors"` .
fastapps create additional-widget
```
+### Using Widget Templates
+
+FastApps provides pre-built templates to jumpstart your widget development:
+
+```bash
+# Create widget from a template
+fastapps create my-list --list # Vertical list with items
+fastapps create my-carousel --carousel # Horizontal scrolling cards
+fastapps create my-albums --albums # Photo gallery viewer
+```
+
### Editing Your Widget
diff --git a/fastapps/__init__.py b/fastapps/__init__.py
index 5e05efa..737f3e1 100644
--- a/fastapps/__init__.py
+++ b/fastapps/__init__.py
@@ -49,13 +49,4 @@ async def execute(self, input_data) -> Dict[str, Any]:
"WidgetBuildResult",
"Field",
"ConfigDict",
- # Dev server API
- "start_dev_server",
- "start_dev_server_with_config",
- "get_server_info",
- "run_dev_server",
- "DevServerConfig",
- "ServerInfo",
- "DevServerError",
- "ProjectNotFoundError",
] + _auth_exports
diff --git a/fastapps/builder/build-all.mts b/fastapps/builder/build-all.mts
index 0ad62d4..1b7b1ea 100644
--- a/fastapps/builder/build-all.mts
+++ b/fastapps/builder/build-all.mts
@@ -9,6 +9,16 @@ import crypto from "crypto";
const pkgPath = path.join(process.cwd(), "package.json");
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
+// Auto-detect and import Tailwind CSS if available
+let tailwindcss: any = null;
+try {
+ const tailwindModule = await import("@tailwindcss/vite");
+ tailwindcss = tailwindModule.default;
+ console.log("✓ Tailwind CSS detected");
+} catch (e) {
+ // Tailwind not installed, skip
+}
+
// Find all widget directories with index.{tsx,jsx}
const widgetDirs = fg.sync("widgets/*/", { onlyDirectories: true });
const entries = widgetDirs.map((dir) => {
@@ -18,10 +28,20 @@ const entries = widgetDirs.map((dir) => {
}).filter(Boolean);
const outDir = "assets";
+// Determine build mode: 'hosted' (default) or 'inline'
+const modeEnv = (process.env.MODE ?? "").toLowerCase();
+const MODE: "hosted" | "inline" = modeEnv === "inline" ? "inline" : "hosted";
+
+// Global CSS that applies to all widgets
+const GLOBAL_CSS_PATH = path.resolve("widgets/index.css");
+const PER_ENTRY_CSS_GLOB = "**/*.{css,pcss,scss,sass}";
+const PER_ENTRY_CSS_IGNORE = ["**/*.module.*"];
+
function wrapEntryPlugin(
virtualId: string,
entryFile: string,
- widgetName: string
+ widgetName: string,
+ cssPaths: string[]
): Plugin {
return {
name: `virtual-entry-wrapper:${entryFile}`,
@@ -33,8 +53,14 @@ function wrapEntryPlugin(
return null;
}
+ // Import CSS files (global first, then per-entry)
+ const cssImports = cssPaths
+ .map((css) => `import ${JSON.stringify(css)};`)
+ .join("\n");
+
// Automatically add mounting logic - no _app.jsx needed!
return `
+ ${cssImports}
import React from 'react';
import { createRoot } from 'react-dom/client';
import Component from ${JSON.stringify(entryFile)};
@@ -61,13 +87,32 @@ for (const file of entries) {
const name = path.basename(path.dirname(file));
const entryAbs = path.resolve(file);
+ const entryDir = path.dirname(entryAbs);
+
+ // Collect CSS paths: global first, then per-entry
+ const cssPaths: string[] = [];
+
+ // Add global CSS if it exists
+ if (fs.existsSync(GLOBAL_CSS_PATH)) {
+ cssPaths.push(GLOBAL_CSS_PATH);
+ }
+
+ // Add per-entry CSS
+ const perEntryCss = fg.sync(PER_ENTRY_CSS_GLOB, {
+ cwd: entryDir,
+ absolute: true,
+ dot: false,
+ ignore: PER_ENTRY_CSS_IGNORE,
+ });
+ cssPaths.push(...perEntryCss);
const virtualId = `\0virtual-entry:${entryAbs}`;
const createConfig = (): InlineConfig => ({
plugins: [
- wrapEntryPlugin(virtualId, entryAbs, name),
+ wrapEntryPlugin(virtualId, entryAbs, name, cssPaths),
react(),
+ ...(tailwindcss ? [tailwindcss()] : []),
{
name: "remove-manual-chunks",
outputOptions(options) {
@@ -143,32 +188,56 @@ console.groupEnd();
console.log("new hash: ", h);
-for (const name of builtNames) {
- const dir = outDir;
- const htmlPath = path.join(dir, `${name}-${h}.html`);
- const cssPath = path.join(dir, `${name}-${h}.css`);
- const jsPath = path.join(dir, `${name}-${h}.js`);
-
- const css = fs.existsSync(cssPath)
- ? fs.readFileSync(cssPath, { encoding: "utf8" })
- : "";
- const js = fs.existsSync(jsPath)
- ? fs.readFileSync(jsPath, { encoding: "utf8" })
- : "";
-
- const cssBlock = css ? `\n \n` : "";
- const jsBlock = js ? `\n ` : "";
-
- const html = [
- "",
- "",
- `
${cssBlock}`,
- "",
- ` ${jsBlock}`,
- "",
- "",
- ].join("\n");
- fs.writeFileSync(htmlPath, html, { encoding: "utf8" });
- console.log(`${htmlPath} (generated)`);
+if (MODE === "inline") {
+ for (const name of builtNames) {
+ const dir = outDir;
+ const htmlPath = path.join(dir, `${name}-${h}.html`);
+ const cssPath = path.join(dir, `${name}-${h}.css`);
+ const jsPath = path.join(dir, `${name}-${h}.js`);
+
+ const css = fs.existsSync(cssPath)
+ ? fs.readFileSync(cssPath, { encoding: "utf8" })
+ : "";
+ const js = fs.existsSync(jsPath)
+ ? fs.readFileSync(jsPath, { encoding: "utf8" })
+ : "";
+
+ const cssBlock = css ? `\n \n` : "";
+ const jsBlock = js ? `\n ` : "";
+
+ const html = [
+ "",
+ "",
+ `${cssBlock}`,
+ "",
+ ` ${jsBlock}`,
+ "",
+ "",
+ ].join("\n");
+ fs.writeFileSync(htmlPath, html, { encoding: "utf8" });
+ console.log(`${htmlPath} (generated inline)`);
+ }
+} else {
+ const defaultBaseUrl = "http://localhost:4444";
+ const baseUrlCandidate = process.env.BASE_URL?.trim() ?? "";
+ const baseUrlRaw = baseUrlCandidate || defaultBaseUrl;
+ const normalizedBaseUrl = baseUrlRaw.replace(/\/+$/, "");
+ console.log(`Using BASE_URL: ${normalizedBaseUrl}`);
+ for (const name of builtNames) {
+ const dir = outDir;
+ const htmlPath = path.join(dir, `${name}-${h}.html`);
+ const html = `
+
+
+
+
+
+
+
+
+
+`;
+ fs.writeFileSync(htmlPath, html, { encoding: "utf8" });
+ console.log(`${htmlPath} (generated with external references)`);
+ }
}
-
diff --git a/fastapps/builder/compiler.py b/fastapps/builder/compiler.py
index a82c526..b132a34 100644
--- a/fastapps/builder/compiler.py
+++ b/fastapps/builder/compiler.py
@@ -1,3 +1,4 @@
+import os
import platform
import re
import shutil
@@ -31,23 +32,39 @@ def __init__(self, project_root: Path | str):
self.widgets_dir = self.project_root / "widgets"
self.framework_dir = Path(__file__).parent
- def build_all(self) -> Dict[str, WidgetBuildResult]:
+ def build_all(self, mode: str = "hosted") -> Dict[str, WidgetBuildResult]:
"""
Build all widgets in the project.
+ Args:
+ mode: Build mode - "hosted" (default, external JS/CSS references) or
+ "inline" (self-contained HTML)
+
Returns:
Dictionary mapping widget names to build results.
"""
# 1. Auto-discover widgets
self._discover_widgets()
- # 2. Copy framework's build script to project (if not exists)
+ # 2. Ensure unified build script exists in project (if not exists)
self._ensure_build_script()
# 3. Run build (Windows-compatible)
npx_cmd = "npx.cmd" if platform.system() == "Windows" else "npx"
+ build_script = "build-all.mts"
+
+ # Pass mode and BASE_URL (for hosted) via environment
+ env = os.environ.copy()
+ # Explicit mode for the script
+ env["MODE"] = mode
+ if mode == "hosted":
+ env["BASE_URL"] = env.get("BASE_URL", "http://localhost:4444")
+
subprocess.run(
- [npx_cmd, "tsx", "build-all.mts"], cwd=self.project_root, check=True
+ [npx_cmd, "tsx", build_script],
+ cwd=self.project_root,
+ check=True,
+ env=env
)
# 4. Parse results
@@ -57,25 +74,32 @@ def _ensure_build_script(self):
"""
Ensure build script exists.
- The build script is provided by flick-react NPM package.
- Users should have 'flick-react' installed as a devDependency.
+ Args:
+ mode: Build mode - determines which script to copy
"""
- project_build_script = self.project_root / "build-all.mts"
+ # Use unified build script name
+ script_name = "build-all.mts"
- # Check if flick-react is installed in node_modules
- chatjs_build_script = (
- self.project_root / "node_modules" / "fastapps" / "build-all.mts"
- )
+ project_build_script = self.project_root / script_name
+ framework_build_script = self.framework_dir / script_name
+ # Copy from framework if not exists
if not project_build_script.exists():
- if chatjs_build_script.exists():
- # Copy from node_modules
- shutil.copy(chatjs_build_script, project_build_script)
- print("Copied build script from fastapps package")
+ if framework_build_script.exists():
+ shutil.copy(framework_build_script, project_build_script)
+ print(f"Copied {script_name} from FastApps framework")
else:
- raise FileNotFoundError(
- "build-all.mts not found. Please install fastapps: npm install --save-dev fastapps"
+ # Fallback: check node_modules
+ node_modules_script = (
+ self.project_root / "node_modules" / "fastapps" / script_name
)
+ if node_modules_script.exists():
+ shutil.copy(node_modules_script, project_build_script)
+ print(f"Copied {script_name} from fastapps package")
+ else:
+ raise FileNotFoundError(
+ f"{script_name} not found. Please install fastapps: npm install --save-dev fastapps"
+ )
def _discover_widgets(self):
"""
diff --git a/fastapps/cli/commands/create.py b/fastapps/cli/commands/create.py
index 3da1a23..018e35d 100644
--- a/fastapps/cli/commands/create.py
+++ b/fastapps/cli/commands/create.py
@@ -1,178 +1,19 @@
"""Create widget command."""
from pathlib import Path
+import shutil
+import json
+import subprocess
from rich.console import Console
console = Console()
+# Get templates directory (go up from cli/commands/ to fastapps/)
+TEMPLATES_DIR = Path(__file__).parent.parent.parent / "templates"
-def generate_tool_code(
- class_name: str,
- identifier: str,
- title: str,
- auth_type: str = None,
- scopes: list = None,
-) -> str:
- """Generate tool code with optional authentication."""
- # Base imports
- imports = "from fastapps import BaseWidget, ConfigDict"
-
- # Add auth imports if needed
- if auth_type == "required":
- imports += ", auth_required, UserContext"
- elif auth_type == "none":
- imports += ", no_auth"
- elif auth_type == "optional":
- imports += ", optional_auth, UserContext"
- else:
- # Include commented examples
- imports += "\n# from fastapps import auth_required, no_auth, optional_auth, UserContext"
-
- imports += "\nfrom pydantic import BaseModel\nfrom typing import Dict, Any"
-
- # Generate decorator
- decorator = ""
- if auth_type == "required":
- scope_str = f"[{', '.join(repr(s) for s in scopes)}]" if scopes else "[]"
- decorator = f"@auth_required(scopes={scope_str})"
- elif auth_type == "none":
- decorator = "@no_auth"
- elif auth_type == "optional":
- scope_str = f"[{', '.join(repr(s) for s in scopes)}]" if scopes else "[]"
- decorator = f"@optional_auth(scopes={scope_str})"
- else:
- # Commented examples
- decorator = """# Optional: Add authentication
-# @auth_required(scopes=["user"])
-# @no_auth
-# @optional_auth(scopes=["user"])"""
-
- # Generate execute body based on auth type
- if auth_type in ["required", "optional"]:
- execute_body = """ # Access authenticated user
- if user and user.is_authenticated:
- return {
- "message": f"Hello, {user.claims.get('name', 'User')}!",
- "user_id": user.subject,
- "scopes": user.scopes,
- }
-
- return {
- "message": "Welcome to FastApps"
- }"""
- else:
- execute_body = """ return {
- "message": "Welcome to FastApps"
- }"""
-
- # Generate description based on auth type
- if auth_type == "required":
- scope_desc = f" ({', '.join(scopes)})" if scopes else ""
- description = f"Requires authentication{scope_desc}"
- elif auth_type == "none":
- description = "Public widget - no authentication required"
- elif auth_type == "optional":
- description = "Supports both authenticated and anonymous access"
- else:
- description = ""
-
- # Format description line
- description_line = "" if not description else f'\n description = "{description}"'
-
- return f"""{imports}
-
-
-class {class_name}Input(BaseModel):
- model_config = ConfigDict(populate_by_name=True)
-
-
-{decorator}
-class {class_name}Tool(BaseWidget):
- identifier = "{identifier}"
- title = "{title}"{description_line}
- input_schema = {class_name}Input
- invoking = "Loading widget..."
- invoked = "Widget ready!"
-
- widget_csp = {{
- "connect_domains": [],
- "resource_domains": []
- }}
-
- async def execute(self, input_data: {class_name}Input, context=None, user=None) -> Dict[str, Any]:
-{execute_body}
-"""
-
-
-TOOL_TEMPLATE = """from fastapps import BaseWidget, ConfigDict
-from pydantic import BaseModel
-from typing import Dict, Any
-
-# Optional: Add per-widget authentication
-# from fastapps import auth_required, no_auth, optional_auth
-
-
-class {ClassName}Input(BaseModel):
- model_config = ConfigDict(populate_by_name=True)
-
-
-# Optional: Require authentication for this widget
-# @auth_required(scopes=["user"])
-# Or make it explicitly public:
-# @no_auth
-# Or support both authenticated and anonymous:
-# @optional_auth(scopes=["user"])
-class {ClassName}Tool(BaseWidget):
- identifier = "{identifier}"
- title = "{title}"
- input_schema = {ClassName}Input
- invoking = "Loading widget..."
- invoked = "Widget ready!"
-
- widget_csp = {{
- "connect_domains": [],
- "resource_domains": []
- }}
-
- async def execute(self, input_data: {ClassName}Input, context=None, user=None) -> Dict[str, Any]:
- # Access authenticated user (if present)
- # if user and user.is_authenticated:
- # return {{
- # "message": f"Hello {{user.subject}}!",
- # "scopes": user.scopes,
- # "user_data": user.claims
- # }}
-
- return {{
- "message": "Welcome to FastApps"
- }}
-"""
-
-WIDGET_TEMPLATE = """import React from 'react';
-import {{ useWidgetProps }} from 'fastapps';
-
-export default function {ClassName}() {{
- const props = useWidgetProps();
-
- return (
-
-
{{props?.message || 'Welcome to FastApps'}}
-
- );
-}}
-"""
-
-
-def create_widget(name: str, auth_type: str = None, scopes: list = None):
+def create_widget(name: str, auth_type: str = None, scopes: list = None, template: str = None):
"""
Create a new widget with tool and component files.
@@ -180,6 +21,7 @@ def create_widget(name: str, auth_type: str = None, scopes: list = None):
name: Widget name
auth_type: Authentication type ('required', 'none', 'optional', or None)
scopes: List of OAuth scopes
+ template: Template type ('list', 'carousel', 'albums', or None for default)
"""
# Convert name to proper formats
@@ -209,20 +51,199 @@ def create_widget(name: str, auth_type: str = None, scopes: list = None):
tool_dir.mkdir(parents=True, exist_ok=True)
widget_dir.mkdir(parents=True, exist_ok=True)
- # Generate files with auth configuration
- tool_content = generate_tool_code(
- class_name=class_name,
- identifier=identifier,
- title=title,
- auth_type=auth_type,
- scopes=scopes,
- )
+ # Determine template to use
+ template_name = template if template else "default"
+ template_dir = TEMPLATES_DIR / template_name
- widget_content = WIDGET_TEMPLATE.format(ClassName=class_name)
+ if not template_dir.exists():
+ console.print(f"[red]Template directory not found: {template_dir}[/red]")
+ return False
- # Write files
- tool_file.write_text(tool_content)
- widget_file.write_text(widget_content)
+ # Copy and customize tool.py
+ tool_template_file = template_dir / "tool.py"
+ if tool_template_file.exists():
+ tool_content = tool_template_file.read_text()
+ tool_content = tool_content.replace("{ClassName}", class_name)
+ tool_content = tool_content.replace("{identifier}", identifier)
+ tool_content = tool_content.replace("{title}", title)
+
+ # Handle auth configuration for default template
+ if template_name == "default" and auth_type:
+ # Add auth imports and decorators
+ if auth_type == "required":
+ tool_content = tool_content.replace(
+ "# from fastapps import auth_required, no_auth, optional_auth, UserContext",
+ "from fastapps import auth_required, UserContext"
+ )
+ scope_str = f"[{', '.join(repr(s) for s in scopes)}]" if scopes else "[]"
+ tool_content = tool_content.replace(
+ "# @auth_required(scopes=[\"user\"])\n# Or make it explicitly public:\n# @no_auth\n# Or support both authenticated and anonymous:\n# @optional_auth(scopes=[\"user\"])",
+ f"@auth_required(scopes={scope_str})"
+ )
+ elif auth_type == "none":
+ tool_content = tool_content.replace(
+ "# from fastapps import auth_required, no_auth, optional_auth, UserContext",
+ "from fastapps import no_auth"
+ )
+ tool_content = tool_content.replace(
+ "# @auth_required(scopes=[\"user\"])\n# Or make it explicitly public:\n# @no_auth\n# Or support both authenticated and anonymous:\n# @optional_auth(scopes=[\"user\"])",
+ "@no_auth"
+ )
+ elif auth_type == "optional":
+ tool_content = tool_content.replace(
+ "# from fastapps import auth_required, no_auth, optional_auth, UserContext",
+ "from fastapps import optional_auth, UserContext"
+ )
+ scope_str = f"[{', '.join(repr(s) for s in scopes)}]" if scopes else "[]"
+ tool_content = tool_content.replace(
+ "# @auth_required(scopes=[\"user\"])\n# Or make it explicitly public:\n# @no_auth\n# Or support both authenticated and anonymous:\n# @optional_auth(scopes=[\"user\"])",
+ f"@optional_auth(scopes={scope_str})"
+ )
+
+ tool_file.write_text(tool_content)
+
+ # Copy widget files
+ widget_template_dir = template_dir / "widget"
+ if widget_template_dir.exists():
+ for item in widget_template_dir.iterdir():
+ if item.is_file():
+ dest_file = widget_dir / item.name
+ if item.suffix == ".jsx":
+ # Customize .jsx files with class name
+ content = item.read_text()
+ content = content.replace("{ClassName}", class_name)
+ dest_file.write_text(content)
+ else:
+ # Copy other files as-is (like index.css)
+ shutil.copy2(item, dest_file)
+ elif item.is_dir():
+ # Copy subdirectories (like hooks folder) recursively
+ dest_dir = widget_dir / item.name
+ shutil.copytree(item, dest_dir, dirs_exist_ok=True)
+
+ # Install additional dependencies for templates
+ if template in ["list", "carousel", "albums"]:
+ console.print(f"\n[green][OK] Widget '{name}' created from '{template}' template![/green]")
+
+ dep_name = {"list": "Tailwind CSS", "carousel": "Carousel", "albums": "Albums"}.get(template, "Template")
+ console.print(f"\n[cyan]Installing {dep_name} dependencies...[/cyan]")
+
+ # Check if package.json exists
+ package_json_path = Path("package.json")
+ if package_json_path.exists():
+ try:
+ # Read current package.json
+ with open(package_json_path, 'r') as f:
+ package_data = json.load(f)
+
+ # Add Tailwind dependencies to devDependencies
+ if 'devDependencies' not in package_data:
+ package_data['devDependencies'] = {}
+ if 'dependencies' not in package_data:
+ package_data['dependencies'] = {}
+
+ # Template-specific dependencies
+ if template == "list":
+ # Dev dependencies for list
+ template_dev_deps = {
+ "@tailwindcss/vite": "^4.1.11",
+ "autoprefixer": "^10.4.21",
+ "postcss": "^8.5.6",
+ "tailwindcss": "^4.1.11"
+ }
+ # Runtime dependencies for list
+ template_deps = {
+ "lucide-react": "^0.552.0"
+ }
+ elif template == "carousel":
+ # Dev dependencies for carousel
+ template_dev_deps = {
+ "@tailwindcss/vite": "^4.1.11",
+ "autoprefixer": "^10.4.21",
+ "postcss": "^8.5.6",
+ "tailwindcss": "^4.1.11"
+ }
+ # Runtime dependencies for carousel
+ template_deps = {
+ "lucide-react": "^0.552.0",
+ "embla-carousel-react": "^8.6.0"
+ }
+ elif template == "albums":
+ # Dev dependencies for albums
+ template_dev_deps = {
+ "@tailwindcss/vite": "^4.1.11",
+ "autoprefixer": "^10.4.21",
+ "postcss": "^8.5.6",
+ "tailwindcss": "^4.1.11"
+ }
+ # Runtime dependencies for albums
+ template_deps = {
+ "lucide-react": "^0.552.0",
+ "embla-carousel-react": "^8.6.0"
+ }
+ else:
+ template_dev_deps = {}
+ template_deps = {}
+
+ # Check if dependencies already exist
+ deps_to_install = []
+ for dep, version in template_dev_deps.items():
+ if dep not in package_data['devDependencies']:
+ package_data['devDependencies'][dep] = version
+ deps_to_install.append(dep)
+
+ for dep, version in template_deps.items():
+ if dep not in package_data['dependencies']:
+ package_data['dependencies'][dep] = version
+ deps_to_install.append(dep)
+
+ # Write updated package.json
+ if deps_to_install:
+ with open(package_json_path, 'w') as f:
+ json.dump(package_data, f, indent=2)
+
+ dep_type = "template" if template else "Tailwind"
+ console.print(f"[cyan]Added {len(deps_to_install)} {dep_type} dependencies to package.json[/cyan]")
+
+ # Run npm install
+ console.print("[cyan]Running npm install...[/cyan]")
+ try:
+ result = subprocess.run(
+ ["npm", "install"],
+ capture_output=True,
+ text=True,
+ check=True
+ )
+ success_msg = f"{dep_name} dependencies" if template in ["list", "carousel", "albums"] else "Dependencies"
+ console.print(f"[green]✓ {success_msg} installed[/green]")
+ except subprocess.CalledProcessError as e:
+ console.print("[yellow]⚠ npm install failed. Run 'npm install' manually[/yellow]")
+ except FileNotFoundError:
+ console.print("[yellow]⚠ npm not found. Run 'npm install' manually[/yellow]")
+ else:
+ success_msg = f"{dep_name} dependencies" if template in ["list", "carousel", "albums"] else "Dependencies"
+ console.print(f"[green]✓ {success_msg} already installed[/green]")
+
+ except Exception as e:
+ console.print(f"[yellow]⚠ Could not update package.json: {e}[/yellow]")
+ if template in ["carousel", "albums"]:
+ console.print(f"[yellow]Please install {template} dependencies manually:[/yellow]")
+ console.print("[dim] npm install embla-carousel-react lucide-react[/dim]")
+ console.print("[dim] npm install -D @tailwindcss/vite tailwindcss autoprefixer postcss[/dim]")
+ else:
+ console.print("[yellow]Please install Tailwind CSS manually:[/yellow]")
+ console.print("[dim] npm install -D @tailwindcss/vite tailwindcss autoprefixer postcss[/dim]")
+ else:
+ console.print("[yellow]⚠ package.json not found[/yellow]")
+ if template in ["carousel", "albums"]:
+ console.print(f"[yellow]Please install {template} dependencies manually:[/yellow]")
+ console.print("[dim] npm install embla-carousel-react lucide-react[/dim]")
+ console.print("[dim] npm install -D @tailwindcss/vite tailwindcss autoprefixer postcss[/dim]")
+ else:
+ console.print("[yellow]Please install Tailwind CSS manually:[/yellow]")
+ console.print("[dim] npm install -D @tailwindcss/vite tailwindcss autoprefixer postcss[/dim]")
+ else:
+ console.print(f"\n[green][OK] Widget '{name}' created![/green]")
console.print("\n[green][OK] Widget created successfully![/green]")
console.print("\n[cyan]Created files:[/cyan]")
diff --git a/fastapps/cli/commands/dev.py b/fastapps/cli/commands/dev.py
index 22dd87e..948a8c8 100644
--- a/fastapps/cli/commands/dev.py
+++ b/fastapps/cli/commands/dev.py
@@ -1,10 +1,14 @@
"""Development server command with Cloudflare Tunnel integration."""
+import http.server
import json
+import os
import platform
import re
+import socketserver
import subprocess
import sys
+import threading
import time
from pathlib import Path
@@ -114,8 +118,40 @@ def start_cloudflare_tunnel(port: int) -> tuple[subprocess.Popen, str]:
return process, public_url
-def start_dev_server(port=8001, host="0.0.0.0"):
- """Start development server with Cloudflare Tunnel."""
+def start_asset_server(assets_dir: Path, port: int = 4444):
+ """
+ Start static file server for assets (hosted mode).
+
+ Serves files from the assets directory with CORS headers enabled.
+ """
+ class CORSHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, directory=str(assets_dir), **kwargs)
+
+ def end_headers(self):
+ self.send_header('Access-Control-Allow-Origin', '*')
+ self.send_header('Access-Control-Allow-Methods', 'GET, OPTIONS')
+ self.send_header('Access-Control-Allow-Headers', 'Content-Type')
+ super().end_headers()
+
+ def log_message(self, format, *args):
+ # Suppress request logs to keep output clean
+ pass
+
+ handler = CORSHTTPRequestHandler
+ with socketserver.TCPServer(("", port), handler) as httpd:
+ console.print(f"[green]✓ Asset server running on http://localhost:{port}[/green]")
+ httpd.serve_forever()
+
+
+def start_dev_server(port=8001, host="0.0.0.0", mode="hosted"):
+ """Start development server with Cloudflare Tunnel.
+
+ Args:
+ port: Port for MCP server (default: 8001)
+ host: Host to bind server (default: "0.0.0.0")
+ mode: Build mode - "hosted" (default) or "inline"
+ """
# Check if we're in a FastApps project
if not Path("server/main.py").exists():
@@ -125,6 +161,24 @@ def start_dev_server(port=8001, host="0.0.0.0"):
)
return False
+ # Start asset server if hosted mode
+ asset_server_thread = None
+ if mode == "hosted":
+ assets_dir = Path.cwd() / "assets"
+ if not assets_dir.exists():
+ console.print("[yellow]Creating assets directory...[/yellow]")
+ assets_dir.mkdir(parents=True, exist_ok=True)
+
+ console.print(f"[cyan]Starting asset server on port 4444...[/cyan]")
+ asset_server_thread = threading.Thread(
+ target=start_asset_server,
+ args=(assets_dir, 4444),
+ daemon=True
+ )
+ asset_server_thread.start()
+ time.sleep(0.5)
+ console.print()
+
# Check if cloudflared is installed
if not check_cloudflared_installed():
console.print("[yellow]cloudflared not found[/yellow]")
@@ -150,7 +204,8 @@ def start_dev_server(port=8001, host="0.0.0.0"):
# Import project server
sys.path.insert(0, str(Path.cwd()))
# Reset sys.argv to avoid argparse conflicts in server/main.py
- sys.argv = ["server/main.py", "--build"] # Enable build mode for development
+ # Pass mode to server for builder
+ sys.argv = ["server/main.py", "--build", f"--mode={mode}"]
from server.main import app
# Create server config
@@ -158,9 +213,6 @@ def start_dev_server(port=8001, host="0.0.0.0"):
server = uvicorn.Server(config)
# Start server in background thread to show info panel
- import threading
- import time
-
def run_server():
asyncio.run(server.serve())
diff --git a/fastapps/cli/commands/init.py b/fastapps/cli/commands/init.py
index e0012da..3263258 100644
--- a/fastapps/cli/commands/init.py
+++ b/fastapps/cli/commands/init.py
@@ -71,14 +71,20 @@ def auto_load_tools(build_results):
action="store_true",
help="Build widgets on startup (for development)"
)
+parser.add_argument(
+ "--mode",
+ choices=["inline", "hosted"],
+ default="hosted",
+ help="Widget build mode: hosted (default) or inline"
+)
args = parser.parse_args()
# Load build results
if args.build:
# Build widgets on startup
- print(f"[INFO] Building widgets")
+ print(f"[INFO] Building widgets (mode: {args.mode})")
builder = WidgetBuilder(PROJECT_ROOT)
- build_results = builder.build_all()
+ build_results = builder.build_all(mode=args.mode)
else:
# Load pre-built widgets from assets directory
print(f"[INFO] Loading pre-built widgets from assets")
diff --git a/fastapps/cli/main.py b/fastapps/cli/main.py
index 9c70809..c14deea 100644
--- a/fastapps/cli/main.py
+++ b/fastapps/cli/main.py
@@ -50,7 +50,10 @@ def init(project_name):
@click.option("--public", is_flag=True, help="Add no_auth decorator (public widget)")
@click.option("--optional-auth", is_flag=True, help="Add optional_auth decorator")
@click.option("--scopes", help="OAuth scopes (comma-separated, e.g., 'user,read:data')")
-def create(widget_name, auth, public, optional_auth, scopes):
+@click.option("--list", "use_list_template", is_flag=True, help="Create a list widget using the list template")
+@click.option("--carousel", "use_carousel_template", is_flag=True, help="Create a carousel widget using the carousel template")
+@click.option("--albums", "use_albums_template", is_flag=True, help="Create an albums widget using the albums template")
+def create(widget_name, auth, public, optional_auth, scopes, use_list_template, use_carousel_template, use_albums_template):
"""Create a new widget with tool and component files.
Examples:
@@ -58,12 +61,20 @@ def create(widget_name, auth, public, optional_auth, scopes):
fastapps create mywidget --auth --scopes user,read:data
fastapps create mywidget --public
fastapps create mywidget --optional-auth --scopes user
+ fastapps create mywidget --list
+ fastapps create mywidget --carousel
+ fastapps create mywidget --albums
Authentication options:
--auth: Require OAuth authentication
--public: Mark as public (no auth)
--optional-auth: Support both authenticated and anonymous
--scopes: OAuth scopes to require
+
+ Template options:
+ --list: Use the list widget template (includes Tailwind CSS styling)
+ --carousel: Use the carousel widget template (includes Embla Carousel)
+ --albums: Use the albums widget template (includes fullscreen photo viewer)
"""
# Parse scopes
scope_list = scopes.split(",") if scopes else None
@@ -85,7 +96,24 @@ def create(widget_name, auth, public, optional_auth, scopes):
elif optional_auth:
auth_type = "optional"
- create_widget(widget_name, auth_type=auth_type, scopes=scope_list)
+ # Validate template options
+ template_count = sum([use_list_template, use_carousel_template, use_albums_template])
+ if template_count > 1:
+ console.print(
+ "[red]Error: Only one template option allowed (--list, --carousel, or --albums)[/red]"
+ )
+ return
+
+ # Determine template
+ template = None
+ if use_list_template:
+ template = "list"
+ elif use_carousel_template:
+ template = "carousel"
+ elif use_albums_template:
+ template = "albums"
+
+ create_widget(widget_name, auth_type=auth_type, scopes=scope_list, template=template)
@cli.command()
@@ -93,23 +121,34 @@ def create(widget_name, auth, public, optional_auth, scopes):
@click.option(
"--host", default="0.0.0.0", help="Host to bind the server to (default: 0.0.0.0)"
)
-def dev(port, host):
+@click.option(
+ "--mode",
+ type=click.Choice(['inline', 'hosted'], case_sensitive=False),
+ default='hosted',
+ help="Widget build mode: 'hosted' (default, external JS/CSS on port 4444) or 'inline' (self-contained HTML)"
+)
+def dev(port, host, mode):
"""Start development server with Cloudflare Tunnel.
This command will:
- 1. Build widgets
+ 1. Build widgets (inline or hosted mode)
2. Install cloudflared if needed (automatic, no token required)
3. Start a public Cloudflare Tunnel
4. Launch the FastApps development server
5. Display public and local URLs
- Example:
- fastapps dev
- fastapps dev --port 8080
+ Build modes:
+ --mode=hosted : Widgets reference external JS/CSS from localhost:4444 (default, faster dev, ChatGPT compatible)
+ --mode=inline : Widgets built as self-contained HTML (production-ready)
+
+ Examples:
+ fastapps dev # Hosted mode (default)
+ fastapps dev --mode=inline # Inline mode (self-contained)
+ fastapps dev --port 8080 # Custom port
Note: Uses Cloudflare Tunnel (free, unlimited, no sign-up required)
"""
- start_dev_server(port=port, host=host)
+ start_dev_server(port=port, host=host, mode=mode)
@cli.command()
diff --git a/fastapps/templates/albums/tool.py b/fastapps/templates/albums/tool.py
new file mode 100644
index 0000000..31be6a9
--- /dev/null
+++ b/fastapps/templates/albums/tool.py
@@ -0,0 +1,89 @@
+from fastapps import BaseWidget, ConfigDict
+from pydantic import BaseModel, Field
+
+
+class {ClassName}Input(BaseModel):
+ model_config = ConfigDict(populate_by_name=True)
+
+ # Example parameter - customize based on your widget's needs
+ query: str = Field(
+ default="",
+ description="Search query or filter parameter"
+ )
+
+
+class {ClassName}Tool(BaseWidget):
+ identifier = "{identifier}"
+ title = "{title}"
+ description = "Photo albums gallery widget with fullscreen viewer"
+ invoking = "Loading albums..."
+ invoked = "Albums ready!"
+ input_schema = {ClassName}Input
+
+ async def execute(self, input_data: {ClassName}Input, context=None, user=None):
+ # Example: Return sample albums
+ return {
+ "albums": [
+ {
+ "id": "album-1",
+ "title": "Sample Album 1",
+ "cover": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-1.png",
+ "photos": [
+ {
+ "id": "p1",
+ "title": "Photo 1",
+ "url": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-1.png"
+ },
+ {
+ "id": "p2",
+ "title": "Photo 2",
+ "url": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-2.png"
+ },
+ {
+ "id": "p3",
+ "title": "Photo 3",
+ "url": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-3.png"
+ }
+ ]
+ },
+ {
+ "id": "album-2",
+ "title": "Sample Album 2",
+ "cover": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-5.png",
+ "photos": [
+ {
+ "id": "p4",
+ "title": "Photo 4",
+ "url": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-4.png"
+ },
+ {
+ "id": "p5",
+ "title": "Photo 5",
+ "url": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-5.png"
+ }
+ ]
+ },
+ {
+ "id": "album-3",
+ "title": "Sample Album 3",
+ "cover": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-7.png",
+ "photos": [
+ {
+ "id": "p6",
+ "title": "Photo 6",
+ "url": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-6.png"
+ },
+ {
+ "id": "p7",
+ "title": "Photo 7",
+ "url": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-7.png"
+ },
+ {
+ "id": "p8",
+ "title": "Photo 8",
+ "url": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-8.png"
+ }
+ ]
+ }
+ ]
+ }
diff --git a/fastapps/templates/albums/widget/AlbumCard.jsx b/fastapps/templates/albums/widget/AlbumCard.jsx
new file mode 100644
index 0000000..9867194
--- /dev/null
+++ b/fastapps/templates/albums/widget/AlbumCard.jsx
@@ -0,0 +1,28 @@
+import React from "react";
+
+function AlbumCard({ album, onSelect }) {
+ return (
+
+ );
+}
+
+export default AlbumCard;
diff --git a/fastapps/templates/albums/widget/FilmStrip.jsx b/fastapps/templates/albums/widget/FilmStrip.jsx
new file mode 100644
index 0000000..049cd95
--- /dev/null
+++ b/fastapps/templates/albums/widget/FilmStrip.jsx
@@ -0,0 +1,30 @@
+import React from "react";
+
+export default function FilmStrip({ album, selectedIndex, onSelect }) {
+ return (
+
+ {album.photos.map((photo, idx) => (
+
+ ))}
+
+ );
+}
diff --git a/fastapps/templates/albums/widget/FullscreenViewer.jsx b/fastapps/templates/albums/widget/FullscreenViewer.jsx
new file mode 100644
index 0000000..6ae2802
--- /dev/null
+++ b/fastapps/templates/albums/widget/FullscreenViewer.jsx
@@ -0,0 +1,60 @@
+import React from "react";
+import { ArrowLeft } from "lucide-react";
+import { useMaxHeight } from "./hooks/use-max-height";
+import FilmStrip from "./FilmStrip";
+
+export default function FullscreenViewer({ album, onBack }) {
+ const maxHeight = useMaxHeight() ?? undefined;
+ const [index, setIndex] = React.useState(0);
+
+ React.useEffect(() => {
+ setIndex(0);
+ }, [album?.id]);
+
+ const photo = album?.photos?.[index];
+
+ return (
+
+ {/* Back button */}
+ {onBack && (
+
+ )}
+
+
+ {/* Film strip */}
+
+
+
+ {/* Main photo */}
+
+
+ {photo ? (
+

+ ) : null}
+
+
+
+
+ );
+}
diff --git a/fastapps/templates/albums/widget/hooks/types.ts b/fastapps/templates/albums/widget/hooks/types.ts
new file mode 100644
index 0000000..765164e
--- /dev/null
+++ b/fastapps/templates/albums/widget/hooks/types.ts
@@ -0,0 +1,101 @@
+export type OpenAiGlobals<
+ ToolInput = UnknownObject,
+ ToolOutput = UnknownObject,
+ ToolResponseMetadata = UnknownObject,
+ WidgetState = UnknownObject
+> = {
+ // visuals
+ theme: Theme;
+
+ userAgent: UserAgent;
+ locale: string;
+
+ // layout
+ maxHeight: number;
+ displayMode: DisplayMode;
+ safeArea: SafeArea;
+
+ // state
+ toolInput: ToolInput;
+ toolOutput: ToolOutput | null;
+ toolResponseMetadata: ToolResponseMetadata | null;
+ widgetState: WidgetState | null;
+ setWidgetState: (state: WidgetState) => Promise;
+};
+
+// currently copied from types.ts in chatgpt/web-sandbox.
+// Will eventually use a public package.
+type API = {
+ callTool: CallTool;
+ sendFollowUpMessage: (args: { prompt: string }) => Promise;
+ openExternal(payload: { href: string }): void;
+
+ // Layout controls
+ requestDisplayMode: RequestDisplayMode;
+};
+
+export type UnknownObject = Record;
+
+export type Theme = "light" | "dark";
+
+export type SafeAreaInsets = {
+ top: number;
+ bottom: number;
+ left: number;
+ right: number;
+};
+
+export type SafeArea = {
+ insets: SafeAreaInsets;
+};
+
+export type DeviceType = "mobile" | "tablet" | "desktop" | "unknown";
+
+export type UserAgent = {
+ device: { type: DeviceType };
+ capabilities: {
+ hover: boolean;
+ touch: boolean;
+ };
+};
+
+/** Display mode */
+export type DisplayMode = "pip" | "inline" | "fullscreen";
+export type RequestDisplayMode = (args: { mode: DisplayMode }) => Promise<{
+ /**
+ * The granted display mode. The host may reject the request.
+ * For mobile, PiP is always coerced to fullscreen.
+ */
+ mode: DisplayMode;
+}>;
+
+export type CallToolResponse = {
+ result: string;
+};
+
+/** Calling APIs */
+export type CallTool = (
+ name: string,
+ args: Record
+) => Promise;
+
+/** Extra events */
+export const SET_GLOBALS_EVENT_TYPE = "openai:set_globals";
+export class SetGlobalsEvent extends CustomEvent<{
+ globals: Partial;
+}> {
+ readonly type = SET_GLOBALS_EVENT_TYPE;
+}
+
+/**
+ * Global oai object injected by the web sandbox for communicating with chatgpt host page.
+ */
+declare global {
+ interface Window {
+ openai: API & OpenAiGlobals;
+ }
+
+ interface WindowEventMap {
+ [SET_GLOBALS_EVENT_TYPE]: SetGlobalsEvent;
+ }
+}
diff --git a/fastapps/templates/albums/widget/hooks/use-max-height.ts b/fastapps/templates/albums/widget/hooks/use-max-height.ts
new file mode 100644
index 0000000..5e0d8c3
--- /dev/null
+++ b/fastapps/templates/albums/widget/hooks/use-max-height.ts
@@ -0,0 +1,5 @@
+import { useOpenAiGlobal } from "./use-openai-global";
+
+export const useMaxHeight = (): number | null => {
+ return useOpenAiGlobal("maxHeight");
+};
diff --git a/fastapps/templates/albums/widget/hooks/use-openai-global.ts b/fastapps/templates/albums/widget/hooks/use-openai-global.ts
new file mode 100644
index 0000000..f8a20a1
--- /dev/null
+++ b/fastapps/templates/albums/widget/hooks/use-openai-global.ts
@@ -0,0 +1,37 @@
+import { useSyncExternalStore } from "react";
+import {
+ SET_GLOBALS_EVENT_TYPE,
+ SetGlobalsEvent,
+ type OpenAiGlobals,
+} from "./types";
+
+export function useOpenAiGlobal(
+ key: K
+): OpenAiGlobals[K] | null {
+ return useSyncExternalStore(
+ (onChange) => {
+ if (typeof window === "undefined") {
+ return () => {};
+ }
+
+ const handleSetGlobal = (event: SetGlobalsEvent) => {
+ const value = event.detail.globals[key];
+ if (value === undefined) {
+ return;
+ }
+
+ onChange();
+ };
+
+ window.addEventListener(SET_GLOBALS_EVENT_TYPE, handleSetGlobal, {
+ passive: true,
+ });
+
+ return () => {
+ window.removeEventListener(SET_GLOBALS_EVENT_TYPE, handleSetGlobal);
+ };
+ },
+ () => window.openai?.[key] ?? null,
+ () => window.openai?.[key] ?? null
+ );
+}
diff --git a/fastapps/templates/albums/widget/index.css b/fastapps/templates/albums/widget/index.css
new file mode 100644
index 0000000..7b9c76c
--- /dev/null
+++ b/fastapps/templates/albums/widget/index.css
@@ -0,0 +1,40 @@
+@import "tailwindcss" source(".");
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer utilities {
+ .overflow-auto > *,
+ .overflow-scroll > *,
+ .overflow-x-auto > *,
+ .overflow-y-auto > * {
+ scrollbar-color: auto;
+ }
+
+ /* Base style for scrollable elements */
+ .overflow-auto,
+ .overflow-scroll,
+ .overflow-x-auto,
+ .overflow-y-auto,
+ .overflow-x-scroll,
+ .overflow-y-scroll {
+ scrollbar-color: rgb(0, 0, 0, 0.1) transparent;
+
+ @media (prefers-color-scheme: dark) {
+ scrollbar-color: rgb(255, 255, 255, 0.1) transparent;
+ }
+ }
+
+ /* Hover state directly on the scrollable element */
+ .overflow-auto:hover,
+ .overflow-scroll:hover,
+ .overflow-x-auto:hover,
+ .overflow-y-auto:hover {
+ scrollbar-color: rgb(0, 0, 0, 0.2) transparent;
+
+ @media (prefers-color-scheme: dark) {
+ scrollbar-color: rgb(255, 255, 255, 0.2) transparent;
+ }
+ }
+}
diff --git a/fastapps/templates/albums/widget/index.jsx b/fastapps/templates/albums/widget/index.jsx
new file mode 100644
index 0000000..0f1ac5e
--- /dev/null
+++ b/fastapps/templates/albums/widget/index.jsx
@@ -0,0 +1,164 @@
+import React, { useEffect } from "react";
+import { useWidgetProps } from "fastapps";
+import useEmblaCarousel from "embla-carousel-react";
+import { ArrowLeft, ArrowRight } from "lucide-react";
+import { useMaxHeight } from "./hooks/use-max-height";
+import { useOpenAiGlobal } from "./hooks/use-openai-global";
+import FullscreenViewer from "./FullscreenViewer";
+import AlbumCard from "./AlbumCard";
+import "./index.css";
+
+function AlbumsCarousel({ onSelect }) {
+ const widgetProps = useWidgetProps();
+ const albums = widgetProps?.albums || [];
+ const [emblaRef, emblaApi] = useEmblaCarousel({
+ align: "center",
+ loop: false,
+ containScroll: "trimSnaps",
+ slidesToScroll: "auto",
+ dragFree: false,
+ });
+ const [canPrev, setCanPrev] = React.useState(false);
+ const [canNext, setCanNext] = React.useState(false);
+
+ React.useEffect(() => {
+ if (!emblaApi) return;
+ const updateButtons = () => {
+ setCanPrev(emblaApi.canScrollPrev());
+ setCanNext(emblaApi.canScrollNext());
+ };
+ updateButtons();
+ emblaApi.on("select", updateButtons);
+ emblaApi.on("reInit", updateButtons);
+ return () => {
+ emblaApi.off("select", updateButtons);
+ emblaApi.off("reInit", updateButtons);
+ };
+ }, [emblaApi]);
+
+ return (
+
+
+
+ {albums.map((album, i) => (
+
+ ))}
+
+
+
+
+ {canPrev && (
+
+ )}
+ {canNext && (
+
+ )}
+
+ );
+}
+
+function {ClassName}() {
+ const widgetProps = useWidgetProps();
+ const displayMode = useOpenAiGlobal("displayMode");
+ const [selectedAlbum, setSelectedAlbum] = React.useState(null);
+ const maxHeight = useMaxHeight() ?? undefined;
+
+ useEffect(() => {
+ if (widgetProps) {
+ console.log('Albums widget props:', widgetProps);
+ }
+ }, [widgetProps]);
+
+ const handleSelectAlbum = (album) => {
+ setSelectedAlbum(album);
+ if (window?.openai?.requestDisplayMode) {
+ window.openai.requestDisplayMode({ mode: "fullscreen" });
+ }
+ };
+
+ const handleBackToAlbums = () => {
+ setSelectedAlbum(null);
+ if (window?.openai?.requestDisplayMode) {
+ window.openai.requestDisplayMode({ mode: "inline" });
+ }
+ };
+
+ return (
+
+ {displayMode !== "fullscreen" && (
+
+ )}
+
+ {displayMode === "fullscreen" && selectedAlbum && (
+
+ )}
+
+ );
+}
+
+export default {ClassName};
diff --git a/fastapps/templates/carousel/tool.py b/fastapps/templates/carousel/tool.py
new file mode 100644
index 0000000..71030d4
--- /dev/null
+++ b/fastapps/templates/carousel/tool.py
@@ -0,0 +1,68 @@
+from fastapps import BaseWidget, ConfigDict
+from pydantic import BaseModel, Field
+
+
+class {ClassName}Input(BaseModel):
+ model_config = ConfigDict(populate_by_name=True)
+
+ # Example parameter - customize based on your widget's needs
+ query: str = Field(
+ default="",
+ description="Search query or filter parameter"
+ )
+
+
+class {ClassName}Tool(BaseWidget):
+ identifier = "{identifier}"
+ title = "{title}"
+ description = "A horizontal scrolling carousel widget"
+ invoking = "Loading carousel..."
+ invoked = "Carousel ready!"
+ input_schema = {ClassName}Input
+
+ async def execute(self, input_data: {ClassName}Input, context=None, user=None):
+ # Example: Return sample cards for carousel
+ return {
+ "cards": [
+ {
+ "id": 1,
+ "name": "Sample Card 1",
+ "thumbnail": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-1.png",
+ "rating": 4.5,
+ "price": "$$",
+ "description": "Description for Card 1"
+ },
+ {
+ "id": 2,
+ "name": "Sample Card 2",
+ "thumbnail": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-5.png",
+ "rating": 4.2,
+ "price": "$$$",
+ "description": "Description for Card 2"
+ },
+ {
+ "id": 3,
+ "name": "Sample Card 3",
+ "thumbnail": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-7.png",
+ "rating": 4.7,
+ "price": "$$",
+ "description": "Description for Card 3"
+ },
+ {
+ "id": 4,
+ "name": "Sample Card 4",
+ "thumbnail": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-9.png",
+ "rating": 3.8,
+ "price": "$",
+ "description": "Description for Card 4"
+ },
+ {
+ "id": 5,
+ "name": "Sample Card 5",
+ "thumbnail": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-10.png",
+ "rating": 4.9,
+ "price": "$$$$",
+ "description": "Description for Card 5"
+ },
+ ]
+ }
diff --git a/fastapps/templates/carousel/widget/Card.jsx b/fastapps/templates/carousel/widget/Card.jsx
new file mode 100644
index 0000000..3f2d01b
--- /dev/null
+++ b/fastapps/templates/carousel/widget/Card.jsx
@@ -0,0 +1,38 @@
+import React from "react";
+import { Star } from "lucide-react";
+
+export default function Card({ card }) {
+ if (!card) return null;
+ return (
+
+
+

+
+
+
{card.name}
+
+
+ {card.rating?.toFixed ? card.rating.toFixed(1) : card.rating}
+ {card.price ? · {card.price} : null}
+
+ {card.description ? (
+
+ {card.description}
+
+ ) : null}
+
+
+
+
+
+ );
+}
diff --git a/fastapps/templates/carousel/widget/index.css b/fastapps/templates/carousel/widget/index.css
new file mode 100644
index 0000000..7b9c76c
--- /dev/null
+++ b/fastapps/templates/carousel/widget/index.css
@@ -0,0 +1,40 @@
+@import "tailwindcss" source(".");
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer utilities {
+ .overflow-auto > *,
+ .overflow-scroll > *,
+ .overflow-x-auto > *,
+ .overflow-y-auto > * {
+ scrollbar-color: auto;
+ }
+
+ /* Base style for scrollable elements */
+ .overflow-auto,
+ .overflow-scroll,
+ .overflow-x-auto,
+ .overflow-y-auto,
+ .overflow-x-scroll,
+ .overflow-y-scroll {
+ scrollbar-color: rgb(0, 0, 0, 0.1) transparent;
+
+ @media (prefers-color-scheme: dark) {
+ scrollbar-color: rgb(255, 255, 255, 0.1) transparent;
+ }
+ }
+
+ /* Hover state directly on the scrollable element */
+ .overflow-auto:hover,
+ .overflow-scroll:hover,
+ .overflow-x-auto:hover,
+ .overflow-y-auto:hover {
+ scrollbar-color: rgb(0, 0, 0, 0.2) transparent;
+
+ @media (prefers-color-scheme: dark) {
+ scrollbar-color: rgb(255, 255, 255, 0.2) transparent;
+ }
+ }
+}
diff --git a/fastapps/templates/carousel/widget/index.jsx b/fastapps/templates/carousel/widget/index.jsx
new file mode 100644
index 0000000..09c5ff8
--- /dev/null
+++ b/fastapps/templates/carousel/widget/index.jsx
@@ -0,0 +1,125 @@
+import React, { useEffect } from "react";
+import { useWidgetProps } from "fastapps";
+import useEmblaCarousel from "embla-carousel-react";
+import { ArrowLeft, ArrowRight } from "lucide-react";
+import Card from "./Card";
+import "./index.css";
+
+function {ClassName}() {
+ const widgetProps = useWidgetProps();
+ const cards = widgetProps?.cards || [];
+
+ useEffect(() => {
+ if (widgetProps) {
+ console.log('Carousel widget props:', widgetProps);
+ }
+ }, [widgetProps]);
+
+ const [emblaRef, emblaApi] = useEmblaCarousel({
+ align: "center",
+ loop: false,
+ containScroll: "trimSnaps",
+ slidesToScroll: "auto",
+ dragFree: false,
+ });
+
+ const [canPrev, setCanPrev] = React.useState(false);
+ const [canNext, setCanNext] = React.useState(false);
+
+ React.useEffect(() => {
+ if (!emblaApi) return;
+ const updateButtons = () => {
+ setCanPrev(emblaApi.canScrollPrev());
+ setCanNext(emblaApi.canScrollNext());
+ };
+ updateButtons();
+ emblaApi.on("select", updateButtons);
+ emblaApi.on("reInit", updateButtons);
+ return () => {
+ emblaApi.off("select", updateButtons);
+ emblaApi.off("reInit", updateButtons);
+ };
+ }, [emblaApi]);
+
+ return (
+
+
+
+ {cards.map((card, i) => (
+
+
+
+ ))}
+
+
+ {/* Edge gradients */}
+
+
+ {canPrev && (
+
+ )}
+ {canNext && (
+
+ )}
+
+ );
+}
+
+export default {ClassName};
diff --git a/fastapps/templates/default/tool.py b/fastapps/templates/default/tool.py
new file mode 100644
index 0000000..8ce3731
--- /dev/null
+++ b/fastapps/templates/default/tool.py
@@ -0,0 +1,42 @@
+from fastapps import BaseWidget, ConfigDict
+from pydantic import BaseModel
+from typing import Dict, Any
+
+# Optional: Add per-widget authentication
+# from fastapps import auth_required, no_auth, optional_auth, UserContext
+
+
+class {ClassName}Input(BaseModel):
+ model_config = ConfigDict(populate_by_name=True)
+
+
+# Optional: Require authentication for this widget
+# @auth_required(scopes=["user"])
+# Or make it explicitly public:
+# @no_auth
+# Or support both authenticated and anonymous:
+# @optional_auth(scopes=["user"])
+class {ClassName}Tool(BaseWidget):
+ identifier = "{identifier}"
+ title = "{title}"
+ input_schema = {ClassName}Input
+ invoking = "Loading widget..."
+ invoked = "Widget ready!"
+
+ widget_csp = {
+ "connect_domains": [],
+ "resource_domains": []
+ }
+
+ async def execute(self, input_data: {ClassName}Input, context=None, user=None) -> Dict[str, Any]:
+ # Access authenticated user (if present)
+ # if user and user.is_authenticated:
+ # return {
+ # "message": f"Hello {user.subject}!",
+ # "scopes": user.scopes,
+ # "user_data": user.claims
+ # }
+
+ return {
+ "message": "Welcome to FastApps"
+ }
diff --git a/fastapps/templates/default/widget/index.jsx b/fastapps/templates/default/widget/index.jsx
new file mode 100644
index 0000000..8de1186
--- /dev/null
+++ b/fastapps/templates/default/widget/index.jsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import { useWidgetProps } from 'fastapps';
+
+export default function {ClassName}() {
+ const props = useWidgetProps();
+
+ return (
+
+
{props?.message || 'Welcome to FastApps'}
+
+ );
+}
diff --git a/fastapps/templates/list/tool.py b/fastapps/templates/list/tool.py
new file mode 100644
index 0000000..4e29548
--- /dev/null
+++ b/fastapps/templates/list/tool.py
@@ -0,0 +1,65 @@
+from fastapps import BaseWidget, ConfigDict
+from pydantic import BaseModel, Field
+
+
+class {ClassName}Input(BaseModel):
+ model_config = ConfigDict(populate_by_name=True)
+
+ # Example parameter - customize based on your widget's needs
+ query: str = Field(
+ default="",
+ description="Search query or filter parameter"
+ )
+
+
+class {ClassName}Tool(BaseWidget):
+ identifier = "{identifier}"
+ title = "{title}"
+ description = "A ranked list widget"
+ invoking = "Loading list..."
+ invoked = "List ready!"
+ input_schema = {ClassName}Input
+
+ async def execute(self, input_data: {ClassName}Input, context=None, user=None):
+ # Example: Return sample items
+ return {
+ "title": "Sample List",
+ "description": "A list of items",
+ "items": [
+ {
+ "id": 1,
+ "name": "Sample Item 1",
+ "thumbnail": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-1.png",
+ "rating": 4.5,
+ "info": "Additional info 1"
+ },
+ {
+ "id": 2,
+ "name": "Sample Item 2",
+ "thumbnail": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-5.png",
+ "rating": 4.2,
+ "info": "Additional info 2"
+ },
+ {
+ "id": 3,
+ "name": "Sample Item 3",
+ "thumbnail": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-7.png",
+ "rating": 4.7,
+ "info": "Additional info 3"
+ },
+ {
+ "id": 4,
+ "name": "Sample Item 4",
+ "thumbnail": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-9.png",
+ "rating": 3.8,
+ "info": "Additional info 4"
+ },
+ {
+ "id": 5,
+ "name": "Sample Item 5",
+ "thumbnail": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-10.png",
+ "rating": 3.9,
+ "info": "Additional info 5"
+ },
+ ]
+ }
diff --git a/fastapps/templates/list/widget/index.css b/fastapps/templates/list/widget/index.css
new file mode 100644
index 0000000..7b9c76c
--- /dev/null
+++ b/fastapps/templates/list/widget/index.css
@@ -0,0 +1,40 @@
+@import "tailwindcss" source(".");
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer utilities {
+ .overflow-auto > *,
+ .overflow-scroll > *,
+ .overflow-x-auto > *,
+ .overflow-y-auto > * {
+ scrollbar-color: auto;
+ }
+
+ /* Base style for scrollable elements */
+ .overflow-auto,
+ .overflow-scroll,
+ .overflow-x-auto,
+ .overflow-y-auto,
+ .overflow-x-scroll,
+ .overflow-y-scroll {
+ scrollbar-color: rgb(0, 0, 0, 0.1) transparent;
+
+ @media (prefers-color-scheme: dark) {
+ scrollbar-color: rgb(255, 255, 255, 0.1) transparent;
+ }
+ }
+
+ /* Hover state directly on the scrollable element */
+ .overflow-auto:hover,
+ .overflow-scroll:hover,
+ .overflow-x-auto:hover,
+ .overflow-y-auto:hover {
+ scrollbar-color: rgb(0, 0, 0, 0.2) transparent;
+
+ @media (prefers-color-scheme: dark) {
+ scrollbar-color: rgb(255, 255, 255, 0.2) transparent;
+ }
+ }
+}
diff --git a/fastapps/templates/list/widget/index.jsx b/fastapps/templates/list/widget/index.jsx
new file mode 100644
index 0000000..1a077ed
--- /dev/null
+++ b/fastapps/templates/list/widget/index.jsx
@@ -0,0 +1,118 @@
+import React, { useEffect } from "react";
+import { useWidgetProps } from "fastapps";
+import { PlusCircle, Star } from "lucide-react";
+import "./index.css";
+
+function {ClassName}() {
+ const widgetProps = useWidgetProps();
+ const items = widgetProps?.items || [];
+
+ useEffect(() => {
+ if (widgetProps) {
+ console.log('List widget props:', widgetProps);
+ }
+ }, [widgetProps]);
+
+ return (
+
+
+
+
+
+
+ {widgetProps?.title || "List Title"}
+
+
+ {widgetProps?.description || "A list of items"}
+
+
+
+
+
+
+
+ {items.slice(0, 7).map((item, i) => (
+
+
+
+
+

+
+ {i + 1}
+
+
+
+ {item.name}
+
+
+
+
+
+ {item.rating?.toFixed
+ ? item.rating.toFixed(1)
+ : item.rating}
+
+
+
+ {item.info || "–"}
+
+
+
+
+
+
+ {item.info || "–"}
+
+
+
+
+ ))}
+ {items.length === 0 && (
+
+ No items found.
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+export default {ClassName};
diff --git a/pyproject.toml b/pyproject.toml
index 90ce08b..086ce06 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "fastapps"
-version = "1.3.2"
+version = "1.3.3"
description = "A zero-boilerplate framework for building interactive ChatGPT widgets"
readme = "README.md"
requires-python = ">=3.11"