From a57648a1c856dd2d9f3ae11fb14bae33b766c831 Mon Sep 17 00:00:00 2001 From: Aditya-thesys Date: Tue, 21 Jul 2026 19:35:19 +0530 Subject: [PATCH 1/9] skill: document Cloud tools, MCP, and multi-user identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SKILL.md: add an OpenUI Cloud capability matrix (Responses-compatible endpoint, out-of-box tools, artifact editing auto-enabled, fct_ storage plane) and trigger keywords for MCP / custom tools / multi-user queries - cloud-integration.md: two new runbook steps — 'Add Tools and MCP' (server-side tool table, remote MCP example, the app-owned function-tool loop contract and its two safety rules) and 'Multi-User and Multi-App Convention' (fct_ scope binding, APP_ID identity, ownership checks, brownfield recipe); mint example now sends app_id; new verify items - oss-to-cloud-migration.md: custom tool execution is now documented as supported via the template loop instead of a hard boundary --- skills/openui/SKILL.md | 24 +++++- skills/openui/references/cloud-integration.md | 80 ++++++++++++++++++- .../references/oss-to-cloud-migration.md | 2 +- 3 files changed, 100 insertions(+), 6 deletions(-) diff --git a/skills/openui/SKILL.md b/skills/openui/SKILL.md index d99b20442..a85c9affb 100644 --- a/skills/openui/SKILL.md +++ b/skills/openui/SKILL.md @@ -1,6 +1,6 @@ --- name: openui -description: "Use for building, debugging, integrating, migrating, or documenting OpenUI, OpenUI Lang, Agent Interface, OpenUI Cloud, @openuidev packages, streaming generative UI rendering, component libraries, existing-project Cloud integration, self-hosted-to-Cloud migration, and migrations from JSON UI formats." +description: "Use for building, debugging, integrating, migrating, or documenting OpenUI, OpenUI Lang, Agent Interface, OpenUI Cloud, @openuidev packages, streaming generative UI rendering, component libraries, existing-project Cloud integration, self-hosted-to-Cloud migration, migrations from JSON UI formats, Cloud tools (web/image search, artifacts), remote MCP servers, custom function tools and tool loops, and multi-user or multi-app identity (frontend tokens, app_id/user_id, conversation APIs, Responses metadata)." --- # OpenUI @@ -46,6 +46,28 @@ Choose the package for the target runtime. For backend-only parsing or prompt/sc - If the user wants OpenUI Lang rendering in an existing React project without the full React UI surface, use `@openuidev/react-lang`. - If the host app is Vue or Svelte, use `@openuidev/vue-lang` or `@openuidev/svelte-lang`. Use `@openuidev/lang-core` for framework-agnostic parsing, prompt generation, schemas, or backend/runtime work. +## OpenUI Cloud Capabilities + +OpenUI Cloud speaks the OpenAI Responses API (`POST https://api.thesys.dev/v1/embed/responses`, stock `openai` SDK). Check this table before calling anything unsupported: + +| Capability | How | +|---|---| +| Generative UI (OpenUI Lang) | Default response format, streamed in Responses-compatible events | +| Output validation & correction | Invalid model output detected and corrected in-stream; sanitized fallback — no broken UI reaches the renderer | +| Managed model access | Leading providers behind one API (billed at cost), automatic model/provider fallbacks; models list endpoint | +| Artifacts: slides + reports | `artifactTool({ artifacts: ["slides", "report"] })` — generated server-side; **editing is automatically enabled** (the model edits existing artifacts on follow-up asks, no extra config), rendered in the managed viewer | +| Web search | `{ type: "web_search" }` — runs server-side | +| Image search | `{ type: "image_search" }` — runs server-side | +| Remote MCP servers | `{ type: "mcp", server_label, server_url }` — run server-side, declared per request | +| App-owned function tools | `type: "function"` tools + the template's `runFunctionToolLoop` (`src/lib/tool-loop.ts`) — not published as a package: copy the file, or port its two safety rules when writing another language/stack | +| Conversation + artifact persistence | `conversation` + `store: true` persists server-side; the browser reads/edits it DIRECTLY via `useOpenuiCloudStorage` + one fct_ mint route — no proxy routes for the conversation APIs needed. (Alternative: proxy `/v1/conversations*` yourself with the master key.) | +| Multi-user / multi-app isolation | Mint the fct_ with `{ user_id, app_id }` — the token binds the scope, so every browser storage call is automatically limited to that user + app (first-class fields, not metadata) | +| App metadata | `metadata` on conversations and on Responses calls | +| Standard OpenAI Responses params | Being Responses-compatible, `previous_response_id`, `stream: false`, `instructions`, `tool_choice`, `parallel_tool_calls`, `safety_identifier` work as documented by OpenAI — production setups here use `conversation` + `store: true` + streaming | +| Responsive managed UI | `AgentInterface` + `chatLibrary` | + +Tools/MCP and multi-user are steps 8-9 of [references/cloud-integration.md](references/cloud-integration.md). + ## Route Cloud Integration and Migration Tasks Inspect the target project's framework and router, package manifest and lockfile, server runtime, authentication, existing OpenUI imports, chat transport, storage, component library, tools, and artifacts. Preserve its package manager, route conventions, auth boundary, design system, and working behavior. diff --git a/skills/openui/references/cloud-integration.md b/skills/openui/references/cloud-integration.md index 6865fbc22..891c7802e 100644 --- a/skills/openui/references/cloud-integration.md +++ b/skills/openui/references/cloud-integration.md @@ -11,8 +11,10 @@ Use this runbook to add the stock OpenUI Cloud Agent Interface to an existing Re 5. [Authorize Cloud Conversations](#authorize-cloud-conversations) 6. [Add the Generation Proxy](#add-the-generation-proxy) 7. [Add the Frontend Token Route](#add-the-frontend-token-route) -8. [Adapt Beyond Next.js](#adapt-beyond-nextjs) -9. [Verify](#verify) +8. [Add Tools and MCP](#add-tools-and-mcp) +9. [Multi-User and Multi-App Convention](#multi-user-and-multi-app-convention) +10. [Adapt Beyond Next.js](#adapt-beyond-nextjs) +11. [Verify](#verify) ## Supported Contract @@ -23,8 +25,9 @@ The verified happy path provides: - The managed `chatLibrary` component set. - Managed report and presentation artifacts. - A server-side Responses proxy and a server-side frontend-token mint. +- Server-side Cloud tools (artifacts, web/image search, remote MCP) and app-owned function tools via the documented loop ([Add Tools and MCP](#add-tools-and-mcp)). -Do not imply that a browser-only app can safely integrate Cloud: it needs a trusted server boundary. Treat custom Cloud tool execution, historical data import, and generation with a custom component library as separate capabilities that require current first-party support. Do not assume the installed SDK exports a conversation-ownership helper; a production generation proxy needs the explicit ownership design below. +Do not imply that a browser-only app can safely integrate Cloud: it needs a trusted server boundary. Custom tool execution is supported via the documented function-tool loop ([Add Tools and MCP](#add-tools-and-mcp)). Treat historical data import and generation with a custom component library as separate capabilities that require current first-party support. Do not assume the installed SDK exports a conversation-ownership helper; a production generation proxy needs the explicit ownership design below. ## Audit the Host @@ -188,7 +191,11 @@ export async function mintCloudFrontendToken(userId: string): Promise-` into `.env`). Never derive it from the API key — key rotation would orphan every user's history — and never change it after launch. +2. **"Single-user demo or real multi-user?"** Demo: keep the scaffold's `DEMO_USER_ID`. Multi-user: derive `user_id` from the host's server-side session; only the token route changes. + +How scoping works on the Cloud conversation plane: + +- **The fct_ token binds the scope.** Mint it with `POST /v1/frontend-tokens` `{ user_id, app_id }`. With an fct_ token, conversation create/list are locked to the token's user and app — `user_id`/`app_id` in request bodies or query are rejected, so the browser can never widen its own scope. +- **The master key is the server plane.** Create conversations with `user_id` / `app_id` in the body; list org-wide or filtered with `GET /v1/conversations?user_id=`. +- **Ownership fields are first-class, not metadata.** The `metadata` object on conversations (create/update) and the `metadata` param on `POST /v1/embed/responses` are for the app's own data; reserved keys (`userId`, `appId`, `orgId`) are stripped server-side. Do not encode ownership in metadata. +- **Generation still needs an ownership check.** `/api/chat` runs on the master key, so verify the untrusted `threadId` belongs to the session user ([Authorize Cloud Conversations](#authorize-cloud-conversations)). + +Working code: the template's `src/app/api/frontend-token/route.ts` sends `app_id` + demo identity; this runbook's mint helper and ownership designs cover the multi-user variants. + +Brownfield recipe (existing app, real users): + +1. Locate the host's server-side session lookup and its stable user id. +2. Choose `APP_ID` with the user; put it in the host's server env. +3. Token route: mint the fct with `{ user_id: sessionUserId, app_id: process.env.APP_ID }` — never accept `user_id` from the request body. +4. Generation route: enforce thread ownership per [Authorize Cloud Conversations](#authorize-cloud-conversations). +5. Verify isolation: two signed-in users see disjoint thread lists, and two apps with different `APP_ID`s on one org key see disjoint thread lists. + ## Adapt Beyond Next.js Keep the same contracts in other server frameworks: @@ -414,3 +483,6 @@ The managed client packages are React packages. Do not promise a Vue, Svelte, Re 8. Verify ownership-check failures return a service error rather than accidentally authorizing or misreporting them as `403`. 9. Verify a user cannot proxy generation into another user's `threadId`, then confirm two authenticated users receive isolated thread lists. 10. With an authorized test key, stream a reply, reload the page to verify persistence, then create and open one report or presentation. +11. Ask a weather-style question: confirm the declared function tool executes, its result reaches the model's final answer, and no `thesys_*` function_call is ever executed or answered by the app's loop. +12. If MCP is declared, confirm `mcp_list_tools` appears on the stream and that an unreachable server surfaces its `error` instead of failing silently. +13. Confirm two different `APP_ID`s sharing one org key produce disjoint thread lists. diff --git a/skills/openui/references/oss-to-cloud-migration.md b/skills/openui/references/oss-to-cloud-migration.md index 97a3c9a80..aecac8240 100644 --- a/skills/openui/references/oss-to-cloud-migration.md +++ b/skills/openui/references/oss-to-cloud-migration.md @@ -106,7 +106,7 @@ Select the mode on the server or through trusted deployment configuration. Do no ## Respect Unsupported Boundaries - **Historical conversations/artifacts:** no import path is established by the repository sources. Preserve the old store read-only or export it separately; do not fabricate Cloud records. -- **Custom tool execution:** declaring a function tool is not the same as executing it. The app must catch the streamed tool call, execute it, and submit the result through the supported Responses continuation flow. Keep the self-hosted loop unless current Cloud docs provide the full contract. +- **Custom tool execution:** supported. Declare `type: "function"` tools and execute them with the template's `runFunctionToolLoop` (`src/lib/tool-loop.ts`; see cloud-integration.md "Add Tools and MCP"). Never execute or answer Cloud's own `thesys_*` function calls. Prefer a remote MCP server when the capability already exists as one. - **Custom artifact-producing tools:** managed `artifactTool()` covers the documented report and slide path. Do not infer support for arbitrary custom artifacts. - **Attachments and media:** preserve an attachment-capable self-hosted path until the installed Cloud client, generation input, storage, and size-limit contracts are verified end to end. - **Non-React clients:** the verified managed client surface is React. Require a first-party runtime/example before promising another framework. From 68736e8b09a6f323bdbc2193e679482f1813ffdb Mon Sep 17 00:00:00 2001 From: Aditya-thesys Date: Tue, 21 Jul 2026 19:35:19 +0530 Subject: [PATCH 2/9] cloud template: function-tool loop, MCP example, identity and scaffold fixes - ship src/lib/tool-loop.ts (runFunctionToolLoop): executes only declared tool names and skips calls already answered on the stream, so app tools coexist safely with Cloud's server-side tools - add get_weather (Open-Meteo, no key) as the wired reference tool and a commented remote-MCP example in the chat route - typed missing/invalid API key errors in both routes - scaffold identity: create-app generates a stable APP_ID into .env; the frontend-token route sends it as app_id so apps sharing an org key stay isolated - default model to google/gemini-3.5-flash-free; unpin dev/start ports - pnpm.onlyBuiltDependencies for sharp/unrs-resolver (approve-builds no longer interrupts install) - ship template gitignore un-dotted and restore the real name at scaffold time (npm strips nested .gitignore files from published packages) - add default favicon --- .../openui-cli/src/commands/create-app.ts | 41 ++++- .../openui-cloud/{.gitignore => gitignore} | 0 .../src/templates/openui-cloud/package.json | 10 +- .../openui-cloud/src/app/api/chat/route.ts | 117 ++++++++++---- .../src/app/api/frontend-token/route.ts | 50 +++++- .../templates/openui-cloud/src/app/icon.svg | 9 ++ .../templates/openui-cloud/src/lib/models.ts | 2 +- .../openui-cloud/src/lib/tool-loop.ts | 143 ++++++++++++++++++ .../openui-cloud/src/lib/tools/get-weather.ts | 130 ++++++++++++++++ .../{.gitignore => gitignore} | 0 10 files changed, 460 insertions(+), 42 deletions(-) rename packages/openui-cli/src/templates/openui-cloud/{.gitignore => gitignore} (100%) create mode 100644 packages/openui-cli/src/templates/openui-cloud/src/app/icon.svg create mode 100644 packages/openui-cli/src/templates/openui-cloud/src/lib/tool-loop.ts create mode 100644 packages/openui-cli/src/templates/openui-cloud/src/lib/tools/get-weather.ts rename packages/openui-cli/src/templates/openui-self-hosted/{.gitignore => gitignore} (100%) diff --git a/packages/openui-cli/src/commands/create-app.ts b/packages/openui-cli/src/commands/create-app.ts index cf21ade50..7a14bb0e8 100644 --- a/packages/openui-cli/src/commands/create-app.ts +++ b/packages/openui-cli/src/commands/create-app.ts @@ -19,6 +19,29 @@ function shouldCopyTemplatePath(templateDir: string, src: string): boolean { return !["node_modules", ".next", ".turbo", "dist"].includes(top); } +function restoreDotfiles(projectDir: string) { + // Templates ship `gitignore` un-dotted: npm silently strips `.gitignore` + // files (at any depth) from published packages, so a dotted copy never + // reaches the scaffold — and freshly created apps would commit `.env`. + // Restore the real name here instead. + const plain = path.join(projectDir, "gitignore"); + if (fs.existsSync(plain)) { + fs.renameSync(plain, path.join(projectDir, ".gitignore")); + } +} + +function buildAppId(name: string): string { + // Stable per-scaffold identity (see writeEnv). Random suffix keeps two + // same-named apps in one org from colliding. + const slug = + name + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 32) || "app"; + return `${slug}-${Math.random().toString(36).slice(2, 8)}`; +} + function rewritePackageJson(projectDir: string, name: string) { // package.json: set the project name and de-vendor monorepo-local deps // (workspace:* / file: / catalog:) to the published "latest". link: deps are @@ -149,6 +172,7 @@ export async function runCreateApp(options: CreateAppOptions): Promise { recursive: true, filter: (src) => shouldCopyTemplatePath(templateDir, src), }); + restoreDotfiles(targetDir); rewritePackageJson(targetDir, name); } catch (err) { captureScaffoldFailed(); @@ -160,7 +184,7 @@ export async function runCreateApp(options: CreateAppOptions): Promise { ai_setup: aiSetup, }); - await writeEnv(targetDir, envResult); + await writeEnv(targetDir, envResult, template === "openui-cloud" ? buildAppId(name) : undefined); telemetry.capture("cli_env_resolved", { ...createFunnelProps("env_written"), template, @@ -241,9 +265,18 @@ export async function runCreateApp(options: CreateAppOptions): Promise { ); } -async function writeEnv(targetDir: string, result: EnvResult): Promise { - if (!result.envContent) return; - await fs.promises.writeFile(path.join(targetDir, ".env"), result.envContent); +async function writeEnv( + targetDir: string, + result: EnvResult, + appId?: string, +): Promise { + // APP_ID is the scaffold's stable identity: the frontend-token route sends + // it as `app_id`, so every conversation this app creates is bound to it and + // apps sharing one org API key stay isolated from each other. It must stay + // stable for the app's lifetime — regenerating it orphans existing threads. + const content = `${result.envContent ?? ""}${appId ? `APP_ID=${appId}\n` : ""}`; + if (!content) return; + await fs.promises.writeFile(path.join(targetDir, ".env"), content); } async function resolveChatEnv(interactive: boolean): Promise { diff --git a/packages/openui-cli/src/templates/openui-cloud/.gitignore b/packages/openui-cli/src/templates/openui-cloud/gitignore similarity index 100% rename from packages/openui-cli/src/templates/openui-cloud/.gitignore rename to packages/openui-cli/src/templates/openui-cloud/gitignore diff --git a/packages/openui-cli/src/templates/openui-cloud/package.json b/packages/openui-cli/src/templates/openui-cloud/package.json index 294d28682..1f02d793a 100644 --- a/packages/openui-cli/src/templates/openui-cloud/package.json +++ b/packages/openui-cli/src/templates/openui-cloud/package.json @@ -3,9 +3,9 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev -p 3300", + "dev": "next dev", "build": "next build", - "start": "next start -p 3300", + "start": "next start", "lint": "eslint", "typecheck": "tsc --noEmit", "test": "vitest run" @@ -53,5 +53,11 @@ "tailwindcss": "^4", "typescript": "^5", "vitest": "^4.1.0" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "sharp", + "unrs-resolver" + ] } } diff --git a/packages/openui-cli/src/templates/openui-cloud/src/app/api/chat/route.ts b/packages/openui-cli/src/templates/openui-cloud/src/app/api/chat/route.ts index 94498d4b8..dce893cf9 100644 --- a/packages/openui-cli/src/templates/openui-cloud/src/app/api/chat/route.ts +++ b/packages/openui-cli/src/templates/openui-cloud/src/app/api/chat/route.ts @@ -1,6 +1,8 @@ import { getBillingCreditsErrorMessage } from "@/lib/billing"; -import { envOr, requiredEnv } from "@/lib/env"; +import { envOr } from "@/lib/env"; import { DEFAULT_MODEL, resolveRequestedModel } from "@/lib/models"; +import { runFunctionToolLoop } from "@/lib/tool-loop"; +import { executeGetWeather, getWeatherTool } from "@/lib/tools/get-weather"; import { artifactTool, createResponsesInstructions } from "@openuidev/thesys-server"; import OpenAI from "openai"; import type { ResponseInputItem } from "openai/resources/responses/responses"; @@ -13,9 +15,11 @@ import type { ResponseInputItem } from "openai/resources/responses/responses"; * stream straight to the browser, where `openAIResponsesAdapter` parses it * (including the custom `response.artifact_call.delta` events). * - * The artifact tool runs **server-side** inside OpenUI Cloud, so this route is a - * pure pipe: there is no client-side tool loop. Reads/edits go browser → /v1/* - * with the fct_ token (see /api/frontend-token + the storage adapter). + * Cloud's built-in tools (artifacts / web_search / image_search / MCP) run + * server-side inside OpenUI Cloud. App-owned `type: "function"` tools run HERE + * via `runFunctionToolLoop` — `get_weather` ships as the reference example. + * Reads/edits go browser → /v1/* with the fct_ token (see /api/frontend-token + * + the storage adapter). */ export async function POST(req: Request) { const { threadId, input, model: requestedModel } = (await req.json()) as { @@ -37,34 +41,63 @@ export async function POST(req: Request) { ); } + const apiKey = process.env.THESYS_API_KEY; + if (!apiKey) { + return Response.json( + { + error: { + code: "missing_api_key", + message: + "THESYS_API_KEY is not set. Add it to .env (create a key in the Thesys console → API keys) and restart the dev server.", + }, + }, + { status: 500 }, + ); + } + const client = new OpenAI({ baseURL: "https://api.thesys.dev/v1/embed", - apiKey: requiredEnv("THESYS_API_KEY"), // sent as Authorization: Bearer … + apiKey, // sent as Authorization: Bearer … }); + // App-owned function tools, executed in THIS route by runFunctionToolLoop. + // The loop runs ONLY the names declared here — Cloud-internal function_call + // items (thesys_*) pass through untouched. Add your own tools the same way. + const functionTools = { + [getWeatherTool.name]: executeGetWeather, + }; + + const createParams = { + model: resolveRequestedModel(requestedModel, envOr("OPENUI_MODEL", DEFAULT_MODEL)), + conversation: threadId, // store:true persists to the conversation + input, + store: true, + tools: [ + artifactTool({ artifacts: ["slides", "report"] }), + { + type: "web_search", + }, + { + type: "image_search", + }, + getWeatherTool, + // Remote MCP servers run server-side inside OpenUI Cloud — no client + // loop needed. Uncomment to let the model answer questions about any + // public GitHub repo via DeepWiki (no auth required): + // { + // type: "mcp", + // server_label: "deepwiki", + // server_url: "https://mcp.deepwiki.com/mcp", + // }, + ], + instructions: createResponsesInstructions(), + }; + let stream: AsyncIterable>; try { - const model = resolveRequestedModel(requestedModel, envOr("OPENUI_MODEL", DEFAULT_MODEL)); - stream = (await client.responses.create( - { - model, - conversation: threadId, // store:true persists to the conversation - input, - stream: true, - store: true, - tools: [ - artifactTool({ artifacts: ["slides", "report"] }), - { - type: "web_search", - }, - { - type: "image_search", - }, - ], - instructions: createResponsesInstructions(), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { ...createParams, stream: true } as any, { signal: req.signal }, // propagate browser aborts (stop button / tab close) )) as unknown as AsyncIterable>; } catch (err) { @@ -79,29 +112,49 @@ export async function POST(req: Request) { ); } + if (e.status === 401 || e.status === 403) { + return Response.json( + { + error: { + code: "invalid_api_key", + message: + "OpenUI Cloud rejected THESYS_API_KEY. Check the key in .env against the Thesys console → API keys.", + }, + }, + { status: e.status }, + ); + } + return Response.json( { error: e.error ?? { message: e.message ?? "upstream error" } }, { status: e.status ?? 502 }, ); } - // Re-emit each SDK event as SSE for the browser adapter. + // Re-emit each SDK event as SSE for the browser adapter, executing declared + // function tools between model turns. const encoder = new TextEncoder(); const body = new ReadableStream({ async start(controller) { + const enqueue = (event: Record) => { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + }; try { - for await (const event of stream) { - controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); - } + await runFunctionToolLoop({ + client, + createParams, + firstStream: stream, + tools: functionTools, + enqueue, + signal: req.signal, + }); } catch (err) { const message = isRateLimitError(err) ? getBillingCreditsErrorMessage() : err instanceof Error ? err.message : String(err); - controller.enqueue( - encoder.encode(`data: ${JSON.stringify({ type: "error", message })}\n\n`), - ); + enqueue({ type: "error", message }); } finally { controller.close(); } diff --git a/packages/openui-cli/src/templates/openui-cloud/src/app/api/frontend-token/route.ts b/packages/openui-cli/src/templates/openui-cloud/src/app/api/frontend-token/route.ts index 41f57fe81..c2e3e0d7d 100644 --- a/packages/openui-cli/src/templates/openui-cloud/src/app/api/frontend-token/route.ts +++ b/packages/openui-cli/src/templates/openui-cloud/src/app/api/frontend-token/route.ts @@ -1,16 +1,60 @@ -import { envOr, requiredEnv } from "@/lib/env"; +import { envOr } from "@/lib/env"; +/** + * Mints the short-lived fct_ token the browser uses for the storage plane + * (conversations / artifacts). The token BINDS the scope: with an fct_ token, + * conversation create/list are locked to its `user_id` and `app_id` — the + * browser cannot widen them. + * + * `APP_ID` (written to .env at scaffold time) keeps this app's threads + * isolated from other apps sharing the same org API key. Keep it stable — + * changing it orphans the app's existing conversations. + * + * DEMO_USER_ID is single-user demo identity. For real multi-user support, + * derive `user_id` from your server-side auth session here (never from the + * request body) — see the OpenUI skill's cloud-integration reference. + */ export async function POST() { + const apiKey = process.env.THESYS_API_KEY; + if (!apiKey) { + return Response.json( + { + error: { + code: "missing_api_key", + message: + "THESYS_API_KEY is not set. Add it to .env (create a key in the Thesys console → API keys) and restart the dev server.", + }, + }, + { status: 500 }, + ); + } + + const appId = process.env.APP_ID; const upstream = await fetch(`https://api.thesys.dev/v1/frontend-tokens`, { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${requiredEnv("THESYS_API_KEY")}`, + Authorization: `Bearer ${apiKey}`, }, - body: JSON.stringify({ user_id: envOr("DEMO_USER_ID", "demo-user") }), + body: JSON.stringify({ + user_id: envOr("DEMO_USER_ID", "demo-user"), + ...(appId ? { app_id: appId } : {}), + }), }); if (!upstream.ok) { + if (upstream.status === 401 || upstream.status === 403) { + return Response.json( + { + error: { + code: "invalid_api_key", + message: + "OpenUI Cloud rejected THESYS_API_KEY. Check the key in .env against the Thesys console → API keys.", + }, + }, + { status: upstream.status }, + ); + } const errText = await upstream .text() .catch(() => "There was an error in the response from the upstream service."); diff --git a/packages/openui-cli/src/templates/openui-cloud/src/app/icon.svg b/packages/openui-cli/src/templates/openui-cloud/src/app/icon.svg new file mode 100644 index 000000000..e43397114 --- /dev/null +++ b/packages/openui-cli/src/templates/openui-cloud/src/app/icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/openui-cli/src/templates/openui-cloud/src/lib/models.ts b/packages/openui-cli/src/templates/openui-cloud/src/lib/models.ts index 76647bdf9..1f5f21dc8 100644 --- a/packages/openui-cli/src/templates/openui-cloud/src/lib/models.ts +++ b/packages/openui-cli/src/templates/openui-cloud/src/lib/models.ts @@ -1,4 +1,4 @@ -export const DEFAULT_MODEL = "google/gemini-3.1-pro-free"; +export const DEFAULT_MODEL = "google/gemini-3.5-flash-free"; export interface ModelOption { id: string; diff --git a/packages/openui-cli/src/templates/openui-cloud/src/lib/tool-loop.ts b/packages/openui-cli/src/templates/openui-cloud/src/lib/tool-loop.ts new file mode 100644 index 000000000..bb1bc5ee9 --- /dev/null +++ b/packages/openui-cli/src/templates/openui-cloud/src/lib/tool-loop.ts @@ -0,0 +1,143 @@ +import type OpenAI from "openai"; +import type { ResponseInputItem } from "openai/resources/responses/responses"; + +/** + * Function-tool execution loop for the OpenUI Cloud Responses API. + * + * OpenUI Cloud executes its own tools (artifacts, web_search, image_search, + * MCP) server-side, but `type: "function"` tools you declare are executed by + * YOUR server: the model emits a `function_call`, you run it, post the + * `function_call_output` back, and the model continues — possibly calling more + * tools — until it produces the final answer. + * + * Two rules make this safe alongside Cloud's server-side tools, and both are + * enforced here rather than left to the caller: + * + * 1. Execute ONLY calls whose `name` you declared (the keys of `tools`). + * Cloud streams some of its own tools as real-named `function_call` items + * (e.g. `thesys_generate_report` carrying the artifact program) — those are + * already executed server-side and must never be run or answered again. + * 2. Skip any call whose `call_id` already received a `function_call_output` + * on the same stream. The API never streams an output for a call it wants + * the client to execute, so an output's presence means "already settled". + */ + +export type FunctionToolExecutor = ( + argsJson: string, + ctx: { callId: string; signal?: AbortSignal }, +) => Promise; + +export interface RunFunctionToolLoopOptions { + client: OpenAI; + /** The params of the original request; reused verbatim for continuations. */ + createParams: Record; + /** The already-open stream of the first response. */ + firstStream: AsyncIterable>; + /** name → executor. The keys are the ONLY tool names this loop will run. */ + tools: Record; + /** Receives every stream event (forward these to the browser as SSE). */ + enqueue: (event: Record) => void; + /** Propagates browser aborts into executors and continuation requests. */ + signal?: AbortSignal; + /** Cap on model round-trips after tool results (default 5). */ + maxRounds?: number; +} + +interface PendingCall { + callId: string; + name: string; + argsJson: string; +} + +/** + * Drive the stream to completion, executing declared function tools between + * model turns. Resolves when the model finishes without requesting any of the + * caller's tools (or `maxRounds` is reached). + */ +export async function runFunctionToolLoop(options: RunFunctionToolLoopOptions): Promise { + const { client, createParams, tools, enqueue, signal, maxRounds = 5 } = options; + + let pending = await consumeStream(options.firstStream, tools, enqueue); + + for (let round = 0; round < maxRounds && pending.length > 0; round++) { + const outputs: ResponseInputItem[] = []; + for (const call of pending) { + let output: string; + try { + output = await tools[call.name]!(call.argsJson, { callId: call.callId, signal }); + } catch (err) { + output = JSON.stringify({ error: err instanceof Error ? err.message : String(err) }); + } + const item = { type: "function_call_output" as const, call_id: call.callId, output }; + outputs.push(item); + // Surface the result to the browser so the tool tray can render it. + enqueue({ + type: "response.output_item.added", + item: { ...item, id: `fc_out_${call.callId}` }, + }); + } + + const stream = (await client.responses.create( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { ...createParams, input: outputs, stream: true } as any, + { signal }, + )) as unknown as AsyncIterable>; + + pending = await consumeStream(stream, tools, enqueue); + } +} + +/** + * Forward every event and collect the declared-tool calls that the stream + * leaves unanswered. Arguments may arrive on the added item, as deltas, or on + * the done item — all three are handled, last write wins. + */ +async function consumeStream( + stream: AsyncIterable>, + tools: Record, + enqueue: (event: Record) => void, +): Promise { + const callsByItemId = new Map(); + const calls: PendingCall[] = []; + const answeredCallIds = new Set(); + + for await (const event of stream) { + enqueue(event); + + const type = event.type; + if (type === "response.output_item.added" || type === "response.output_item.done") { + const item = event.item as + | { type?: string; id?: string; call_id?: string; name?: string; arguments?: string } + | undefined; + if (item?.type === "function_call_output" && item.call_id) { + answeredCallIds.add(item.call_id); + } else if (item?.type === "function_call" && item.call_id && item.name) { + const known = + (item.id ? callsByItemId.get(item.id) : undefined) ?? callsByItemId.get(item.call_id); + if (known) { + if (item.arguments != null) known.argsJson = item.arguments; + } else if (item.name in tools) { + // Rule 1: track only declared tools — everything else (including + // Cloud-internal thesys_* calls) is passed through untouched. + const call: PendingCall = { + callId: item.call_id, + name: item.name, + argsJson: item.arguments ?? "", + }; + calls.push(call); + if (item.id) callsByItemId.set(item.id, call); + callsByItemId.set(item.call_id, call); + } + } + } else if (type === "response.function_call_arguments.delta") { + const call = callsByItemId.get(event.item_id as string); + if (call && typeof event.delta === "string") call.argsJson += event.delta; + } else if (type === "response.function_call_arguments.done") { + const call = callsByItemId.get(event.item_id as string); + if (call && typeof event.arguments === "string") call.argsJson = event.arguments; + } + } + + // Rule 2: a call that already has an output on this stream is settled. + return calls.filter((call) => !answeredCallIds.has(call.callId)); +} diff --git a/packages/openui-cli/src/templates/openui-cloud/src/lib/tools/get-weather.ts b/packages/openui-cli/src/templates/openui-cloud/src/lib/tools/get-weather.ts new file mode 100644 index 000000000..75c5d0fb3 --- /dev/null +++ b/packages/openui-cli/src/templates/openui-cloud/src/lib/tools/get-weather.ts @@ -0,0 +1,130 @@ +/** + * Example function tool: current weather via Open-Meteo (free, no API key). + * + * This is the reference for adding your own tools — declare the tool to the + * model (`getWeatherTool`) and execute it on your server (`executeGetWeather`), + * wired together through `runFunctionToolLoop` in the chat route. + */ + +/** OpenAI Responses `type: "function"` declaration sent to the model. */ +export const getWeatherTool = { + type: "function" as const, + name: "get_weather", + description: + "Get the current weather and a short daily forecast for a city or place name. " + + "Use whenever the user asks about weather, temperature, rain, or what to wear.", + parameters: { + type: "object", + properties: { + location: { + type: "string", + description: "City or place name, e.g. 'Berlin' or 'San Francisco'.", + }, + }, + required: ["location"], + additionalProperties: false, + }, + strict: false, +}; + +// https://open-meteo.com/en/docs — WMO weather interpretation codes. +const WEATHER_CODES: Record = { + 0: "clear sky", + 1: "mainly clear", + 2: "partly cloudy", + 3: "overcast", + 45: "fog", + 48: "depositing rime fog", + 51: "light drizzle", + 53: "drizzle", + 55: "dense drizzle", + 61: "light rain", + 63: "rain", + 65: "heavy rain", + 71: "light snow", + 73: "snow", + 75: "heavy snow", + 80: "rain showers", + 81: "rain showers", + 82: "violent rain showers", + 95: "thunderstorm", + 96: "thunderstorm with hail", + 99: "thunderstorm with heavy hail", +}; + +export async function executeGetWeather( + argsJson: string, + ctx: { signal?: AbortSignal } = {}, +): Promise { + let location: string; + try { + const args = JSON.parse(argsJson || "{}") as { location?: unknown }; + if (typeof args.location !== "string" || !args.location.trim()) { + return JSON.stringify({ error: "location is required" }); + } + location = args.location.trim(); + } catch { + return JSON.stringify({ error: "invalid JSON arguments" }); + } + + try { + const geoUrl = new URL("https://geocoding-api.open-meteo.com/v1/search"); + geoUrl.searchParams.set("name", location); + geoUrl.searchParams.set("count", "1"); + const geo = (await (await fetch(geoUrl, { signal: ctx.signal })).json()) as { + results?: Array<{ name: string; country?: string; latitude: number; longitude: number }>; + }; + const place = geo.results?.[0]; + if (!place) return JSON.stringify({ error: `No place found for "${location}"` }); + + const wxUrl = new URL("https://api.open-meteo.com/v1/forecast"); + wxUrl.searchParams.set("latitude", String(place.latitude)); + wxUrl.searchParams.set("longitude", String(place.longitude)); + wxUrl.searchParams.set( + "current", + "temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,wind_speed_10m", + ); + wxUrl.searchParams.set( + "daily", + "temperature_2m_max,temperature_2m_min,precipitation_probability_max", + ); + wxUrl.searchParams.set("forecast_days", "3"); + wxUrl.searchParams.set("timezone", "auto"); + const wx = (await (await fetch(wxUrl, { signal: ctx.signal })).json()) as { + current?: { + temperature_2m: number; + apparent_temperature: number; + relative_humidity_2m: number; + weather_code: number; + wind_speed_10m: number; + }; + daily?: { + time: string[]; + temperature_2m_max: number[]; + temperature_2m_min: number[]; + precipitation_probability_max: number[]; + }; + }; + + return JSON.stringify({ + place: `${place.name}${place.country ? `, ${place.country}` : ""}`, + current: wx.current && { + temperature_c: wx.current.temperature_2m, + feels_like_c: wx.current.apparent_temperature, + humidity_pct: wx.current.relative_humidity_2m, + wind_kmh: wx.current.wind_speed_10m, + conditions: WEATHER_CODES[wx.current.weather_code] ?? "unknown", + }, + daily: wx.daily?.time.map((date, i) => ({ + date, + high_c: wx.daily!.temperature_2m_max[i], + low_c: wx.daily!.temperature_2m_min[i], + precipitation_probability_pct: wx.daily!.precipitation_probability_max[i], + })), + }); + } catch (err) { + return JSON.stringify({ + error: `Weather lookup failed: ${err instanceof Error ? err.message : String(err)}`, + }); + } +} diff --git a/packages/openui-cli/src/templates/openui-self-hosted/.gitignore b/packages/openui-cli/src/templates/openui-self-hosted/gitignore similarity index 100% rename from packages/openui-cli/src/templates/openui-self-hosted/.gitignore rename to packages/openui-cli/src/templates/openui-self-hosted/gitignore From aa56efdaba01bdefde280f7ee860c478949e2f07 Mon Sep 17 00:00:00 2001 From: Aditya-thesys Date: Wed, 22 Jul 2026 14:19:50 +0530 Subject: [PATCH 3/9] cloud template: harden the function-tool loop, narrow the SDK cast - allowlist uses Object.hasOwn so prototype-chain names (toString, constructor, ...) can never match a declared tool - round-cap exhaustion settles instead of dropping (mirrors the openai SDK runTools invariant): the last round posts outputs with tool_choice "none", and calls a non-enforcing server lets through are settled in one final forward-only turn - a stored conversation is never left holding an unanswered function_call - chat route: type createParams as ResponseCreateParamsNonStreaming and confine the cast to the Cloud-extension tool entries (artifact, image_search); no more blanket as-any - pnpm-workspace.yaml: carry the allow-build list where pnpm >=10.14 (onlyBuiltDependencies) and >=11 (allowBuilds) actually read it - the package.json pnpm field is ignored there --- .../openui-cloud/pnpm-workspace.yaml | 10 +++ .../openui-cloud/src/app/api/chat/route.ts | 19 +++--- .../openui-cloud/src/lib/tool-loop.ts | 64 +++++++++++++++---- 3 files changed, 72 insertions(+), 21 deletions(-) create mode 100644 packages/openui-cli/src/templates/openui-cloud/pnpm-workspace.yaml diff --git a/packages/openui-cli/src/templates/openui-cloud/pnpm-workspace.yaml b/packages/openui-cli/src/templates/openui-cloud/pnpm-workspace.yaml new file mode 100644 index 000000000..25df91940 --- /dev/null +++ b/packages/openui-cli/src/templates/openui-cloud/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +# Allow native build scripts so `pnpm install` never stops at (or nags about) +# approve-builds. pnpm reads this from a different place per major version, so +# the same list appears three times: package.json "pnpm" field (<=10.13), +# onlyBuiltDependencies here (10.14-10.x), allowBuilds here (>=11). +onlyBuiltDependencies: + - sharp + - unrs-resolver +allowBuilds: + sharp: true + unrs-resolver: true diff --git a/packages/openui-cli/src/templates/openui-cloud/src/app/api/chat/route.ts b/packages/openui-cli/src/templates/openui-cloud/src/app/api/chat/route.ts index dce893cf9..16a5bdebc 100644 --- a/packages/openui-cli/src/templates/openui-cloud/src/app/api/chat/route.ts +++ b/packages/openui-cli/src/templates/openui-cloud/src/app/api/chat/route.ts @@ -5,7 +5,11 @@ import { runFunctionToolLoop } from "@/lib/tool-loop"; import { executeGetWeather, getWeatherTool } from "@/lib/tools/get-weather"; import { artifactTool, createResponsesInstructions } from "@openuidev/thesys-server"; import OpenAI from "openai"; -import type { ResponseInputItem } from "openai/resources/responses/responses"; +import type { + ResponseCreateParamsNonStreaming, + ResponseInputItem, + Tool, +} from "openai/resources/responses/responses"; /** * Generation plane: browser → THIS route → OpenUI Cloud. @@ -67,19 +71,19 @@ export async function POST(req: Request) { [getWeatherTool.name]: executeGetWeather, }; - const createParams = { + const createParams: ResponseCreateParamsNonStreaming = { model: resolveRequestedModel(requestedModel, envOr("OPENUI_MODEL", DEFAULT_MODEL)), conversation: threadId, // store:true persists to the conversation input, store: true, tools: [ - artifactTool({ artifacts: ["slides", "report"] }), + // artifact/image_search are Cloud extensions of the Responses tools + // union — cast those entries only; the rest stays type-checked. + artifactTool({ artifacts: ["slides", "report"] }) as unknown as Tool, { type: "web_search", }, - { - type: "image_search", - }, + { type: "image_search" } as unknown as Tool, getWeatherTool, // Remote MCP servers run server-side inside OpenUI Cloud — no client // loop needed. Uncomment to let the model answer questions about any @@ -96,8 +100,7 @@ export async function POST(req: Request) { let stream: AsyncIterable>; try { stream = (await client.responses.create( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - { ...createParams, stream: true } as any, + { ...createParams, stream: true }, { signal: req.signal }, // propagate browser aborts (stop button / tab close) )) as unknown as AsyncIterable>; } catch (err) { diff --git a/packages/openui-cli/src/templates/openui-cloud/src/lib/tool-loop.ts b/packages/openui-cli/src/templates/openui-cloud/src/lib/tool-loop.ts index bb1bc5ee9..15defdf7f 100644 --- a/packages/openui-cli/src/templates/openui-cloud/src/lib/tool-loop.ts +++ b/packages/openui-cli/src/templates/openui-cloud/src/lib/tool-loop.ts @@ -1,5 +1,8 @@ import type OpenAI from "openai"; -import type { ResponseInputItem } from "openai/resources/responses/responses"; +import type { + ResponseCreateParamsNonStreaming, + ResponseInputItem, +} from "openai/resources/responses/responses"; /** * Function-tool execution loop for the OpenUI Cloud Responses API. @@ -20,6 +23,17 @@ import type { ResponseInputItem } from "openai/resources/responses/responses"; * 2. Skip any call whose `call_id` already received a `function_call_output` * on the same stream. The API never streams an output for a call it wants * the client to execute, so an output's presence means "already settled". + * + * The loop mirrors the openai SDK's `runTools` invariant: the round cap limits + * additional model turns, never settlement — the last allowed round posts its + * outputs with `tool_choice: "none"`, and calls that a non-enforcing server + * still lets through are settled in one final forward-only turn, so a stored + * conversation is never left holding an unanswered `function_call`. Two + * deliberate deviations from + * `runTools`, both forced by server-side tools + stored conversations: + * undeclared names are ignored rather than answered with an "invalid tool" + * message (rule 1), and executor throws become error outputs rather than + * aborting the run (a mid-run abort would strand calls in the conversation). */ export type FunctionToolExecutor = ( @@ -30,7 +44,7 @@ export type FunctionToolExecutor = ( export interface RunFunctionToolLoopOptions { client: OpenAI; /** The params of the original request; reused verbatim for continuations. */ - createParams: Record; + createParams: ResponseCreateParamsNonStreaming; /** The already-open stream of the first response. */ firstStream: AsyncIterable>; /** name → executor. The keys are the ONLY tool names this loop will run. */ @@ -52,16 +66,17 @@ interface PendingCall { /** * Drive the stream to completion, executing declared function tools between * model turns. Resolves when the model finishes without requesting any of the - * caller's tools (or `maxRounds` is reached). + * caller's tools; if `maxRounds` is reached, the final round still posts its + * outputs but pins `tool_choice: "none"` so the model must answer in text. */ export async function runFunctionToolLoop(options: RunFunctionToolLoopOptions): Promise { const { client, createParams, tools, enqueue, signal, maxRounds = 5 } = options; - let pending = await consumeStream(options.firstStream, tools, enqueue); - - for (let round = 0; round < maxRounds && pending.length > 0; round++) { + // Run every pending call and surface each result to the browser; a throwing + // executor settles as an error output rather than aborting the run. + const executeCalls = async (calls: PendingCall[]): Promise => { const outputs: ResponseInputItem[] = []; - for (const call of pending) { + for (const call of calls) { let output: string; try { output = await tools[call.name]!(call.argsJson, { callId: call.callId, signal }); @@ -70,20 +85,43 @@ export async function runFunctionToolLoop(options: RunFunctionToolLoopOptions): } const item = { type: "function_call_output" as const, call_id: call.callId, output }; outputs.push(item); - // Surface the result to the browser so the tool tray can render it. enqueue({ type: "response.output_item.added", item: { ...item, id: `fc_out_${call.callId}` }, }); } + return outputs; + }; - const stream = (await client.responses.create( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - { ...createParams, input: outputs, stream: true } as any, + const continueWith = (outputs: ResponseInputItem[], settleOnly: boolean) => + client.responses.create( + { + ...createParams, + input: outputs, + stream: true, + ...(settleOnly ? { tool_choice: "none" as const } : {}), + }, { signal }, - )) as unknown as AsyncIterable>; + ) as unknown as Promise>>; + let pending = await consumeStream(options.firstStream, tools, enqueue); + + for (let round = 0; pending.length > 0; round++) { + // Last allowed round: settlement still happens, but the model may not + // request more tools. + const settleOnly = round >= maxRounds - 1; + const stream = await continueWith(await executeCalls(pending), settleOnly); pending = await consumeStream(stream, tools, enqueue); + if (settleOnly) break; + } + + // Non-empty only when the settle round produced NEW calls — i.e. the server + // did not enforce tool_choice:"none" (observed with some models). Settle + // them once more, forward-only: a stored conversation must never be left + // holding an unanswered function_call. + if (pending.length > 0) { + const stream = await continueWith(await executeCalls(pending), true); + for await (const event of stream) enqueue(event); } } @@ -116,7 +154,7 @@ async function consumeStream( (item.id ? callsByItemId.get(item.id) : undefined) ?? callsByItemId.get(item.call_id); if (known) { if (item.arguments != null) known.argsJson = item.arguments; - } else if (item.name in tools) { + } else if (Object.hasOwn(tools, item.name)) { // Rule 1: track only declared tools — everything else (including // Cloud-internal thesys_* calls) is passed through untouched. const call: PendingCall = { From bcc4f83b97dc7c7d95cdcb13325422b4665a6d4b Mon Sep 17 00:00:00 2001 From: Aditya-thesys Date: Wed, 22 Jul 2026 14:44:48 +0530 Subject: [PATCH 4/9] cloud template: openai is a runtime dependency, not a dev one Both API routes import the openai SDK at request time; move it out of devDependencies so production installs (--prod, deploy platforms that prune dev deps) don't lose it. --- packages/openui-cli/src/templates/openui-cloud/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/openui-cli/src/templates/openui-cloud/package.json b/packages/openui-cli/src/templates/openui-cloud/package.json index 1f02d793a..37b386eb0 100644 --- a/packages/openui-cli/src/templates/openui-cloud/package.json +++ b/packages/openui-cli/src/templates/openui-cloud/package.json @@ -30,6 +30,7 @@ "lucide-react": "^0.575.0", "mermaid": "11.15.0", "next": "16.1.6", + "openai": "^6.22.0", "react": "19.2.3", "react-dom": "19.2.3", "recharts": "2.15.4", @@ -49,7 +50,6 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.1.6", - "openai": "^6.22.0", "tailwindcss": "^4", "typescript": "^5", "vitest": "^4.1.0" From 99977a29f2df6d5da77493ce024cc1d3fd944dff Mon Sep 17 00:00:00 2001 From: Aditya-thesys Date: Wed, 22 Jul 2026 15:14:27 +0530 Subject: [PATCH 5/9] cloud template: slim the weather reference tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Current conditions only — drop the 3-day forecast block and collapse the WMO code table into a small family mapper. Still a real Open-Meteo call so the reference keeps demonstrating fetch + abort signal + error-as-JSON. 130 -> 90 lines. --- .../openui-cloud/src/lib/tools/get-weather.ts | 82 +++++-------------- 1 file changed, 20 insertions(+), 62 deletions(-) diff --git a/packages/openui-cli/src/templates/openui-cloud/src/lib/tools/get-weather.ts b/packages/openui-cli/src/templates/openui-cloud/src/lib/tools/get-weather.ts index 75c5d0fb3..cc859075f 100644 --- a/packages/openui-cli/src/templates/openui-cloud/src/lib/tools/get-weather.ts +++ b/packages/openui-cli/src/templates/openui-cloud/src/lib/tools/get-weather.ts @@ -11,8 +11,8 @@ export const getWeatherTool = { type: "function" as const, name: "get_weather", description: - "Get the current weather and a short daily forecast for a city or place name. " + - "Use whenever the user asks about weather, temperature, rain, or what to wear.", + "Get the current weather for a city or place name. Use whenever the user " + + "asks about weather, temperature, rain, or what to wear.", parameters: { type: "object", properties: { @@ -27,30 +27,18 @@ export const getWeatherTool = { strict: false, }; -// https://open-meteo.com/en/docs — WMO weather interpretation codes. -const WEATHER_CODES: Record = { - 0: "clear sky", - 1: "mainly clear", - 2: "partly cloudy", - 3: "overcast", - 45: "fog", - 48: "depositing rime fog", - 51: "light drizzle", - 53: "drizzle", - 55: "dense drizzle", - 61: "light rain", - 63: "rain", - 65: "heavy rain", - 71: "light snow", - 73: "snow", - 75: "heavy snow", - 80: "rain showers", - 81: "rain showers", - 82: "violent rain showers", - 95: "thunderstorm", - 96: "thunderstorm with hail", - 99: "thunderstorm with heavy hail", -}; +// WMO weather codes (https://open-meteo.com/en/docs), collapsed to families. +function describeWeather(code: number): string { + if (code === 0) return "clear sky"; + if (code <= 3) return "partly cloudy"; + if (code <= 48) return "fog"; + if (code <= 57) return "drizzle"; + if (code <= 67) return "rain"; + if (code <= 77) return "snow"; + if (code <= 82) return "rain showers"; + if (code <= 86) return "snow showers"; + return "thunderstorm"; +} export async function executeGetWeather( argsJson: string, @@ -80,47 +68,17 @@ export async function executeGetWeather( const wxUrl = new URL("https://api.open-meteo.com/v1/forecast"); wxUrl.searchParams.set("latitude", String(place.latitude)); wxUrl.searchParams.set("longitude", String(place.longitude)); - wxUrl.searchParams.set( - "current", - "temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,wind_speed_10m", - ); - wxUrl.searchParams.set( - "daily", - "temperature_2m_max,temperature_2m_min,precipitation_probability_max", - ); - wxUrl.searchParams.set("forecast_days", "3"); - wxUrl.searchParams.set("timezone", "auto"); + wxUrl.searchParams.set("current", "temperature_2m,weather_code,wind_speed_10m"); const wx = (await (await fetch(wxUrl, { signal: ctx.signal })).json()) as { - current?: { - temperature_2m: number; - apparent_temperature: number; - relative_humidity_2m: number; - weather_code: number; - wind_speed_10m: number; - }; - daily?: { - time: string[]; - temperature_2m_max: number[]; - temperature_2m_min: number[]; - precipitation_probability_max: number[]; - }; + current?: { temperature_2m: number; weather_code: number; wind_speed_10m: number }; }; + if (!wx.current) return JSON.stringify({ error: "No weather data returned" }); return JSON.stringify({ place: `${place.name}${place.country ? `, ${place.country}` : ""}`, - current: wx.current && { - temperature_c: wx.current.temperature_2m, - feels_like_c: wx.current.apparent_temperature, - humidity_pct: wx.current.relative_humidity_2m, - wind_kmh: wx.current.wind_speed_10m, - conditions: WEATHER_CODES[wx.current.weather_code] ?? "unknown", - }, - daily: wx.daily?.time.map((date, i) => ({ - date, - high_c: wx.daily!.temperature_2m_max[i], - low_c: wx.daily!.temperature_2m_min[i], - precipitation_probability_pct: wx.daily!.precipitation_probability_max[i], - })), + temperature_c: wx.current.temperature_2m, + conditions: describeWeather(wx.current.weather_code), + wind_kmh: wx.current.wind_speed_10m, }); } catch (err) { return JSON.stringify({ From ccb85b81c88e1357c8078bfc09d8387ca0f5aa61 Mon Sep 17 00:00:00 2001 From: Aditya-thesys Date: Wed, 22 Jul 2026 15:23:16 +0530 Subject: [PATCH 6/9] cli: simplify the APP_ID slug to a single replace One regex instead of slug-trim-cap-fallback: collapse every run of non-alphanumerics to a hyphen. Edge names now produce slightly uglier ids (trailing/duplicate hyphens) but stay .env- and query-param-safe, which is the part that matters. --- packages/openui-cli/src/commands/create-app.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/openui-cli/src/commands/create-app.ts b/packages/openui-cli/src/commands/create-app.ts index 7a14bb0e8..632f0b886 100644 --- a/packages/openui-cli/src/commands/create-app.ts +++ b/packages/openui-cli/src/commands/create-app.ts @@ -31,14 +31,10 @@ function restoreDotfiles(projectDir: string) { } function buildAppId(name: string): string { - // Stable per-scaffold identity (see writeEnv). Random suffix keeps two - // same-named apps in one org from colliding. - const slug = - name - .toLowerCase() - .replace(/[^a-z0-9-]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 32) || "app"; + // Stable per-scaffold identity (see writeEnv). Slugified because the name is + // free-form and APP_ID lands in .env and ?app_id= query params; the random + // suffix keeps two same-named apps in one org from colliding. + const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-"); return `${slug}-${Math.random().toString(36).slice(2, 8)}`; } From f91973c78cfa1059d22a218ae97655431c67c4c2 Mon Sep 17 00:00:00 2001 From: Aditya-thesys Date: Wed, 22 Jul 2026 19:44:53 +0530 Subject: [PATCH 7/9] cli: format writeEnv signature (fixes CI lint) --- packages/openui-cli/src/commands/create-app.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/openui-cli/src/commands/create-app.ts b/packages/openui-cli/src/commands/create-app.ts index 632f0b886..ee28fa676 100644 --- a/packages/openui-cli/src/commands/create-app.ts +++ b/packages/openui-cli/src/commands/create-app.ts @@ -261,11 +261,7 @@ export async function runCreateApp(options: CreateAppOptions): Promise { ); } -async function writeEnv( - targetDir: string, - result: EnvResult, - appId?: string, -): Promise { +async function writeEnv(targetDir: string, result: EnvResult, appId?: string): Promise { // APP_ID is the scaffold's stable identity: the frontend-token route sends // it as `app_id`, so every conversation this app creates is bound to it and // apps sharing one org API key stay isolated from each other. It must stay From 2f9853e27b001bf8675f282bef459c681724446b Mon Sep 17 00:00:00 2001 From: Aditya-thesys Date: Wed, 22 Jul 2026 19:44:53 +0530 Subject: [PATCH 8/9] cloud template: consolidate the missing-key check into apiKeyOrError Both API routes carried the same inline 13-line missing-key response and requiredEnv had gone unused. apiKeyOrError() in lib/env.ts now owns the check: routes do a two-line guard, and the helper's doc comment records why this is a structured response instead of a throw (keyless scaffolds are a first-run state; a thrown Error surfaces as an opaque 500). --- .../openui-cloud/src/app/api/chat/route.ts | 17 ++--------- .../src/app/api/frontend-token/route.ts | 17 ++--------- .../src/templates/openui-cloud/src/lib/env.ts | 29 +++++++++++++++---- 3 files changed, 29 insertions(+), 34 deletions(-) diff --git a/packages/openui-cli/src/templates/openui-cloud/src/app/api/chat/route.ts b/packages/openui-cli/src/templates/openui-cloud/src/app/api/chat/route.ts index 16a5bdebc..f1e872826 100644 --- a/packages/openui-cli/src/templates/openui-cloud/src/app/api/chat/route.ts +++ b/packages/openui-cli/src/templates/openui-cloud/src/app/api/chat/route.ts @@ -1,5 +1,5 @@ import { getBillingCreditsErrorMessage } from "@/lib/billing"; -import { envOr } from "@/lib/env"; +import { apiKeyOrError, envOr } from "@/lib/env"; import { DEFAULT_MODEL, resolveRequestedModel } from "@/lib/models"; import { runFunctionToolLoop } from "@/lib/tool-loop"; import { executeGetWeather, getWeatherTool } from "@/lib/tools/get-weather"; @@ -45,19 +45,8 @@ export async function POST(req: Request) { ); } - const apiKey = process.env.THESYS_API_KEY; - if (!apiKey) { - return Response.json( - { - error: { - code: "missing_api_key", - message: - "THESYS_API_KEY is not set. Add it to .env (create a key in the Thesys console → API keys) and restart the dev server.", - }, - }, - { status: 500 }, - ); - } + const apiKey = apiKeyOrError(); + if (apiKey instanceof Response) return apiKey; const client = new OpenAI({ baseURL: "https://api.thesys.dev/v1/embed", diff --git a/packages/openui-cli/src/templates/openui-cloud/src/app/api/frontend-token/route.ts b/packages/openui-cli/src/templates/openui-cloud/src/app/api/frontend-token/route.ts index c2e3e0d7d..3d91d28c1 100644 --- a/packages/openui-cli/src/templates/openui-cloud/src/app/api/frontend-token/route.ts +++ b/packages/openui-cli/src/templates/openui-cloud/src/app/api/frontend-token/route.ts @@ -1,4 +1,4 @@ -import { envOr } from "@/lib/env"; +import { apiKeyOrError, envOr } from "@/lib/env"; /** * Mints the short-lived fct_ token the browser uses for the storage plane @@ -15,19 +15,8 @@ import { envOr } from "@/lib/env"; * request body) — see the OpenUI skill's cloud-integration reference. */ export async function POST() { - const apiKey = process.env.THESYS_API_KEY; - if (!apiKey) { - return Response.json( - { - error: { - code: "missing_api_key", - message: - "THESYS_API_KEY is not set. Add it to .env (create a key in the Thesys console → API keys) and restart the dev server.", - }, - }, - { status: 500 }, - ); - } + const apiKey = apiKeyOrError(); + if (apiKey instanceof Response) return apiKey; const appId = process.env.APP_ID; const upstream = await fetch(`https://api.thesys.dev/v1/frontend-tokens`, { diff --git a/packages/openui-cli/src/templates/openui-cloud/src/lib/env.ts b/packages/openui-cli/src/templates/openui-cloud/src/lib/env.ts index 14b7dcf50..2a18c8196 100644 --- a/packages/openui-cli/src/templates/openui-cloud/src/lib/env.ts +++ b/packages/openui-cli/src/templates/openui-cloud/src/lib/env.ts @@ -1,9 +1,26 @@ -export function requiredEnv(name: string): string { - const value = process.env[name]; - if (!value) throw new Error(`Missing required env var: ${name}`); - return value; -} - export function envOr(name: string, fallback: string): string { return process.env[name] || fallback; } + +/** + * THESYS_API_KEY, or the error Response the route should return instead. + * + * The scaffold can legitimately start without a key (`--auth skip`), so a + * missing key is a first-run state, not a programmer error: return a + * structured `{ error: { code, message } }` the chat UI renders as actionable + * guidance, where a thrown Error would surface as an opaque 500. + */ +export function apiKeyOrError(): string | Response { + const key = process.env.THESYS_API_KEY; + if (key) return key; + return Response.json( + { + error: { + code: "missing_api_key", + message: + "THESYS_API_KEY is not set. Add it to .env (create a key in the Thesys console → API keys) and restart the dev server.", + }, + }, + { status: 500 }, + ); +} From 6e4f7f30f5b7693ac37bad8d1c2234eadce69934 Mon Sep 17 00:00:00 2001 From: Aditya-thesys Date: Wed, 22 Jul 2026 20:03:21 +0530 Subject: [PATCH 9/9] cloud template: revert missing-key handling to requiredEnv MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A keyless browser test showed the structured missing_api_key response never reaches the chat UI: the first failing call is the SDK's frontend-token mint, and its helper throws a status-only error without reading the response body. Until the SDK surfaces error bodies, the requiredEnv throw is the leaner equivalent, so env.ts and both routes go back to it. The chat route keeps its response-driven handling (429 billing message, 401/403 key guidance) — those do render, via getChatErrorMessage. --- .../openui-cloud/src/app/api/chat/route.ts | 7 ++--- .../src/app/api/frontend-token/route.ts | 19 ++---------- .../src/templates/openui-cloud/src/lib/env.ts | 29 ++++--------------- 3 files changed, 10 insertions(+), 45 deletions(-) diff --git a/packages/openui-cli/src/templates/openui-cloud/src/app/api/chat/route.ts b/packages/openui-cli/src/templates/openui-cloud/src/app/api/chat/route.ts index f1e872826..e78e613e0 100644 --- a/packages/openui-cli/src/templates/openui-cloud/src/app/api/chat/route.ts +++ b/packages/openui-cli/src/templates/openui-cloud/src/app/api/chat/route.ts @@ -1,5 +1,5 @@ import { getBillingCreditsErrorMessage } from "@/lib/billing"; -import { apiKeyOrError, envOr } from "@/lib/env"; +import { envOr, requiredEnv } from "@/lib/env"; import { DEFAULT_MODEL, resolveRequestedModel } from "@/lib/models"; import { runFunctionToolLoop } from "@/lib/tool-loop"; import { executeGetWeather, getWeatherTool } from "@/lib/tools/get-weather"; @@ -45,12 +45,9 @@ export async function POST(req: Request) { ); } - const apiKey = apiKeyOrError(); - if (apiKey instanceof Response) return apiKey; - const client = new OpenAI({ baseURL: "https://api.thesys.dev/v1/embed", - apiKey, // sent as Authorization: Bearer … + apiKey: requiredEnv("THESYS_API_KEY"), // sent as Authorization: Bearer … }); // App-owned function tools, executed in THIS route by runFunctionToolLoop. diff --git a/packages/openui-cli/src/templates/openui-cloud/src/app/api/frontend-token/route.ts b/packages/openui-cli/src/templates/openui-cloud/src/app/api/frontend-token/route.ts index 3d91d28c1..35b0fd8f8 100644 --- a/packages/openui-cli/src/templates/openui-cloud/src/app/api/frontend-token/route.ts +++ b/packages/openui-cli/src/templates/openui-cloud/src/app/api/frontend-token/route.ts @@ -1,4 +1,4 @@ -import { apiKeyOrError, envOr } from "@/lib/env"; +import { envOr, requiredEnv } from "@/lib/env"; /** * Mints the short-lived fct_ token the browser uses for the storage plane @@ -15,15 +15,12 @@ import { apiKeyOrError, envOr } from "@/lib/env"; * request body) — see the OpenUI skill's cloud-integration reference. */ export async function POST() { - const apiKey = apiKeyOrError(); - if (apiKey instanceof Response) return apiKey; - const appId = process.env.APP_ID; const upstream = await fetch(`https://api.thesys.dev/v1/frontend-tokens`, { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, + Authorization: `Bearer ${requiredEnv("THESYS_API_KEY")}`, }, body: JSON.stringify({ user_id: envOr("DEMO_USER_ID", "demo-user"), @@ -32,18 +29,6 @@ export async function POST() { }); if (!upstream.ok) { - if (upstream.status === 401 || upstream.status === 403) { - return Response.json( - { - error: { - code: "invalid_api_key", - message: - "OpenUI Cloud rejected THESYS_API_KEY. Check the key in .env against the Thesys console → API keys.", - }, - }, - { status: upstream.status }, - ); - } const errText = await upstream .text() .catch(() => "There was an error in the response from the upstream service."); diff --git a/packages/openui-cli/src/templates/openui-cloud/src/lib/env.ts b/packages/openui-cli/src/templates/openui-cloud/src/lib/env.ts index 2a18c8196..14b7dcf50 100644 --- a/packages/openui-cli/src/templates/openui-cloud/src/lib/env.ts +++ b/packages/openui-cli/src/templates/openui-cloud/src/lib/env.ts @@ -1,26 +1,9 @@ -export function envOr(name: string, fallback: string): string { - return process.env[name] || fallback; +export function requiredEnv(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`Missing required env var: ${name}`); + return value; } -/** - * THESYS_API_KEY, or the error Response the route should return instead. - * - * The scaffold can legitimately start without a key (`--auth skip`), so a - * missing key is a first-run state, not a programmer error: return a - * structured `{ error: { code, message } }` the chat UI renders as actionable - * guidance, where a thrown Error would surface as an opaque 500. - */ -export function apiKeyOrError(): string | Response { - const key = process.env.THESYS_API_KEY; - if (key) return key; - return Response.json( - { - error: { - code: "missing_api_key", - message: - "THESYS_API_KEY is not set. Add it to .env (create a key in the Thesys console → API keys) and restart the dev server.", - }, - }, - { status: 500 }, - ); +export function envOr(name: string, fallback: string): string { + return process.env[name] || fallback; }