From 3850ced1a8429970d914b3dc7abe915ac6bff417 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Wed, 7 Oct 2026 13:08:12 +0530 Subject: [PATCH 1/3] feat(server): add provider-independent client tool executor --- .changeset/server-tool-executor.md | 5 + docs/content/docs/api-reference/index.mdx | 2 +- docs/content/docs/api-reference/server.mdx | 24 +++ docs/content/docs/autofix.mdx | 2 +- packages/server/README.md | 24 +++ packages/server/src/index.ts | 4 + packages/server/src/tools/execute.ts | 186 +++++++++++++++++++++ packages/server/src/tools/types.ts | 31 ++++ 8 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 .changeset/server-tool-executor.md create mode 100644 packages/server/src/tools/execute.ts create mode 100644 packages/server/src/tools/types.ts diff --git a/.changeset/server-tool-executor.md b/.changeset/server-tool-executor.md new file mode 100644 index 0000000000..8cfa13a0f3 --- /dev/null +++ b/.changeset/server-tool-executor.md @@ -0,0 +1,5 @@ +--- +"@openuidev/server": minor +--- + +Add provider-independent client.tools.execute for registered tools, complete OpenUI response bundles, and artifact scripts. Drive bounded sandbox continuations with request-scoped context, cancellation, a total deadline, and no tool replay after snapshot failures. Keep equivalent support in the existing renderer PR unchanged. diff --git a/docs/content/docs/api-reference/index.mdx b/docs/content/docs/api-reference/index.mdx index 2fb670c1c4..8a39d6f5d5 100644 --- a/docs/content/docs/api-reference/index.mdx +++ b/docs/content/docs/api-reference/index.mdx @@ -17,7 +17,7 @@ The OpenUI SDK is split into packages that build on each other: - **`@openuidev/langchain`** — LangChain and LangGraph integration primitives for transforming protocol-v2 agent streams into AG-UI events and relaying them to an OpenUI frontend over Web-standard Server-Sent Events. -- **`@openuidev/server`** — Server client for OpenUI Gateway. Fix invalid OpenUI Lang and persist Chat Completions turns. Import `createClient` from the package root. +- **`@openuidev/server`** — Server client for OpenUI Gateway. Fix invalid OpenUI Lang, persist Chat Completions turns, and execute customer tools or sandbox scripts. Import `createClient` from the package root. - **`@openuidev/react-email`** — Pre-built email component library and prompt options for model-generated emails that can be rendered to HTML with React Email. diff --git a/docs/content/docs/api-reference/server.mdx b/docs/content/docs/api-reference/server.mdx index 84e9bd88ac..3f9c877d31 100644 --- a/docs/content/docs/api-reference/server.mdx +++ b/docs/content/docs/api-reference/server.mdx @@ -116,3 +116,27 @@ await openUIClient.openai.completions.conversations.appendMessages({ ``` Accepts Chat Completions messages only and returns a `ConversationItemList`. It converts user/assistant messages and function calls/results into Conversations API items; system/developer instructions are skipped. An empty converted turn makes no request. The conversation must already exist. Append only the new turn to avoid duplicate items; writes are not retried automatically. + +## Execute tools and scripts + +```ts +const result = await client.tools.execute({ + name: "summary", + arguments: { period: "last_month" }, + tools: { + getRevenue: async (args, { context, signal }) => loadRevenue(args, context.userId, signal), + }, + response: generatedOpenUIBundle, + context: { userId: authenticatedUser.id }, + signal: request.signal, + timeoutMs: 60_000, +}); +``` + +The method returns the final value. A registered `name` dispatches a local tool directly; otherwise `response` supplies the complete OpenUI bundle containing the named script. For stored scripts, replace `response` with `artifact: { id, version? }`. Scripts execute in the Gateway sandbox, not on the host server. Response bundles use `/v1/app/execute`; stored artifacts use `/v1/dashboards/execute`. + +Tools receive trusted per-request `context` and an abort signal. Derive context from authenticated server state; tools validate their own arguments and enforce authorization. Only own properties of the registry are callable. + +The continuation loop validates call IDs before execution, preserves customer-tool failures as sandbox results, and limits runs to eight tool-call rounds and sixteen customer-tool calls. The total deadline defaults to 60 seconds, including tools. Cancellation propagates to requests and executors; work that ignores it is no longer awaited. Snapshot failures do not replay previously executed tools. + +The root exports `ExecuteToolInput`, `ToolArtifactRef`, and `ToolExecutor`. diff --git a/docs/content/docs/autofix.mdx b/docs/content/docs/autofix.mdx index 6ffbde4d5a..05037a04dd 100644 --- a/docs/content/docs/autofix.mdx +++ b/docs/content/docs/autofix.mdx @@ -96,7 +96,7 @@ Use either `chunks` or `toResponse()` once. `result` settles after consumption. | `toResponse()` | SSE `Response` for the matching frontend. | | `result` | Settled Autofix result. Persist `content` when present, rather than joined text. | -`result` is `null` when Autofix validation did not run. A failed streamed fix throws with `code: "fix_failed"`. See the [server client reference](/docs/api-reference/server) for configuration and conversation persistence. +`result` is `null` when Autofix validation did not run. A failed streamed fix throws with `code: "fix_failed"`. See the [server client reference](/docs/api-reference/server) for configuration, persistence, and tool execution. ## Call the API directly diff --git a/packages/server/README.md b/packages/server/README.md index 066fa5d08a..3f828161fb 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -113,3 +113,27 @@ await openUIClient.openai.completions.conversations.appendMessages({ ``` Accepts Chat Completions messages only and returns a `ConversationItemList`. It converts user/assistant messages and function calls/results into Conversations API items; system/developer instructions are skipped. An empty converted turn makes no request. The conversation must already exist. Append only the new turn to avoid duplicate items; writes are not retried automatically. + +## Execute tools and scripts + +```ts +const result = await client.tools.execute({ + name: "summary", + arguments: { period: "last_month" }, + tools: { + getRevenue: async (args, { context, signal }) => loadRevenue(args, context.userId, signal), + }, + response: generatedOpenUIBundle, + context: { userId: authenticatedUser.id }, + signal: request.signal, + timeoutMs: 60_000, +}); +``` + +The method returns the final value. A registered `name` dispatches a local tool directly; otherwise `response` supplies the complete OpenUI bundle containing the named script. For stored scripts, replace `response` with `artifact: { id, version? }`. Scripts execute in the Gateway sandbox, not on the host server. Response bundles use `/v1/app/execute`; stored artifacts use `/v1/dashboards/execute`. + +Tools receive trusted per-request `context` and an abort signal. Derive context from authenticated server state; tools validate their own arguments and enforce authorization. Only own properties of the registry are callable. + +The continuation loop validates call IDs before execution, preserves customer-tool failures as sandbox results, and limits runs to eight tool-call rounds and sixteen customer-tool calls. The total deadline defaults to 60 seconds, including tools. Cancellation propagates to requests and executors; work that ignores it is no longer awaited. Snapshot failures do not replay previously executed tools. + +The root exports `ExecuteToolInput`, `ToolArtifactRef`, and `ToolExecutor`. diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 5234761407..eb518789ef 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -4,6 +4,7 @@ import { createCompletionsConversations } from "./openai/client"; import { openAIResponsesAdapter } from "./openai/responses-adapter"; import { resolveClientOptions, type ClientOptions } from "./shared/client"; import { createClientAutofix, createClientFix } from "./shared/client-autofix"; +import { createTools } from "./tools/execute"; import { vercelAIAdapter } from "./vercel/adapter"; import { eveStreamAdapter } from "./vercel/eve-adapter"; @@ -18,6 +19,7 @@ export type { AutofixResult, AutofixStream } from "./shared/types"; export function createClient(options: ClientOptions = {}) { const config = resolveClientOptions(options); return { + tools: createTools(config), autofix: createClientFix(config), openai: { completions: { @@ -42,3 +44,5 @@ export type Client = ReturnType; export type { MessageStreamEvent as EveStreamEvent } from "eve/client"; export type { LangGraphStreamEvent } from "./langchain/types"; + +export type { ExecuteToolInput, ToolArtifactRef, ToolExecutor } from "./tools/types"; diff --git a/packages/server/src/tools/execute.ts b/packages/server/src/tools/execute.ts new file mode 100644 index 0000000000..0f523ad0ac --- /dev/null +++ b/packages/server/src/tools/execute.ts @@ -0,0 +1,186 @@ +import { abortable } from "../shared/abort"; +import { postJSON, ServerClientError, type ClientConfig } from "../shared/client"; +import type { ExecuteToolInput, ScriptToolCall, ScriptToolResult } from "./types"; + +const MAX_ROUNDS = 8; +const MAX_TOOL_CALLS = 16; +const DEFAULT_TIMEOUT_MS = 60_000; + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value); + +function invalidResponse(): never { + throw new ServerClientError("Invalid script execution response", "invalid_response", 502); +} + +/** Dispatch registered tools locally or drive a stored script's sandbox continuation. */ +export function createTools(config: ClientConfig) { + return { + async execute(input: ExecuteToolInput): Promise { + if (!input || typeof input.name !== "string" || !input.name || !isRecord(input.tools)) { + throw new ServerClientError("A tool name and tools registry are required", "invalid_input"); + } + const args = input.arguments ?? {}; + if (!isRecord(args)) + throw new ServerClientError("Tool arguments must be an object", "invalid_input"); + const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new ServerClientError("timeoutMs must be a positive finite number", "invalid_input"); + } + input.signal?.throwIfAborted(); + const controller = new AbortController(); + const forwardAbort = () => controller.abort(input.signal?.reason); + input.signal?.addEventListener("abort", forwardAbort, { once: true }); + const timer = setTimeout( + () => + controller.abort(new ServerClientError("Tool execution timed out", "tool_timeout", 504)), + timeoutMs, + ); + const signal = controller.signal; + + const dispatch = async (name: string, toolArgs: Record) => { + signal.throwIfAborted(); + if (!Object.hasOwn(input.tools, name) || typeof input.tools[name] !== "function") { + throw new ServerClientError(`Unknown customer tool: ${name}`, "tool_not_found", 404); + } + return abortable( + Promise.resolve().then(() => + input.tools[name]!(toolArgs, { context: input.context as Context, signal }), + ), + signal, + ); + }; + + try { + if (Object.hasOwn(input.tools, input.name)) return await dispatch(input.name, args); + if (input.artifact && input.response !== undefined) { + throw new ServerClientError( + "Supply either an artifact or a response bundle", + "invalid_input", + ); + } + const hasArtifact = input.artifact !== undefined; + if (hasArtifact) { + if (!input.artifact || typeof input.artifact.id !== "string" || !input.artifact.id) { + throw new ServerClientError("Artifact ID is required", "invalid_input"); + } + if (input.artifact.version !== undefined && typeof input.artifact.version !== "string") { + throw new ServerClientError("Artifact version must be a string", "invalid_input"); + } + } else if (typeof input.response !== "string" || !input.response.trim()) { + throw new ServerClientError( + `Unknown tool: ${input.name}. Supply an artifact or complete OpenUI response for script execution.`, + "tool_not_found", + 404, + ); + } + + let state: string | undefined; + let toolResults: ScriptToolResult[] = []; + const seen = new Set(); + // Allow a final settlement request after the last tool-call round. + for (let round = 0; round <= MAX_ROUNDS; round++) { + const response = await abortable( + postJSON( + config, + hasArtifact ? "/v1/dashboards/execute" : "/v1/app/execute", + { + ...(hasArtifact + ? { + artifact_id: input.artifact!.id, + ...(input.artifact!.version !== undefined && { + artifact_version: input.artifact!.version, + }), + } + : { response: input.response }), + name: input.name, + arguments: args, + tool_results: toolResults, + ...(state !== undefined && { state }), + }, + signal, + ), + signal, + ); + if (!isRecord(response)) invalidResponse(); + if (response["status"] === "done") return response["result"]; + if (response["status"] === "error") { + const error = response["error"]; + if ( + !isRecord(error) || + typeof error["code"] !== "string" || + typeof error["message"] !== "string" + ) + invalidResponse(); + // Never replay tools after a failed snapshot: a prior call may have mutated data. + throw new ServerClientError(error["message"], error["code"]); + } + const calls = response["calls"]; + if ( + response["status"] !== "tool_calls" || + typeof response["state"] !== "string" || + !response["state"] || + !Array.isArray(calls) || + !calls.length + ) + invalidResponse(); + if (round === MAX_ROUNDS || seen.size + calls.length > MAX_TOOL_CALLS) { + throw new ServerClientError("Script execution limit exceeded", "script_limit", 502); + } + // Validate the complete batch before any customer tool starts. + for (const call of calls) { + if ( + !isRecord(call) || + (call["type"] !== undefined && call["type"] !== "function_call") || + typeof call["call_id"] !== "string" || + !call["call_id"] || + seen.has(call["call_id"]) || + typeof call["name"] !== "string" || + !call["name"] || + typeof call["arguments"] !== "string" + ) + invalidResponse(); + seen.add(call["call_id"]); + } + state = response["state"]; + toolResults = await Promise.all( + (calls as ScriptToolCall[]).map(async (call): Promise => { + try { + const toolArgs: unknown = JSON.parse(call.arguments); + if (!isRecord(toolArgs)) + throw new ServerClientError("Tool arguments must be an object", "bad_args"); + const result = await dispatch(call.name, toolArgs); + signal.throwIfAborted(); + const output = JSON.stringify(result ?? null); + if (output === undefined) + throw new ServerClientError( + "Tool result must be JSON serializable", + "bad_tool_result", + ); + return { + type: "function_call_output", + call_id: call.call_id, + output, + }; + } catch (error) { + signal.throwIfAborted(); + return { + call_id: call.call_id, + error: { + code: error instanceof ServerClientError ? error.code : "tool_exec_error", + message: error instanceof Error ? error.message : String(error), + }, + }; + } + }), + ); + } + throw new ServerClientError("Script execution limit exceeded", "script_limit", 502); + } finally { + clearTimeout(timer); + input.signal?.removeEventListener("abort", forwardAbort); + controller.abort(); + } + }, + }; +} diff --git a/packages/server/src/tools/types.ts b/packages/server/src/tools/types.ts new file mode 100644 index 0000000000..7e6367e0fd --- /dev/null +++ b/packages/server/src/tools/types.ts @@ -0,0 +1,31 @@ +export interface ToolArtifactRef { + id: string; + version?: string; +} + +/** Registered customer tools execute on the host server with trusted context. */ +export type ToolExecutor = ( + args: Record, + options: { context: Context; signal: AbortSignal }, +) => unknown | Promise; + +export type ExecuteToolInput = { + name: string; + arguments?: Record; + tools: Record>; + signal?: AbortSignal; + /** Total execution budget, including customer tools. Default: 60 seconds. */ + timeoutMs?: number; +} & ({ artifact: ToolArtifactRef; response?: never } | { artifact?: never; response?: string }) & + (undefined extends Context ? { context?: Context } : { context: Context }); + +export interface ScriptToolCall { + type?: "function_call"; + call_id: string; + name: string; + arguments: string; +} + +export type ScriptToolResult = + | { type: "function_call_output"; call_id: string; output: string } + | { call_id: string; error: { code: string; message: string } }; From 9de80b7cad5ac8b7b77aacdcfbb58681fb57f255 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Wed, 7 Oct 2026 13:35:12 +0530 Subject: [PATCH 2/3] docs(server): use openUIClient in tool example --- .changeset/server-tool-executor.md | 2 +- docs/content/docs/api-reference/server.mdx | 2 +- packages/server/README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/server-tool-executor.md b/.changeset/server-tool-executor.md index 8cfa13a0f3..15032c2b11 100644 --- a/.changeset/server-tool-executor.md +++ b/.changeset/server-tool-executor.md @@ -2,4 +2,4 @@ "@openuidev/server": minor --- -Add provider-independent client.tools.execute for registered tools, complete OpenUI response bundles, and artifact scripts. Drive bounded sandbox continuations with request-scoped context, cancellation, a total deadline, and no tool replay after snapshot failures. Keep equivalent support in the existing renderer PR unchanged. +Add provider-independent openUIClient.tools.execute for registered tools, complete OpenUI response bundles, and artifact scripts. Drive bounded sandbox continuations with request-scoped context, cancellation, a total deadline, and no tool replay after snapshot failures. Keep equivalent support in the existing renderer PR unchanged. diff --git a/docs/content/docs/api-reference/server.mdx b/docs/content/docs/api-reference/server.mdx index 3f9c877d31..4e40251ae5 100644 --- a/docs/content/docs/api-reference/server.mdx +++ b/docs/content/docs/api-reference/server.mdx @@ -120,7 +120,7 @@ Accepts Chat Completions messages only and returns a `ConversationItemList`. It ## Execute tools and scripts ```ts -const result = await client.tools.execute({ +const result = await openUIClient.tools.execute({ name: "summary", arguments: { period: "last_month" }, tools: { diff --git a/packages/server/README.md b/packages/server/README.md index 3f828161fb..05affb27b7 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -117,7 +117,7 @@ Accepts Chat Completions messages only and returns a `ConversationItemList`. It ## Execute tools and scripts ```ts -const result = await client.tools.execute({ +const result = await openUIClient.tools.execute({ name: "summary", arguments: { period: "last_month" }, tools: { From 2051e533fa553133df8b84e7224e07baec187647 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Wed, 7 Oct 2026 14:19:14 +0530 Subject: [PATCH 3/3] feat(server): register tool definitions and execution together --- .changeset/server-tool-executor.md | 2 +- docs/content/docs/api-reference/server.mdx | 111 ++++++- packages/server/README.md | 109 ++++++- packages/server/src/index.ts | 10 +- packages/server/src/tools/execute.ts | 326 +++++++++++---------- packages/server/src/tools/types.ts | 40 ++- 6 files changed, 411 insertions(+), 187 deletions(-) diff --git a/.changeset/server-tool-executor.md b/.changeset/server-tool-executor.md index 15032c2b11..6bf07df327 100644 --- a/.changeset/server-tool-executor.md +++ b/.changeset/server-tool-executor.md @@ -2,4 +2,4 @@ "@openuidev/server": minor --- -Add provider-independent openUIClient.tools.execute for registered tools, complete OpenUI response bundles, and artifact scripts. Drive bounded sandbox continuations with request-scoped context, cancellation, a total deadline, and no tool replay after snapshot failures. Keep equivalent support in the existing renderer PR unchanged. +Add provider-independent tool registration through `openUIClient.tools.create`, exposing generation definitions and an executor from one registry. Execute direct server tools or scripts from complete OpenUI responses using request-scoped context, cancellation, a total deadline, and bounded sandbox continuations without replaying tools after snapshot failures. Keep `openUIClient.tools.execute` for existing handler maps. diff --git a/docs/content/docs/api-reference/server.mdx b/docs/content/docs/api-reference/server.mdx index 4e40251ae5..a087d31efe 100644 --- a/docs/content/docs/api-reference/server.mdx +++ b/docs/content/docs/api-reference/server.mdx @@ -1,9 +1,9 @@ --- title: "@openuidev/server" -description: A shared server client for auto-fix and conversation history persistence on the OpenUI gateway. +description: A shared server client for auto-fix, tool and script execution, and conversation history persistence on the OpenUI gateway. --- -Use `createClient` to fix invalid OpenUI Lang and save completed Chat Completions turns to conversation history on the OpenUI Gateway. +Use `createClient` to fix invalid OpenUI Lang, execute tools and generated scripts, and save completed Chat Completions turns to conversation history on the OpenUI Gateway. ## Install @@ -119,24 +119,111 @@ Accepts Chat Completions messages only and returns a `ConversationItemList`. It ## Execute tools and scripts +Register server tools once. The same registry provides definitions for generation and handlers for execution: + +```ts +type AppContext = { userId: string }; + +const appTools = openUIClient.tools.create({ + get_orders: { + description: "Read orders for a selected year.", + parameters: { + type: "object", + properties: { year: { type: "integer" } }, + required: ["year"], + }, + output: { orders: [{ region: "West", revenue: 120 }] }, + execute: (args, { context, signal }) => readOrders(args, { userId: context.userId, signal }), + }, +}); +``` + +`readOrders` is your application's data function. Each registration requires JSON Schema `parameters` and an `execute` handler. `description` and illustrative `output` are optional. Handlers receive arguments as `Record`; they validate inputs and authorize access to actual data. The example output describes a possible result for generation, not the data returned at runtime. + +### Use the definitions for generation + +```ts +import { generateSystemPrompt } from "@openuidev/lang-core"; +import library from "./openui.spec.json"; + +const systemPrompt = generateSystemPrompt({ + cloud: true, + library, + script: { tools: appTools.definitions }, +}); +``` + +`definitions` contains only `{ name, description?, parameters, output? }`. Handlers, request context and client credentials are excluded. Metadata must be JSON serializable. You can create separate registries for different MiniApp libraries using the same client. + +### Execute a direct tool or a generated script + +```ts +// A registered name runs directly on your server; no response bundle is needed. +const orders = await appTools.execute( + { name: "get_orders", arguments: { year: 2026 } }, + { context: { userId: authenticatedUser.id }, signal: request.signal }, +); + +// A generated script can call those same registered tools. +const summary = await appTools.execute( + { name: "sales_summary", arguments: { year: 2026 }, response: generatedOpenUIBundle }, + { context: { userId: authenticatedUser.id }, signal: request.signal, timeoutMs: 60_000 }, +); +``` + +`execute` returns the final value. Registered names take precedence over scripts with the same name. Other names require the complete generated `response`, including its scripts; no parsing or splitting is needed. Scripts execute through `/v1/app/execute` in the Gateway sandbox. The helper runs any requested registered tools on your server and resumes the script until it finishes. Unknown tools or scripts produce errors. + +Context, cancellation and the deadline belong to each call. A typed context is required when your registry declares one. For tools that need no context, omit the generic and call `appTools.execute({ name, arguments, response })` without a second argument. Client creation still requires a Gateway API key. + +### Connect an execution route + +Keep your registry in a server module. Derive context from authenticated server state and select only execution input from the body: + +```ts +import { ServerClientError } from "@openuidev/server"; +import { requireAppContext } from "./auth"; +import { appTools } from "./app-tools"; + +export async function POST(request: Request) { + const context = await requireAppContext(request); + const { name, arguments: args, response } = await request.json(); + + try { + const result = await appTools.execute( + { name, arguments: args, response }, + { context, signal: request.signal }, + ); + return Response.json({ result }); + } catch (error) { + if (request.signal.aborted) throw error; + const code = error instanceof ServerClientError ? error.code : "tool_exec_error"; + const status = error instanceof ServerClientError ? (error.status ?? 500) : 500; + return Response.json({ error: { code, message: "Tool execution failed." } }, { status }); + } +} +``` + +Export `appTools` from the registration module. `requireAppContext` is your authentication helper; retain your application's normal authentication and error handling. The request body cannot supply the registry, trusted context or execution settings. If you store MiniApps by ID, authorize and load the selected response on your server, then pass that full response to `execute`. + +Your Renderer's `toolProvider.callTool` can handle browser-only actions explicitly and forward other names to this route. Browser-only handlers are not available to generated scripts; register script-accessible tools on the server. + +### Use an existing handler map + +If you already manage tool metadata elsewhere, keep the one-shot form: + ```ts const result = await openUIClient.tools.execute({ - name: "summary", - arguments: { period: "last_month" }, + name: "sales_summary", + arguments: { year: 2026 }, tools: { - getRevenue: async (args, { context, signal }) => loadRevenue(args, context.userId, signal), + get_orders: (args, { context, signal }) => readOrders(args, { userId: context.userId, signal }), }, response: generatedOpenUIBundle, context: { userId: authenticatedUser.id }, signal: request.signal, - timeoutMs: 60_000, }); ``` -The method returns the final value. A registered `name` dispatches a local tool directly; otherwise `response` supplies the complete OpenUI bundle containing the named script. For stored scripts, replace `response` with `artifact: { id, version? }`. Scripts execute in the Gateway sandbox, not on the host server. Response bundles use `/v1/app/execute`; stored artifacts use `/v1/dashboards/execute`. - -Tools receive trusted per-request `context` and an abort signal. Derive context from authenticated server state; tools validate their own arguments and enforce authorization. Only own properties of the registry are callable. - -The continuation loop validates call IDs before execution, preserves customer-tool failures as sandbox results, and limits runs to eight tool-call rounds and sixteen customer-tool calls. The total deadline defaults to 60 seconds, including tools. Cancellation propagates to requests and executors; work that ignores it is no longer awaited. Snapshot failures do not replay previously executed tools. +Both forms use the same execution loop. Tools requested together run concurrently; dependent operations belong in successive script calls. Runs are limited to eight tool-call rounds and sixteen customer-tool calls. The total deadline defaults to 60 seconds, including tools. Cancellation propagates to requests and handlers; work that ignores it is no longer awaited. Tool failures are returned to the script, and snapshot failures never automatically replay previously executed tools. -The root exports `ExecuteToolInput`, `ToolArtifactRef`, and `ToolExecutor`. +The root exports `ToolDefinition`, `ToolRegistration`, `RegisteredTools`, `ToolExecutionRequest`, `ToolExecutionOptions`, `ExecuteToolInput`, and the handler type `ToolExecutor`. diff --git a/packages/server/README.md b/packages/server/README.md index 05affb27b7..b9b171289c 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -1,6 +1,6 @@ # `@openuidev/server` -Use `createClient` to fix invalid OpenUI Lang and save completed Chat Completions turns to conversation history on the OpenUI Gateway. +Use `createClient` to fix invalid OpenUI Lang, execute tools and generated scripts, and save completed Chat Completions turns to conversation history on the OpenUI Gateway. ## Install @@ -116,24 +116,111 @@ Accepts Chat Completions messages only and returns a `ConversationItemList`. It ## Execute tools and scripts +Register server tools once. The same registry provides definitions for generation and handlers for execution: + +```ts +type AppContext = { userId: string }; + +const appTools = openUIClient.tools.create({ + get_orders: { + description: "Read orders for a selected year.", + parameters: { + type: "object", + properties: { year: { type: "integer" } }, + required: ["year"], + }, + output: { orders: [{ region: "West", revenue: 120 }] }, + execute: (args, { context, signal }) => readOrders(args, { userId: context.userId, signal }), + }, +}); +``` + +`readOrders` is your application's data function. Each registration requires JSON Schema `parameters` and an `execute` handler. `description` and illustrative `output` are optional. Handlers receive arguments as `Record`; they validate inputs and authorize access to actual data. The example output describes a possible result for generation, not the data returned at runtime. + +### Use the definitions for generation + +```ts +import { generateSystemPrompt } from "@openuidev/lang-core"; +import library from "./openui.spec.json"; + +const systemPrompt = generateSystemPrompt({ + cloud: true, + library, + script: { tools: appTools.definitions }, +}); +``` + +`definitions` contains only `{ name, description?, parameters, output? }`. Handlers, request context and client credentials are excluded. Metadata must be JSON serializable. You can create separate registries for different MiniApp libraries using the same client. + +### Execute a direct tool or a generated script + +```ts +// A registered name runs directly on your server; no response bundle is needed. +const orders = await appTools.execute( + { name: "get_orders", arguments: { year: 2026 } }, + { context: { userId: authenticatedUser.id }, signal: request.signal }, +); + +// A generated script can call those same registered tools. +const summary = await appTools.execute( + { name: "sales_summary", arguments: { year: 2026 }, response: generatedOpenUIBundle }, + { context: { userId: authenticatedUser.id }, signal: request.signal, timeoutMs: 60_000 }, +); +``` + +`execute` returns the final value. Registered names take precedence over scripts with the same name. Other names require the complete generated `response`, including its scripts; no parsing or splitting is needed. Scripts execute through `/v1/app/execute` in the Gateway sandbox. The helper runs any requested registered tools on your server and resumes the script until it finishes. Unknown tools or scripts produce errors. + +Context, cancellation and the deadline belong to each call. A typed context is required when your registry declares one. For tools that need no context, omit the generic and call `appTools.execute({ name, arguments, response })` without a second argument. Client creation still requires a Gateway API key. + +### Connect an execution route + +Keep your registry in a server module. Derive context from authenticated server state and select only execution input from the body: + +```ts +import { ServerClientError } from "@openuidev/server"; +import { requireAppContext } from "./auth"; +import { appTools } from "./app-tools"; + +export async function POST(request: Request) { + const context = await requireAppContext(request); + const { name, arguments: args, response } = await request.json(); + + try { + const result = await appTools.execute( + { name, arguments: args, response }, + { context, signal: request.signal }, + ); + return Response.json({ result }); + } catch (error) { + if (request.signal.aborted) throw error; + const code = error instanceof ServerClientError ? error.code : "tool_exec_error"; + const status = error instanceof ServerClientError ? (error.status ?? 500) : 500; + return Response.json({ error: { code, message: "Tool execution failed." } }, { status }); + } +} +``` + +Export `appTools` from the registration module. `requireAppContext` is your authentication helper; retain your application's normal authentication and error handling. The request body cannot supply the registry, trusted context or execution settings. If you store MiniApps by ID, authorize and load the selected response on your server, then pass that full response to `execute`. + +Your Renderer's `toolProvider.callTool` can handle browser-only actions explicitly and forward other names to this route. Browser-only handlers are not available to generated scripts; register script-accessible tools on the server. + +### Use an existing handler map + +If you already manage tool metadata elsewhere, keep the one-shot form: + ```ts const result = await openUIClient.tools.execute({ - name: "summary", - arguments: { period: "last_month" }, + name: "sales_summary", + arguments: { year: 2026 }, tools: { - getRevenue: async (args, { context, signal }) => loadRevenue(args, context.userId, signal), + get_orders: (args, { context, signal }) => readOrders(args, { userId: context.userId, signal }), }, response: generatedOpenUIBundle, context: { userId: authenticatedUser.id }, signal: request.signal, - timeoutMs: 60_000, }); ``` -The method returns the final value. A registered `name` dispatches a local tool directly; otherwise `response` supplies the complete OpenUI bundle containing the named script. For stored scripts, replace `response` with `artifact: { id, version? }`. Scripts execute in the Gateway sandbox, not on the host server. Response bundles use `/v1/app/execute`; stored artifacts use `/v1/dashboards/execute`. - -Tools receive trusted per-request `context` and an abort signal. Derive context from authenticated server state; tools validate their own arguments and enforce authorization. Only own properties of the registry are callable. - -The continuation loop validates call IDs before execution, preserves customer-tool failures as sandbox results, and limits runs to eight tool-call rounds and sixteen customer-tool calls. The total deadline defaults to 60 seconds, including tools. Cancellation propagates to requests and executors; work that ignores it is no longer awaited. Snapshot failures do not replay previously executed tools. +Both forms use the same execution loop. Tools requested together run concurrently; dependent operations belong in successive script calls. Runs are limited to eight tool-call rounds and sixteen customer-tool calls. The total deadline defaults to 60 seconds, including tools. Cancellation propagates to requests and handlers; work that ignores it is no longer awaited. Tool failures are returned to the script, and snapshot failures never automatically replay previously executed tools. -The root exports `ExecuteToolInput`, `ToolArtifactRef`, and `ToolExecutor`. +The root exports `ToolDefinition`, `ToolRegistration`, `RegisteredTools`, `ToolExecutionRequest`, `ToolExecutionOptions`, `ExecuteToolInput`, and the handler type `ToolExecutor`. diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index eb518789ef..c2dc2752e0 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -45,4 +45,12 @@ export type Client = ReturnType; export type { MessageStreamEvent as EveStreamEvent } from "eve/client"; export type { LangGraphStreamEvent } from "./langchain/types"; -export type { ExecuteToolInput, ToolArtifactRef, ToolExecutor } from "./tools/types"; +export type { + ExecuteToolInput, + RegisteredTools, + ToolDefinition, + ToolExecutionOptions, + ToolExecutionRequest, + ToolExecutor, + ToolRegistration, +} from "./tools/types"; diff --git a/packages/server/src/tools/execute.ts b/packages/server/src/tools/execute.ts index 0f523ad0ac..c73d6cc559 100644 --- a/packages/server/src/tools/execute.ts +++ b/packages/server/src/tools/execute.ts @@ -1,6 +1,13 @@ import { abortable } from "../shared/abort"; import { postJSON, ServerClientError, type ClientConfig } from "../shared/client"; -import type { ExecuteToolInput, ScriptToolCall, ScriptToolResult } from "./types"; +import type { + ExecuteToolInput, + RegisteredTools, + ScriptToolCall, + ScriptToolResult, + ToolExecutionOptions, + ToolRegistration, +} from "./types"; const MAX_ROUNDS = 8; const MAX_TOOL_CALLS = 16; @@ -13,174 +20,183 @@ function invalidResponse(): never { throw new ServerClientError("Invalid script execution response", "invalid_response", 502); } -/** Dispatch registered tools locally or drive a stored script's sandbox continuation. */ +/** Register tool metadata and handlers, or execute an existing handler map. */ export function createTools(config: ClientConfig) { - return { - async execute(input: ExecuteToolInput): Promise { - if (!input || typeof input.name !== "string" || !input.name || !isRecord(input.tools)) { - throw new ServerClientError("A tool name and tools registry are required", "invalid_input"); - } - const args = input.arguments ?? {}; - if (!isRecord(args)) - throw new ServerClientError("Tool arguments must be an object", "invalid_input"); - const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; - if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { - throw new ServerClientError("timeoutMs must be a positive finite number", "invalid_input"); + async function execute(input: ExecuteToolInput): Promise { + if (!input || typeof input.name !== "string" || !input.name || !isRecord(input.tools)) { + throw new ServerClientError("A tool name and tools registry are required", "invalid_input"); + } + const args = input.arguments ?? {}; + if (!isRecord(args)) + throw new ServerClientError("Tool arguments must be an object", "invalid_input"); + const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new ServerClientError("timeoutMs must be a positive finite number", "invalid_input"); + } + input.signal?.throwIfAborted(); + const controller = new AbortController(); + const forwardAbort = () => controller.abort(input.signal?.reason); + input.signal?.addEventListener("abort", forwardAbort, { once: true }); + const timer = setTimeout( + () => + controller.abort(new ServerClientError("Tool execution timed out", "tool_timeout", 504)), + timeoutMs, + ); + const signal = controller.signal; + + const dispatch = async (name: string, toolArgs: Record) => { + signal.throwIfAborted(); + if (!Object.hasOwn(input.tools, name) || typeof input.tools[name] !== "function") { + throw new ServerClientError(`Unknown customer tool: ${name}`, "tool_not_found", 404); } - input.signal?.throwIfAborted(); - const controller = new AbortController(); - const forwardAbort = () => controller.abort(input.signal?.reason); - input.signal?.addEventListener("abort", forwardAbort, { once: true }); - const timer = setTimeout( - () => - controller.abort(new ServerClientError("Tool execution timed out", "tool_timeout", 504)), - timeoutMs, + return abortable( + Promise.resolve().then(() => + input.tools[name]!(toolArgs, { context: input.context as Context, signal }), + ), + signal, ); - const signal = controller.signal; + }; - const dispatch = async (name: string, toolArgs: Record) => { - signal.throwIfAborted(); - if (!Object.hasOwn(input.tools, name) || typeof input.tools[name] !== "function") { - throw new ServerClientError(`Unknown customer tool: ${name}`, "tool_not_found", 404); - } - return abortable( - Promise.resolve().then(() => - input.tools[name]!(toolArgs, { context: input.context as Context, signal }), + try { + if (Object.hasOwn(input.tools, input.name)) return await dispatch(input.name, args); + if (typeof input.response !== "string" || !input.response.trim()) { + throw new ServerClientError( + `Unknown tool: ${input.name}. Supply a complete OpenUI response for script execution.`, + "tool_not_found", + 404, + ); + } + + let state: string | undefined; + let toolResults: ScriptToolResult[] = []; + const seen = new Set(); + // Allow a final settlement request after the last tool-call round. + for (let round = 0; round <= MAX_ROUNDS; round++) { + const response = await abortable( + postJSON( + config, + "/v1/app/execute", + { + response: input.response, + name: input.name, + arguments: args, + tool_results: toolResults, + ...(state !== undefined && { state }), + }, + signal, ), signal, ); - }; - - try { - if (Object.hasOwn(input.tools, input.name)) return await dispatch(input.name, args); - if (input.artifact && input.response !== undefined) { - throw new ServerClientError( - "Supply either an artifact or a response bundle", - "invalid_input", - ); + if (!isRecord(response)) invalidResponse(); + if (response["status"] === "done") return response["result"]; + if (response["status"] === "error") { + const error = response["error"]; + if ( + !isRecord(error) || + typeof error["code"] !== "string" || + typeof error["message"] !== "string" + ) + invalidResponse(); + // Never replay tools after a failed snapshot: a prior call may have mutated data. + throw new ServerClientError(error["message"], error["code"]); } - const hasArtifact = input.artifact !== undefined; - if (hasArtifact) { - if (!input.artifact || typeof input.artifact.id !== "string" || !input.artifact.id) { - throw new ServerClientError("Artifact ID is required", "invalid_input"); - } - if (input.artifact.version !== undefined && typeof input.artifact.version !== "string") { - throw new ServerClientError("Artifact version must be a string", "invalid_input"); - } - } else if (typeof input.response !== "string" || !input.response.trim()) { - throw new ServerClientError( - `Unknown tool: ${input.name}. Supply an artifact or complete OpenUI response for script execution.`, - "tool_not_found", - 404, - ); + const calls = response["calls"]; + if ( + response["status"] !== "tool_calls" || + typeof response["state"] !== "string" || + !response["state"] || + !Array.isArray(calls) || + !calls.length + ) + invalidResponse(); + if (round === MAX_ROUNDS || seen.size + calls.length > MAX_TOOL_CALLS) { + throw new ServerClientError("Script execution limit exceeded", "script_limit", 502); } - - let state: string | undefined; - let toolResults: ScriptToolResult[] = []; - const seen = new Set(); - // Allow a final settlement request after the last tool-call round. - for (let round = 0; round <= MAX_ROUNDS; round++) { - const response = await abortable( - postJSON( - config, - hasArtifact ? "/v1/dashboards/execute" : "/v1/app/execute", - { - ...(hasArtifact - ? { - artifact_id: input.artifact!.id, - ...(input.artifact!.version !== undefined && { - artifact_version: input.artifact!.version, - }), - } - : { response: input.response }), - name: input.name, - arguments: args, - tool_results: toolResults, - ...(state !== undefined && { state }), - }, - signal, - ), - signal, - ); - if (!isRecord(response)) invalidResponse(); - if (response["status"] === "done") return response["result"]; - if (response["status"] === "error") { - const error = response["error"]; - if ( - !isRecord(error) || - typeof error["code"] !== "string" || - typeof error["message"] !== "string" - ) - invalidResponse(); - // Never replay tools after a failed snapshot: a prior call may have mutated data. - throw new ServerClientError(error["message"], error["code"]); - } - const calls = response["calls"]; + // Validate the complete batch before any customer tool starts. + for (const call of calls) { if ( - response["status"] !== "tool_calls" || - typeof response["state"] !== "string" || - !response["state"] || - !Array.isArray(calls) || - !calls.length + !isRecord(call) || + (call["type"] !== undefined && call["type"] !== "function_call") || + typeof call["call_id"] !== "string" || + !call["call_id"] || + seen.has(call["call_id"]) || + typeof call["name"] !== "string" || + !call["name"] || + typeof call["arguments"] !== "string" ) invalidResponse(); - if (round === MAX_ROUNDS || seen.size + calls.length > MAX_TOOL_CALLS) { - throw new ServerClientError("Script execution limit exceeded", "script_limit", 502); - } - // Validate the complete batch before any customer tool starts. - for (const call of calls) { - if ( - !isRecord(call) || - (call["type"] !== undefined && call["type"] !== "function_call") || - typeof call["call_id"] !== "string" || - !call["call_id"] || - seen.has(call["call_id"]) || - typeof call["name"] !== "string" || - !call["name"] || - typeof call["arguments"] !== "string" - ) - invalidResponse(); - seen.add(call["call_id"]); - } - state = response["state"]; - toolResults = await Promise.all( - (calls as ScriptToolCall[]).map(async (call): Promise => { - try { - const toolArgs: unknown = JSON.parse(call.arguments); - if (!isRecord(toolArgs)) - throw new ServerClientError("Tool arguments must be an object", "bad_args"); - const result = await dispatch(call.name, toolArgs); - signal.throwIfAborted(); - const output = JSON.stringify(result ?? null); - if (output === undefined) - throw new ServerClientError( - "Tool result must be JSON serializable", - "bad_tool_result", - ); - return { - type: "function_call_output", - call_id: call.call_id, - output, - }; - } catch (error) { - signal.throwIfAborted(); - return { - call_id: call.call_id, - error: { - code: error instanceof ServerClientError ? error.code : "tool_exec_error", - message: error instanceof Error ? error.message : String(error), - }, - }; - } - }), - ); + seen.add(call["call_id"]); } - throw new ServerClientError("Script execution limit exceeded", "script_limit", 502); - } finally { - clearTimeout(timer); - input.signal?.removeEventListener("abort", forwardAbort); - controller.abort(); + state = response["state"]; + toolResults = await Promise.all( + (calls as ScriptToolCall[]).map(async (call): Promise => { + try { + const toolArgs: unknown = JSON.parse(call.arguments); + if (!isRecord(toolArgs)) + throw new ServerClientError("Tool arguments must be an object", "bad_args"); + const result = await dispatch(call.name, toolArgs); + signal.throwIfAborted(); + const output = JSON.stringify(result ?? null); + if (output === undefined) + throw new ServerClientError( + "Tool result must be JSON serializable", + "bad_tool_result", + ); + return { + type: "function_call_output", + call_id: call.call_id, + output, + }; + } catch (error) { + signal.throwIfAborted(); + return { + call_id: call.call_id, + error: { + code: error instanceof ServerClientError ? error.code : "tool_exec_error", + message: error instanceof Error ? error.message : String(error), + }, + }; + } + }), + ); } + throw new ServerClientError("Script execution limit exceeded", "script_limit", 502); + } finally { + clearTimeout(timer); + input.signal?.removeEventListener("abort", forwardAbort); + controller.abort(); + } + } + + return { + execute, + create( + registry: Record>, + ): RegisteredTools { + const entries = Object.entries(registry); + const tools = Object.fromEntries(entries.map(([name, tool]) => [name, tool.execute])); + const definitions = entries.map(([name, { description, parameters, output }]) => ({ + name, + parameters, + ...(description !== undefined && { description }), + ...(output !== undefined && { output }), + })); + + return { + definitions, + execute(input, options?: ToolExecutionOptions) { + // Only request data comes from input; capabilities and context stay server-owned. + return execute({ + name: input.name, + arguments: input.arguments, + response: input.response, + tools, + context: options?.context, + signal: options?.signal, + timeoutMs: options?.timeoutMs, + } as ExecuteToolInput); + }, + }; }, }; } diff --git a/packages/server/src/tools/types.ts b/packages/server/src/tools/types.ts index 7e6367e0fd..c053f50fcf 100644 --- a/packages/server/src/tools/types.ts +++ b/packages/server/src/tools/types.ts @@ -1,6 +1,10 @@ -export interface ToolArtifactRef { - id: string; - version?: string; +/** Provider-neutral metadata passed to generation, without server handlers. */ +export interface ToolDefinition { + name: string; + description?: string; + parameters: Record; + /** Illustrative output for generation; the handler supplies actual runtime data. */ + output?: unknown; } /** Registered customer tools execute on the host server with trusted context. */ @@ -9,15 +13,37 @@ export type ToolExecutor = ( options: { context: Context; signal: AbortSignal }, ) => unknown | Promise; -export type ExecuteToolInput = { +export type ToolRegistration = Omit & { + execute: ToolExecutor; +}; + +export interface ToolExecutionRequest { name: string; arguments?: Record; - tools: Record>; + /** Complete generated response. Required for script names, optional for direct tools. */ + response?: string; +} + +export type ToolExecutionOptions = { signal?: AbortSignal; /** Total execution budget, including customer tools. Default: 60 seconds. */ timeoutMs?: number; -} & ({ artifact: ToolArtifactRef; response?: never } | { artifact?: never; response?: string }) & - (undefined extends Context ? { context?: Context } : { context: Context }); +} & (undefined extends Context ? { context?: Context } : { context: Context }); + +export type ExecuteToolInput = ToolExecutionRequest & + ToolExecutionOptions & { + tools: Record>; + }; + +export interface RegisteredTools { + definitions: ToolDefinition[]; + execute( + input: ToolExecutionRequest, + ...options: undefined extends Context + ? [options?: ToolExecutionOptions] + : [options: ToolExecutionOptions] + ): Promise; +} export interface ScriptToolCall { type?: "function_call";