@skaile/forge-app-actions 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,7 +29,34 @@ Peers: `@nuxt/ui ^4.11`, `nuxt`, `vue`; `@skaile/workspaces ^6` is an optional p
29
29
  |---|---|
30
30
  | `.` | the Nuxt module (`forgeAppActions` config key) |
31
31
  | `./client-capabilities` | browser-side API, explicit-import entry for tests and non-auto-import contexts |
32
+ | `./chat` | `useChatActions`, `toolNameOf` — forge actions as forge-chat client tools (browser) |
33
+ | `./chat/server` | `clientToolsFromBody` — validates the `clientTools` request field (no Vue) |
32
34
  | `./server` | `clientCapabilityBridge`, `capabilityRegistry`, `routeCapabilityInvocation`, … |
33
35
  | `./types` | `ClientCapability*`, `ForgeAction*`, `Palette*` types |
34
36
 
35
37
  Moved here from `@skaile-ai/forge-common` 9.x (`./client-capabilities` and the capability parts of `./server` and `./types`).
38
+
39
+ ## Using actions from an AI-SDK chat
40
+
41
+ A plain Nuxt app with an AI-SDK route (no Skaile platform, no SSE agent session) can let the model call the browser's actions through `@skaile/forge-chat` ≥ 0.2 client tools. Every action whose `audience` includes `'llm'` becomes a tool; `requiresApproval` actions wait for the user's confirmation first.
42
+
43
+ **Tool names.** Model providers reject dots in tool names (Anthropic `^[a-zA-Z0-9_-]{1,64}$`, OpenAI `^[a-zA-Z0-9_-]+$`), so the tool name is `toolNameOf(capabilityName)`: every character outside `[A-Za-z0-9_-]` becomes `_`, truncated to 64. Action `doc.read` (capability `app.doc.read`) is the tool `app_doc_read`. Name tools in prompts with `toolNameOf('app.doc.read')` (exported from `@skaile/forge-app-actions/chat`). Two actions that map to the same tool name make `descriptors()` and the handler lookup throw. The handler still runs the action under its real capability name (`ctx.name === 'app.doc.read'`). Descriptor schemas are the zod-derived JSON Schema without the top-level `$schema` key.
44
+
45
+ ```ts
46
+ // app/pages/index.vue
47
+ const chatActions = useChatActions({ actions: () => actions }) // from '@skaile/forge-app-actions/chat'
48
+ const transport = createAiSdkTransport({ url: '/api/chat', body: () => ({ clientTools: chatActions.descriptors() }) })
49
+ const session = createChatSession({ transport, tools: chatActions.handlers })
50
+ // app/app.vue: <ForgeCapabilityApproval /> — mount once
51
+
52
+ // server/api/chat.post.ts
53
+ import { clientToolsFromBody } from '@skaile/forge-app-actions/chat/server'
54
+ export default defineEventHandler((event) =>
55
+ createAiSdkChatHandler({ model, clientTools: (ctx) => clientToolsFromBody(ctx.body) })(toWebRequest(event)))
56
+ ```
57
+
58
+ Round trip: the model emits a tool call → the handler ends the turn with `turn-end` reason `tool-calls` → the session runs `chatActions.handlers[name]` → the result is pushed and the turn resumes. A handler validates the input against the action's zod schema first (invalid → the model gets `invalid input: <issues>`), then asks for approval when required (`declined` → the model gets `declined`), then runs the action with `source: 'llm'`.
59
+
60
+ `clientToolsFromBody` returns `undefined` when the body has no `clientTools`, and throws an error with `statusCode: 400` (`clientTools: <issues>`) for a bad tool name, an over-long description or a non-object schema — `createAiSdkChatHandler` answers such an error with that status.
61
+
62
+ Peer: `@skaile/forge-chat ^0.2.0` (optional; type-only imports).
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@skaile/forge-app-actions",
3
3
  "configKey": "forgeAppActions",
4
- "version": "0.1.0",
4
+ "version": "0.2.1",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.3",
7
7
  "unbuild": "3.6.1"
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Browser half of the AI-SDK chat binding (`@skaile/forge-app-actions/chat`).
3
+ *
4
+ * Turns registered {@link ForgeAction}s into forge-chat **client tools**:
5
+ *
6
+ * - `descriptors()` — what the model may call. Goes into the chat request body
7
+ * (`createAiSdkTransport({ body })`); the server validates it with
8
+ * `clientToolsFromBody` from `@skaile/forge-app-actions/chat/server`.
9
+ * - `handlers` — what runs when the model calls one. Pass as
10
+ * `createChatSession({ tools })`. Same contract as the SSE bridge's
11
+ * `useCapabilityInvocations().handle`: validate against the live zod schema,
12
+ * ask for approval (`<ForgeCapabilityApproval />`) when the action declares
13
+ * `requiresApproval`, then run the handler with a `ClientCapabilityContext`.
14
+ *
15
+ * Only actions whose audience includes `'llm'` are exposed. Tools are named
16
+ * {@link toolNameOf}(capability name) — `app.<id>` becomes `app_<id>` — because
17
+ * model providers reject dots in tool names.
18
+ */
19
+ import type { ClientCapabilityLogger } from "../types/client-capability.js";
20
+ import type { ForgeAction } from "../types/forge-action.js";
21
+ import type { ClientToolDecls, ClientToolHandlers } from "./types.js";
22
+ export type { ClientToolDecls, ClientToolHandlers } from "./types.js";
23
+ export interface UseChatActionsOptions {
24
+ /**
25
+ * The actions to expose, read on every use — the list may change over the
26
+ * lifetime of the chat (an editor that mounts later, a route-scoped action).
27
+ */
28
+ actions: () => readonly ForgeAction[];
29
+ /** Opaque session key for the handler's {@link ClientCapabilityContext}. Informational. */
30
+ sessionKey?: string;
31
+ /** Logger handed to handlers. Defaults to `console`. */
32
+ log?: ClientCapabilityLogger;
33
+ /**
34
+ * Deadline for an unanswered approval dialog, in ms; the action's own
35
+ * `callTimeoutMs` wins. Defaults to {@link DEFAULT_APPROVAL_TIMEOUT_MS}; `0`
36
+ * disables the deadline.
37
+ */
38
+ approvalTimeoutMs?: number;
39
+ }
40
+ export interface UseChatActionsReturn {
41
+ /** Tool descriptors for the request body: one per LLM-audience action. */
42
+ descriptors(): ClientToolDecls;
43
+ /** Tool handlers for `createChatSession({ tools })`, resolved against `actions()` at call time. */
44
+ handlers: ClientToolHandlers;
45
+ }
46
+ /**
47
+ * The provider-safe tool name of a capability name: every character outside
48
+ * `[A-Za-z0-9_-]` becomes `_` (Anthropic and OpenAI reject others), truncated
49
+ * to 64. `app.doc.read` → `app_doc_read`. Hosts use it to name tools in prompts.
50
+ */
51
+ export declare function toolNameOf(capabilityName: string): string;
52
+ export declare function useChatActions(options: UseChatActionsOptions): UseChatActionsReturn;
@@ -0,0 +1,100 @@
1
+ import {
2
+ DEFAULT_APPROVAL_TIMEOUT_MS,
3
+ useCapabilityApproval
4
+ } from "../composables/useCapabilityApproval.js";
5
+ import { describeError } from "../internal/describe-error.js";
6
+ const defaultLog = {
7
+ info: (message, ...args) => console.log(message, ...args),
8
+ warn: (message, ...args) => console.warn(message, ...args),
9
+ error: (message, ...args) => console.error(message, ...args)
10
+ };
11
+ const TOOL_NAME_MAX = 64;
12
+ export function toolNameOf(capabilityName) {
13
+ return capabilityName.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, TOOL_NAME_MAX);
14
+ }
15
+ function withoutSchemaKey(schema) {
16
+ const { $schema: _draft, ...rest } = schema;
17
+ return rest;
18
+ }
19
+ export function useChatActions(options) {
20
+ const log = options.log ?? defaultLog;
21
+ function exposed() {
22
+ const byTool = /* @__PURE__ */ new Map();
23
+ for (const action of options.actions()) {
24
+ const capability = action.capability;
25
+ if (!capability || !action.audience.includes("llm")) continue;
26
+ const toolName = toolNameOf(capability.name);
27
+ const other = byTool.get(toolName);
28
+ if (other && other.name !== capability.name) {
29
+ throw new Error(
30
+ `[useChatActions] "${other.name}" and "${capability.name}" both map to the tool name "${toolName}"; rename one action.`
31
+ );
32
+ }
33
+ byTool.set(toolName, capability);
34
+ }
35
+ return byTool;
36
+ }
37
+ function descriptors() {
38
+ const decls = {};
39
+ for (const [toolName, capability] of exposed()) {
40
+ decls[toolName] = {
41
+ description: capability.description,
42
+ // Derived by `defineClientCapability` via `z.toJSONSchema()`; the
43
+ // draft marker is dropped, providers do not take it.
44
+ inputSchema: withoutSchemaKey(capability.inputSchema)
45
+ };
46
+ }
47
+ return decls;
48
+ }
49
+ function handlerFor(toolName) {
50
+ const capability = exposed().get(toolName);
51
+ if (!capability) return void 0;
52
+ const name = capability.name;
53
+ return async (rawInput, { toolCallId, signal }) => {
54
+ if (signal.aborted) throw new Error("aborted");
55
+ const checked = capability.inputZod.safeParse(rawInput ?? {});
56
+ if (!checked.success) throw new Error(`invalid input: ${describeError(checked.error)}`);
57
+ const input = checked.data;
58
+ if (capability.requiresApproval) {
59
+ const decision = await useCapabilityApproval().request({
60
+ callId: toolCallId,
61
+ name,
62
+ displayName: capability.displayName,
63
+ description: capability.description,
64
+ input,
65
+ timeoutMs: capability.callTimeoutMs ?? options.approvalTimeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS,
66
+ signal
67
+ });
68
+ if (decision === "declined") throw new Error("declined");
69
+ if (decision === "timeout") throw new Error("approval timed out");
70
+ if (decision !== "approved") throw new Error("approval cancelled");
71
+ }
72
+ const ctx = {
73
+ sessionKey: options.sessionKey ?? "",
74
+ callId: toolCallId,
75
+ name,
76
+ log
77
+ };
78
+ const result = await capability.handler(input, ctx);
79
+ if (capability.outputZod) {
80
+ const check = capability.outputZod.safeParse(result);
81
+ if (!check.success) {
82
+ log.warn(
83
+ `[useChatActions] ${name} returned a value that does not match its declared output schema: ${describeError(check.error)}`
84
+ );
85
+ }
86
+ }
87
+ return result ?? null;
88
+ };
89
+ }
90
+ const handlers = new Proxy({}, {
91
+ get: (_target, key) => typeof key === "string" ? handlerFor(key) : void 0,
92
+ has: (_target, key) => typeof key === "string" && exposed().has(key),
93
+ ownKeys: () => [...exposed().keys()],
94
+ getOwnPropertyDescriptor: (_target, key) => {
95
+ const handler = typeof key === "string" ? handlerFor(key) : void 0;
96
+ return handler ? { value: handler, enumerable: true, configurable: true, writable: false } : void 0;
97
+ }
98
+ });
99
+ return { descriptors, handlers };
100
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Server half of the AI-SDK chat binding (`@skaile/forge-app-actions/chat/server`).
3
+ *
4
+ * The browser sends the descriptors of its LLM-audience actions in the chat
5
+ * request body (`clientTools`). This module validates that untrusted blob so a
6
+ * host can hand it straight to `createAiSdkChatHandler`:
7
+ *
8
+ * ```ts
9
+ * createAiSdkChatHandler({ clientTools: (ctx) => clientToolsFromBody(ctx.body), … })
10
+ * ```
11
+ *
12
+ * No Vue, no Nuxt, no `ai` import — safe for any Nitro route.
13
+ */
14
+ import type { ClientToolDecls } from "./types.js";
15
+ /** Same pattern forge-chat's handler enforces; checked here first for a precise message. */
16
+ export declare const CLIENT_TOOL_NAME_PATTERN: RegExp;
17
+ /** Upper bound on a tool description — descriptors come from an untrusted browser. */
18
+ export declare const CLIENT_TOOL_DESCRIPTION_MAX = 2000;
19
+ /**
20
+ * Thrown for a malformed `clientTools` body field. Carries `statusCode: 400`, so
21
+ * `createAiSdkChatHandler` answers with that status when it is thrown from an
22
+ * option callback.
23
+ */
24
+ export declare class ClientToolsBodyError extends Error {
25
+ readonly statusCode = 400;
26
+ constructor(message: string);
27
+ }
28
+ /**
29
+ * Read and validate `body.clientTools`.
30
+ *
31
+ * - absent (`undefined`) → `undefined`
32
+ * - valid → the descriptors, unchanged
33
+ * - malformed → throws {@link ClientToolsBodyError} (`statusCode` 400,
34
+ * message `clientTools: <path>: <issue>; …`)
35
+ */
36
+ export declare function clientToolsFromBody(body: Record<string, unknown>): ClientToolDecls | undefined;
@@ -0,0 +1,27 @@
1
+ import * as z from "zod";
2
+ import { describeError } from "../internal/describe-error.js";
3
+ export const CLIENT_TOOL_NAME_PATTERN = /^[a-z][a-z0-9_.-]{0,63}$/i;
4
+ export const CLIENT_TOOL_DESCRIPTION_MAX = 2e3;
5
+ const clientToolsSchema = z.record(
6
+ z.string().regex(CLIENT_TOOL_NAME_PATTERN, "invalid tool name"),
7
+ z.object({
8
+ description: z.string().max(CLIENT_TOOL_DESCRIPTION_MAX),
9
+ inputSchema: z.record(z.string(), z.unknown())
10
+ })
11
+ );
12
+ export class ClientToolsBodyError extends Error {
13
+ statusCode = 400;
14
+ constructor(message) {
15
+ super(message);
16
+ this.name = "ClientToolsBodyError";
17
+ }
18
+ }
19
+ export function clientToolsFromBody(body) {
20
+ const raw = body.clientTools;
21
+ if (raw === void 0) return void 0;
22
+ const parsed = clientToolsSchema.safeParse(raw);
23
+ if (!parsed.success) {
24
+ throw new ClientToolsBodyError(`clientTools: ${describeError(parsed.error)}`);
25
+ }
26
+ return parsed.data;
27
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The two forge-chat contracts this binding speaks. Type-only, so
3
+ * `@skaile/forge-chat` stays an optional peer.
4
+ *
5
+ * `ClientToolHandlers` is derived from `ChatSessionOptions['tools']` because
6
+ * forge-chat does not export the alias by name.
7
+ */
8
+ import type { ChatSessionOptions } from "@skaile/forge-chat/core";
9
+ export type { ClientToolDecls } from "@skaile/forge-chat/server/ai-sdk";
10
+ export type ClientToolHandlers = NonNullable<ChatSessionOptions["tools"]>;
File without changes
@@ -1,3 +1,4 @@
1
+ import { describeError } from "../internal/describe-error.js";
1
2
  import { useAgentCapabilities } from "./useAgentCapabilities.js";
2
3
  import { DEFAULT_APPROVAL_TIMEOUT_MS, useCapabilityApproval } from "./useCapabilityApproval.js";
3
4
  const defaultLog = {
@@ -10,18 +11,6 @@ function isInvocationEvent(event) {
10
11
  const e = event;
11
12
  return e.type === "capability_invoked" && typeof e.callId === "string" && e.callId.length > 0 && typeof e.name === "string" && e.name.length > 0;
12
13
  }
13
- function describeError(err) {
14
- if (err && typeof err === "object" && Array.isArray(err.issues)) {
15
- const issues = err.issues;
16
- const lines = issues.map((issue) => {
17
- const path = Array.isArray(issue.path) ? issue.path.join(".") : "";
18
- return path ? `${path}: ${issue.message}` : String(issue.message);
19
- });
20
- if (lines.length > 0) return lines.join("; ");
21
- }
22
- if (err instanceof Error) return err.message;
23
- return String(err);
24
- }
25
14
  async function postResult(options, callId, result, log) {
26
15
  const fetcher = options.fetcher ?? globalThis.fetch ?? void 0;
27
16
  if (!fetcher) {
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Turn a thrown value into the single line the agent (or model) will read.
3
+ *
4
+ * A zod v4 error carries the useful part in `issues`; `err.message` alone is a
5
+ * JSON blob. Flatten it to `path: message` pairs so the model can act on it.
6
+ *
7
+ * Lives outside `composables/` on purpose: that directory is auto-imported by
8
+ * the Nuxt module, and this helper is internal.
9
+ */
10
+ export declare function describeError(err: unknown): string;
@@ -0,0 +1,12 @@
1
+ export function describeError(err) {
2
+ if (err && typeof err === "object" && Array.isArray(err.issues)) {
3
+ const issues = err.issues;
4
+ const lines = issues.map((issue) => {
5
+ const path = Array.isArray(issue.path) ? issue.path.join(".") : "";
6
+ return path ? `${path}: ${issue.message}` : String(issue.message);
7
+ });
8
+ if (lines.length > 0) return lines.join("; ");
9
+ }
10
+ if (err instanceof Error) return err.message;
11
+ return String(err);
12
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skaile/forge-app-actions",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Browser-declared app actions for Skaile forge apps: defineForgeAction / defineClientCapability, the agent capability bridge, command palette and approval dialog, as a Nuxt module.",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1.3.9",
@@ -15,6 +15,14 @@
15
15
  "types": "./dist/runtime/client-capabilities.d.ts",
16
16
  "import": "./dist/runtime/client-capabilities.js"
17
17
  },
18
+ "./chat": {
19
+ "types": "./dist/runtime/chat/index.d.ts",
20
+ "import": "./dist/runtime/chat/index.js"
21
+ },
22
+ "./chat/server": {
23
+ "types": "./dist/runtime/chat/server.d.ts",
24
+ "import": "./dist/runtime/chat/server.js"
25
+ },
18
26
  "./server": {
19
27
  "types": "./dist/runtime/server/index.d.ts",
20
28
  "import": "./dist/runtime/server/index.js"
@@ -49,11 +57,15 @@
49
57
  },
50
58
  "peerDependencies": {
51
59
  "@nuxt/ui": "^4.11.1",
60
+ "@skaile/forge-chat": "^0.2.0",
52
61
  "@skaile/workspaces": "^6.0.0",
53
62
  "nuxt": ">=3.0.0",
54
63
  "vue": ">=3.0.0"
55
64
  },
56
65
  "peerDependenciesMeta": {
66
+ "@skaile/forge-chat": {
67
+ "optional": true
68
+ },
57
69
  "@skaile/workspaces": {
58
70
  "optional": true
59
71
  }
@@ -67,6 +79,7 @@
67
79
  "@nuxt/module-builder": "^1.0.0",
68
80
  "@nuxt/schema": "^4.0.0",
69
81
  "@nuxt/ui": "^4.11.1",
82
+ "@skaile/forge-chat": "^0.2.0",
70
83
  "@skaile/workspaces": "^6.0.0",
71
84
  "@types/bun": "^1.2.0",
72
85
  "@types/node": "^22.0.0",
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Browser half of the AI-SDK chat binding (`@skaile/forge-app-actions/chat`).
3
+ *
4
+ * Turns registered {@link ForgeAction}s into forge-chat **client tools**:
5
+ *
6
+ * - `descriptors()` — what the model may call. Goes into the chat request body
7
+ * (`createAiSdkTransport({ body })`); the server validates it with
8
+ * `clientToolsFromBody` from `@skaile/forge-app-actions/chat/server`.
9
+ * - `handlers` — what runs when the model calls one. Pass as
10
+ * `createChatSession({ tools })`. Same contract as the SSE bridge's
11
+ * `useCapabilityInvocations().handle`: validate against the live zod schema,
12
+ * ask for approval (`<ForgeCapabilityApproval />`) when the action declares
13
+ * `requiresApproval`, then run the handler with a `ClientCapabilityContext`.
14
+ *
15
+ * Only actions whose audience includes `'llm'` are exposed. Tools are named
16
+ * {@link toolNameOf}(capability name) — `app.<id>` becomes `app_<id>` — because
17
+ * model providers reject dots in tool names.
18
+ */
19
+
20
+ import {
21
+ DEFAULT_APPROVAL_TIMEOUT_MS,
22
+ useCapabilityApproval,
23
+ } from "../composables/useCapabilityApproval";
24
+ import { describeError } from "../internal/describe-error";
25
+ import type {
26
+ ClientCapabilityContext,
27
+ ClientCapabilityLogger,
28
+ DefinedClientCapability,
29
+ } from "../types/client-capability";
30
+ import type { ForgeAction } from "../types/forge-action";
31
+ import type { ClientToolDecls, ClientToolHandlers } from "./types";
32
+
33
+ export type { ClientToolDecls, ClientToolHandlers } from "./types";
34
+
35
+ export interface UseChatActionsOptions {
36
+ /**
37
+ * The actions to expose, read on every use — the list may change over the
38
+ * lifetime of the chat (an editor that mounts later, a route-scoped action).
39
+ */
40
+ actions: () => readonly ForgeAction[];
41
+ /** Opaque session key for the handler's {@link ClientCapabilityContext}. Informational. */
42
+ sessionKey?: string;
43
+ /** Logger handed to handlers. Defaults to `console`. */
44
+ log?: ClientCapabilityLogger;
45
+ /**
46
+ * Deadline for an unanswered approval dialog, in ms; the action's own
47
+ * `callTimeoutMs` wins. Defaults to {@link DEFAULT_APPROVAL_TIMEOUT_MS}; `0`
48
+ * disables the deadline.
49
+ */
50
+ approvalTimeoutMs?: number;
51
+ }
52
+
53
+ export interface UseChatActionsReturn {
54
+ /** Tool descriptors for the request body: one per LLM-audience action. */
55
+ descriptors(): ClientToolDecls;
56
+ /** Tool handlers for `createChatSession({ tools })`, resolved against `actions()` at call time. */
57
+ handlers: ClientToolHandlers;
58
+ }
59
+
60
+ const defaultLog: ClientCapabilityLogger = {
61
+ info: (message, ...args) => console.log(message, ...args),
62
+ warn: (message, ...args) => console.warn(message, ...args),
63
+ error: (message, ...args) => console.error(message, ...args),
64
+ };
65
+
66
+ type ToolHandler = ClientToolHandlers[string];
67
+
68
+ const TOOL_NAME_MAX = 64;
69
+
70
+ /**
71
+ * The provider-safe tool name of a capability name: every character outside
72
+ * `[A-Za-z0-9_-]` becomes `_` (Anthropic and OpenAI reject others), truncated
73
+ * to 64. `app.doc.read` → `app_doc_read`. Hosts use it to name tools in prompts.
74
+ */
75
+ export function toolNameOf(capabilityName: string): string {
76
+ return capabilityName.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, TOOL_NAME_MAX);
77
+ }
78
+
79
+ /** The derived JSON Schema without its top-level `$schema` draft marker; everything else stays. */
80
+ function withoutSchemaKey(schema: Record<string, unknown>): ClientToolDecls[string]["inputSchema"] {
81
+ const { $schema: _draft, ...rest } = schema;
82
+ return rest as ClientToolDecls[string]["inputSchema"];
83
+ }
84
+
85
+ export function useChatActions(options: UseChatActionsOptions): UseChatActionsReturn {
86
+ const log = options.log ?? defaultLog;
87
+
88
+ /** The LLM projection of every exposed action, keyed by provider-safe tool name. */
89
+ function exposed(): Map<string, DefinedClientCapability> {
90
+ const byTool = new Map<string, DefinedClientCapability>();
91
+ for (const action of options.actions()) {
92
+ const capability = action.capability;
93
+ if (!capability || !action.audience.includes("llm")) continue;
94
+ const toolName = toolNameOf(capability.name);
95
+ const other = byTool.get(toolName);
96
+ if (other && other.name !== capability.name) {
97
+ throw new Error(
98
+ `[useChatActions] "${other.name}" and "${capability.name}" both map to the tool name "${toolName}"; rename one action.`,
99
+ );
100
+ }
101
+ byTool.set(toolName, capability);
102
+ }
103
+ return byTool;
104
+ }
105
+
106
+ function descriptors(): ClientToolDecls {
107
+ const decls: ClientToolDecls = {};
108
+ for (const [toolName, capability] of exposed()) {
109
+ decls[toolName] = {
110
+ description: capability.description,
111
+ // Derived by `defineClientCapability` via `z.toJSONSchema()`; the
112
+ // draft marker is dropped, providers do not take it.
113
+ inputSchema: withoutSchemaKey(capability.inputSchema),
114
+ };
115
+ }
116
+ return decls;
117
+ }
118
+
119
+ function handlerFor(toolName: string): ToolHandler | undefined {
120
+ const capability = exposed().get(toolName);
121
+ if (!capability) return undefined;
122
+ const name = capability.name;
123
+
124
+ return async (rawInput, { toolCallId, signal }) => {
125
+ if (signal.aborted) throw new Error("aborted");
126
+
127
+ // Validate BEFORE anything else: `inputSchema` on the wire only shapes
128
+ // what the model tries to send; the live zod schema is the enforcement.
129
+ const checked = capability.inputZod.safeParse(rawInput ?? {});
130
+ if (!checked.success) throw new Error(`invalid input: ${describeError(checked.error)}`);
131
+ const input = checked.data;
132
+
133
+ // The approval gate sits after validation: the user sees the arguments
134
+ // that would actually run.
135
+ if (capability.requiresApproval) {
136
+ const decision = await useCapabilityApproval().request({
137
+ callId: toolCallId,
138
+ name,
139
+ displayName: capability.displayName,
140
+ description: capability.description,
141
+ input,
142
+ timeoutMs:
143
+ capability.callTimeoutMs ?? options.approvalTimeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS,
144
+ signal,
145
+ });
146
+ if (decision === "declined") throw new Error("declined");
147
+ if (decision === "timeout") throw new Error("approval timed out");
148
+ if (decision !== "approved") throw new Error("approval cancelled");
149
+ }
150
+
151
+ const ctx: ClientCapabilityContext = {
152
+ sessionKey: options.sessionKey ?? "",
153
+ callId: toolCallId,
154
+ name,
155
+ log,
156
+ };
157
+ const result = await capability.handler(input, ctx);
158
+
159
+ // Output validation is advisory, as in `useCapabilityInvocations`.
160
+ if (capability.outputZod) {
161
+ const check = capability.outputZod.safeParse(result);
162
+ if (!check.success) {
163
+ log.warn(
164
+ `[useChatActions] ${name} returned a value that does not match its declared ` +
165
+ `output schema: ${describeError(check.error)}`,
166
+ );
167
+ }
168
+ }
169
+ // forge-chat replays the result to the model on the next request, so it
170
+ // must be JSON-serialisable; `undefined` is not.
171
+ return result ?? null;
172
+ };
173
+ }
174
+
175
+ // A Proxy rather than a snapshot: the session looks handlers up by name at
176
+ // call time, and `actions()` may have changed since `useChatActions` ran.
177
+ const handlers = new Proxy({} as ClientToolHandlers, {
178
+ get: (_target, key) => (typeof key === "string" ? handlerFor(key) : undefined),
179
+ has: (_target, key) => typeof key === "string" && exposed().has(key),
180
+ ownKeys: () => [...exposed().keys()],
181
+ getOwnPropertyDescriptor: (_target, key) => {
182
+ const handler = typeof key === "string" ? handlerFor(key) : undefined;
183
+ return handler
184
+ ? { value: handler, enumerable: true, configurable: true, writable: false }
185
+ : undefined;
186
+ },
187
+ });
188
+
189
+ return { descriptors, handlers };
190
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Server half of the AI-SDK chat binding (`@skaile/forge-app-actions/chat/server`).
3
+ *
4
+ * The browser sends the descriptors of its LLM-audience actions in the chat
5
+ * request body (`clientTools`). This module validates that untrusted blob so a
6
+ * host can hand it straight to `createAiSdkChatHandler`:
7
+ *
8
+ * ```ts
9
+ * createAiSdkChatHandler({ clientTools: (ctx) => clientToolsFromBody(ctx.body), … })
10
+ * ```
11
+ *
12
+ * No Vue, no Nuxt, no `ai` import — safe for any Nitro route.
13
+ */
14
+
15
+ import * as z from "zod";
16
+ import { describeError } from "../internal/describe-error";
17
+ import type { ClientToolDecls } from "./types";
18
+
19
+ /** Same pattern forge-chat's handler enforces; checked here first for a precise message. */
20
+ export const CLIENT_TOOL_NAME_PATTERN = /^[a-z][a-z0-9_.-]{0,63}$/i;
21
+
22
+ /** Upper bound on a tool description — descriptors come from an untrusted browser. */
23
+ export const CLIENT_TOOL_DESCRIPTION_MAX = 2000;
24
+
25
+ const clientToolsSchema = z.record(
26
+ z.string().regex(CLIENT_TOOL_NAME_PATTERN, "invalid tool name"),
27
+ z.object({
28
+ description: z.string().max(CLIENT_TOOL_DESCRIPTION_MAX),
29
+ inputSchema: z.record(z.string(), z.unknown()),
30
+ }),
31
+ );
32
+
33
+ /**
34
+ * Thrown for a malformed `clientTools` body field. Carries `statusCode: 400`, so
35
+ * `createAiSdkChatHandler` answers with that status when it is thrown from an
36
+ * option callback.
37
+ */
38
+ export class ClientToolsBodyError extends Error {
39
+ readonly statusCode = 400;
40
+ constructor(message: string) {
41
+ super(message);
42
+ this.name = "ClientToolsBodyError";
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Read and validate `body.clientTools`.
48
+ *
49
+ * - absent (`undefined`) → `undefined`
50
+ * - valid → the descriptors, unchanged
51
+ * - malformed → throws {@link ClientToolsBodyError} (`statusCode` 400,
52
+ * message `clientTools: <path>: <issue>; …`)
53
+ */
54
+ export function clientToolsFromBody(body: Record<string, unknown>): ClientToolDecls | undefined {
55
+ const raw = body.clientTools;
56
+ if (raw === undefined) return undefined;
57
+ const parsed = clientToolsSchema.safeParse(raw);
58
+ if (!parsed.success) {
59
+ throw new ClientToolsBodyError(`clientTools: ${describeError(parsed.error)}`);
60
+ }
61
+ // The schema fixes names, description and an object-shaped inputSchema; a
62
+ // JSON-Schema document itself is the model provider's to judge.
63
+ return parsed.data as ClientToolDecls;
64
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The two forge-chat contracts this binding speaks. Type-only, so
3
+ * `@skaile/forge-chat` stays an optional peer.
4
+ *
5
+ * `ClientToolHandlers` is derived from `ChatSessionOptions['tools']` because
6
+ * forge-chat does not export the alias by name.
7
+ */
8
+
9
+ import type { ChatSessionOptions } from "@skaile/forge-chat/core";
10
+
11
+ export type { ClientToolDecls } from "@skaile/forge-chat/server/ai-sdk";
12
+
13
+ export type ClientToolHandlers = NonNullable<ChatSessionOptions["tools"]>;
@@ -23,6 +23,7 @@
23
23
  * See `docs/superpowers/specs/2026-08-22-forge-app-channel-design.md` §2.
24
24
  */
25
25
 
26
+ import { describeError } from "../internal/describe-error";
26
27
  import type {
27
28
  ClientCapabilityContext,
28
29
  ClientCapabilityLogger,
@@ -171,25 +172,6 @@ function isInvocationEvent(event: unknown): event is CapabilityInvokedEvent {
171
172
  );
172
173
  }
173
174
 
174
- /**
175
- * Turn a thrown value into the single line the agent will read.
176
- *
177
- * A zod v4 error carries the useful part in `issues`; `err.message` alone is a
178
- * JSON blob. Flatten it to `path: message` pairs so the model can act on it.
179
- */
180
- function describeError(err: unknown): string {
181
- if (err && typeof err === "object" && Array.isArray((err as { issues?: unknown }).issues)) {
182
- const issues = (err as { issues: Array<{ path?: unknown[]; message?: string }> }).issues;
183
- const lines = issues.map((issue) => {
184
- const path = Array.isArray(issue.path) ? issue.path.join(".") : "";
185
- return path ? `${path}: ${issue.message}` : String(issue.message);
186
- });
187
- if (lines.length > 0) return lines.join("; ");
188
- }
189
- if (err instanceof Error) return err.message;
190
- return String(err);
191
- }
192
-
193
175
  async function postResult(
194
176
  options: HandleInvocationOptions,
195
177
  callId: string,
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Turn a thrown value into the single line the agent (or model) will read.
3
+ *
4
+ * A zod v4 error carries the useful part in `issues`; `err.message` alone is a
5
+ * JSON blob. Flatten it to `path: message` pairs so the model can act on it.
6
+ *
7
+ * Lives outside `composables/` on purpose: that directory is auto-imported by
8
+ * the Nuxt module, and this helper is internal.
9
+ */
10
+ export function describeError(err: unknown): string {
11
+ if (err && typeof err === "object" && Array.isArray((err as { issues?: unknown }).issues)) {
12
+ const issues = (err as { issues: Array<{ path?: unknown[]; message?: string }> }).issues;
13
+ const lines = issues.map((issue) => {
14
+ const path = Array.isArray(issue.path) ? issue.path.join(".") : "";
15
+ return path ? `${path}: ${issue.message}` : String(issue.message);
16
+ });
17
+ if (lines.length > 0) return lines.join("; ");
18
+ }
19
+ if (err instanceof Error) return err.message;
20
+ return String(err);
21
+ }