diff --git a/.changeset/middleware-capabilities.md b/.changeset/middleware-capabilities.md new file mode 100644 index 000000000..987db886e --- /dev/null +++ b/.changeset/middleware-capabilities.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai': minor +--- + +Add a type-safe capability system to chat middleware. `createCapability()('name')` returns a `[get, provide]` accessor tuple that is also its own identity for `requires`/`provides` declarations — no separate token import. The middleware context also exposes `ctx.get(capability)` / `ctx.getOptional(capability)` / `ctx.provide(capability, value)`, typed by the handle you pass. Middleware gain a `setup` provisioning hook (runs first, before `onConfig`) plus `requires`/`provides`/`optionalRequires`. `chat()` validates that every required capability is provided, at compile time (an array coverage check and the new order-aware `createChatMiddleware()` builder) and at runtime (clear errors before the adapter runs). Adapters can now declare `requires`. This is the primitive layer for upcoming persistence and sandbox middleware; no concrete capabilities ship yet. diff --git a/docs/advanced/middleware.md b/docs/advanced/middleware.md index 6dc399d0f..fab12f6e6 100644 --- a/docs/advanced/middleware.md +++ b/docs/advanced/middleware.md @@ -43,7 +43,7 @@ const logger: ChatMiddleware = { }; const stream = chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.5"), messages: [{ role: "user", content: "Hello" }], middleware: [logger], }); @@ -548,7 +548,7 @@ Middleware execute in array order. The ordering matters for hooks that pipe or s ```typescript const stream = chat({ - adapter: openaiText("gpt-4o"), + adapter: openaiText("gpt-5.5"), messages, middleware: [authMiddleware, loggingMiddleware, cachingMiddleware], }); @@ -567,6 +567,132 @@ const stream = chat({ | `onUsage` | Sequential | All run in order | | `onFinish/onAbort/onError` | Sequential | All run in order | +## Capabilities + +Middleware often need to **share state**. A provider middleware sets something up (a database handle, a per-request counter, a sandbox), and a consumer middleware reads it back later in the same run. Capabilities make that hand-off **type-safe and order-checked**: the consumer declares what it needs, the provider declares what it offers, and `chat()` refuses to run (at compile time _and_ at runtime) if a required capability was never provided. + +### Creating a capability + +A capability is created with `createCapability()('name')` — a **curried** call: + +```typescript +import { createCapability } from "@tanstack/ai"; + +const counterCapability = createCapability<{ value: number }>()("counter"); +const [getCounter, provideCounter] = counterCapability; +``` + +The currying is deliberate: you supply the **value type** explicitly (`<{ value: number }>`) while the **name literal** is inferred from the argument (`"counter"`). A single `createCapability('name')` call can't do both — supplying `T` explicitly stops TypeScript inferring the name, collapsing it to `string` and defeating the compile-time coverage check that keys on the literal name. + +The returned `counterCapability` is a hybrid value: + +- It **destructures to `[get, provide]`** — the two accessors you use inside hooks. +- It **is itself the identity** you list in `requires` / `provides`. There is no separate token to import. + +The accessors: + +| Accessor | Behavior | +|----------|----------| +| `getCounter(ctx)` | Returns the value. **Throws** if the capability was never provided. | +| `getCounter(ctx, { optional: true })` | Returns `TValue \| undefined` — no throw when absent. | +| `provideCounter(ctx, value)` | Sets the value for this run. Call it from `setup`. | + +Equivalently, the context exposes `ctx.get(capability)`, `ctx.getOptional(capability)`, and `ctx.provide(capability, value)` — pass the capability handle directly. These are typed by the handle you pass (`ctx.get(counterCapability)` returns the value type), so `getCounter(ctx)` and `ctx.get(counterCapability)` are interchangeable — use whichever reads better in your hook. + +> **Capability names must be unique across your app.** The compile-time coverage check keys on the name literal (runtime keys on the handle reference), so two capabilities sharing a name will conflate in the type-level check. + +### The `setup` hook + +Provisioning happens in a dedicated `setup(ctx)` hook. It **runs first** — before any `onConfig` (init), across all middleware in array order — so that by the time the rest of the lifecycle begins, every capability is in place. `setup` receives the stable `ChatMiddlewareContext` (not the mutable config), and may be async. + +### `requires` / `provides` / `optionalRequires` + +Three array fields on a middleware declare its capability contract. Each is a `ReadonlyArray` — you list the capability handles themselves: + +| Field | Meaning | +|-------|---------| +| `provides` | Capabilities this middleware sets up. Each one **must** be `provide`d inside `setup`, or `chat()` throws after the setup phase. | +| `requires` | Capabilities this middleware reads. `chat()` validates (compile time + runtime) that some earlier middleware provides each one. | +| `optionalRequires` | Capabilities used **if present** but not required. Non-gating — never causes a validation error. Read with `getX(ctx, { optional: true })`. | + +### Array example + +Author middleware with `defineChatMiddleware` — it sharpens the `requires` / `provides` tuple types so the coverage check and builder can read them precisely. Here a **provider** sets up a counter in `setup`, and a **consumer** reads it in a hook: + +```typescript +import { + chat, + createCapability, + defineChatMiddleware, +} from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; + +const counterCapability = createCapability<{ value: number }>()("counter"); +const [getCounter, provideCounter] = counterCapability; + +// Provider: declares `provides` and provisions the value in `setup`. +const withCounter = defineChatMiddleware({ + name: "with-counter", + provides: [counterCapability], + setup(ctx) { + provideCounter(ctx, { value: 0 }); + }, +}); + +// Consumer: declares `requires` and reads the value via `get` in a hook. +const countsChunks = defineChatMiddleware({ + name: "counts-chunks", + requires: [counterCapability], + onChunk(ctx) { + getCounter(ctx).value++; + }, + onFinish(ctx) { + console.log(`Saw ${getCounter(ctx).value} chunks`); + }, +}); + +const stream = chat({ + adapter: openaiText("gpt-5.5"), + messages: [{ role: "user", content: "Hello" }], + // Provider must come before the consumer. + middleware: [withCounter, countsChunks], +}); +``` + +If you drop `withCounter` from the array, `chat()` reports a compile-time error at the `middleware` option naming the missing `"counter"` capability — and throws at runtime before the adapter is ever called. + +### Builder example + +`createChatMiddleware()` builds the array through chained `.use()` calls and enforces **provider-before-consumer ordering at compile time**: each `.use()` requires that the middleware's `requires` are already covered by capabilities provided by earlier `.use()` calls. + +```typescript +import { chat, createChatMiddleware } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; + +const middleware = createChatMiddleware() + .use(withCounter) // provides "counter" + .use(countsChunks) // requires "counter" — OK, already provided above + .build(); + +const stream = chat({ + adapter: openaiText("gpt-5.5"), + messages: [{ role: "user", content: "Hello" }], + middleware, +}); +``` + +Swap the two `.use()` calls (`.use(countsChunks).use(withCounter)`) and the builder rejects it at the `.use(countsChunks)` line — the consumer is ordered before its provider, so `"counter"` isn't in the provided set yet. + +### Validation guarantees + +The capability system fails loudly and early: + +- **Compile-time coverage.** A required capability that nothing provides surfaces as a type error at the `middleware` option. This is enforced two ways: an **array coverage check** on `middleware: [...]`, and the order-aware **`createChatMiddleware()` builder** (which additionally enforces ordering). +- **Runtime coverage.** Even if types are bypassed, `chat()` validates coverage and **throws before the adapter runs** if a required capability is missing. +- **Post-`setup` assertion.** If a middleware declares a capability in `provides` but never calls its `provide` accessor during `setup`, `chat()` throws after the setup phase — you can't silently forget to provision. +- **Duplicate provide → last-wins + warning.** If two middleware provide the same capability, the last write wins and a development warning is emitted. +- **Unique names.** Capability `name`s must be unique across your app; the compile-time coverage check keys on the name literal (runtime keys on the handle reference). + ## Built-in Middleware TanStack AI ships ready-made middleware for common cases — caching tool results, redacting streamed text, and OpenTelemetry tracing: diff --git a/docs/config.json b/docs/config.json index e3fc3b712..8bffca9c5 100644 --- a/docs/config.json +++ b/docs/config.json @@ -270,7 +270,8 @@ { "label": "Middleware", "to": "advanced/middleware", - "addedAt": "2026-04-15" + "addedAt": "2026-04-15", + "updatedAt": "2026-06-15" }, { "label": "Built-in Middleware", diff --git a/examples/ts-react-chat/src/components/Header.tsx b/examples/ts-react-chat/src/components/Header.tsx index 0e581b321..0a0424ec8 100644 --- a/examples/ts-react-chat/src/components/Header.tsx +++ b/examples/ts-react-chat/src/components/Header.tsx @@ -16,6 +16,7 @@ import { Music, Plug, Server, + Sparkles, Video, X, } from 'lucide-react' @@ -280,6 +281,19 @@ export default function Header() { MCP Servers + + setIsOpen(false)} + className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2" + activeProps={{ + className: + 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2', + }} + > + + Capability Middleware + diff --git a/examples/ts-react-chat/src/routeTree.gen.ts b/examples/ts-react-chat/src/routeTree.gen.ts index 83da3a117..d51c54cf6 100644 --- a/examples/ts-react-chat/src/routeTree.gen.ts +++ b/examples/ts-react-chat/src/routeTree.gen.ts @@ -17,6 +17,7 @@ import { Route as Issue176ToolResultRouteImport } from './routes/issue-176-tool- import { Route as ImageToolReproRouteImport } from './routes/image-tool-repro' import { Route as ImageGenRouteImport } from './routes/image-gen' import { Route as GenerationHooksRouteImport } from './routes/generation-hooks' +import { Route as CapabilityDemoRouteImport } from './routes/capability-demo' import { Route as IndexRouteImport } from './routes/index' import { Route as GenerationsVideoRouteImport } from './routes/generations.video' import { Route as GenerationsTranscriptionRouteImport } from './routes/generations.transcription' @@ -38,6 +39,7 @@ import { Route as ApiMcpManualRouteImport } from './routes/api.mcp-manual' import { Route as ApiMcpChatRouteImport } from './routes/api.mcp-chat' import { Route as ApiImageToolReproRouteImport } from './routes/api.image-tool-repro' import { Route as ApiImageGenRouteImport } from './routes/api.image-gen' +import { Route as ApiCapabilityDemoRouteImport } from './routes/api.capability-demo' import { Route as ExampleGuitarsIndexRouteImport } from './routes/example.guitars/index' import { Route as ExampleGuitarsGuitarIdRouteImport } from './routes/example.guitars/$guitarId' import { Route as ApiGenerateVideoRouteImport } from './routes/api.generate.video' @@ -85,6 +87,11 @@ const GenerationHooksRoute = GenerationHooksRouteImport.update({ path: '/generation-hooks', getParentRoute: () => rootRouteImport, } as any) +const CapabilityDemoRoute = CapabilityDemoRouteImport.update({ + id: '/capability-demo', + path: '/capability-demo', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -193,6 +200,11 @@ const ApiImageGenRoute = ApiImageGenRouteImport.update({ path: '/api/image-gen', getParentRoute: () => rootRouteImport, } as any) +const ApiCapabilityDemoRoute = ApiCapabilityDemoRouteImport.update({ + id: '/api/capability-demo', + path: '/api/capability-demo', + getParentRoute: () => rootRouteImport, +} as any) const ExampleGuitarsIndexRoute = ExampleGuitarsIndexRouteImport.update({ id: '/example/guitars/', path: '/example/guitars/', @@ -226,6 +238,7 @@ const ApiGenerateAudioRoute = ApiGenerateAudioRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/capability-demo': typeof CapabilityDemoRoute '/generation-hooks': typeof GenerationHooksRoute '/image-gen': typeof ImageGenRoute '/image-tool-repro': typeof ImageToolReproRoute @@ -234,6 +247,7 @@ export interface FileRoutesByFullPath { '/realtime': typeof RealtimeRoute '/server-fn-chat': typeof ServerFnChatRoute '/threads': typeof ThreadsRoute + '/api/capability-demo': typeof ApiCapabilityDemoRoute '/api/image-gen': typeof ApiImageGenRoute '/api/image-tool-repro': typeof ApiImageToolReproRoute '/api/mcp-chat': typeof ApiMcpChatRoute @@ -263,6 +277,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/capability-demo': typeof CapabilityDemoRoute '/generation-hooks': typeof GenerationHooksRoute '/image-gen': typeof ImageGenRoute '/image-tool-repro': typeof ImageToolReproRoute @@ -271,6 +286,7 @@ export interface FileRoutesByTo { '/realtime': typeof RealtimeRoute '/server-fn-chat': typeof ServerFnChatRoute '/threads': typeof ThreadsRoute + '/api/capability-demo': typeof ApiCapabilityDemoRoute '/api/image-gen': typeof ApiImageGenRoute '/api/image-tool-repro': typeof ApiImageToolReproRoute '/api/mcp-chat': typeof ApiMcpChatRoute @@ -301,6 +317,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/capability-demo': typeof CapabilityDemoRoute '/generation-hooks': typeof GenerationHooksRoute '/image-gen': typeof ImageGenRoute '/image-tool-repro': typeof ImageToolReproRoute @@ -309,6 +326,7 @@ export interface FileRoutesById { '/realtime': typeof RealtimeRoute '/server-fn-chat': typeof ServerFnChatRoute '/threads': typeof ThreadsRoute + '/api/capability-demo': typeof ApiCapabilityDemoRoute '/api/image-gen': typeof ApiImageGenRoute '/api/image-tool-repro': typeof ApiImageToolReproRoute '/api/mcp-chat': typeof ApiMcpChatRoute @@ -340,6 +358,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/capability-demo' | '/generation-hooks' | '/image-gen' | '/image-tool-repro' @@ -348,6 +367,7 @@ export interface FileRouteTypes { | '/realtime' | '/server-fn-chat' | '/threads' + | '/api/capability-demo' | '/api/image-gen' | '/api/image-tool-repro' | '/api/mcp-chat' @@ -377,6 +397,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/capability-demo' | '/generation-hooks' | '/image-gen' | '/image-tool-repro' @@ -385,6 +406,7 @@ export interface FileRouteTypes { | '/realtime' | '/server-fn-chat' | '/threads' + | '/api/capability-demo' | '/api/image-gen' | '/api/image-tool-repro' | '/api/mcp-chat' @@ -414,6 +436,7 @@ export interface FileRouteTypes { id: | '__root__' | '/' + | '/capability-demo' | '/generation-hooks' | '/image-gen' | '/image-tool-repro' @@ -422,6 +445,7 @@ export interface FileRouteTypes { | '/realtime' | '/server-fn-chat' | '/threads' + | '/api/capability-demo' | '/api/image-gen' | '/api/image-tool-repro' | '/api/mcp-chat' @@ -452,6 +476,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + CapabilityDemoRoute: typeof CapabilityDemoRoute GenerationHooksRoute: typeof GenerationHooksRoute ImageGenRoute: typeof ImageGenRoute ImageToolReproRoute: typeof ImageToolReproRoute @@ -460,6 +485,7 @@ export interface RootRouteChildren { RealtimeRoute: typeof RealtimeRoute ServerFnChatRoute: typeof ServerFnChatRoute ThreadsRoute: typeof ThreadsRoute + ApiCapabilityDemoRoute: typeof ApiCapabilityDemoRoute ApiImageGenRoute: typeof ApiImageGenRoute ApiImageToolReproRoute: typeof ApiImageToolReproRoute ApiMcpChatRoute: typeof ApiMcpChatRoute @@ -546,6 +572,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof GenerationHooksRouteImport parentRoute: typeof rootRouteImport } + '/capability-demo': { + id: '/capability-demo' + path: '/capability-demo' + fullPath: '/capability-demo' + preLoaderRoute: typeof CapabilityDemoRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -693,6 +726,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiImageGenRouteImport parentRoute: typeof rootRouteImport } + '/api/capability-demo': { + id: '/api/capability-demo' + path: '/api/capability-demo' + fullPath: '/api/capability-demo' + preLoaderRoute: typeof ApiCapabilityDemoRouteImport + parentRoute: typeof rootRouteImport + } '/example/guitars/': { id: '/example/guitars/' path: '/example/guitars' @@ -740,6 +780,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + CapabilityDemoRoute: CapabilityDemoRoute, GenerationHooksRoute: GenerationHooksRoute, ImageGenRoute: ImageGenRoute, ImageToolReproRoute: ImageToolReproRoute, @@ -748,6 +789,7 @@ const rootRouteChildren: RootRouteChildren = { RealtimeRoute: RealtimeRoute, ServerFnChatRoute: ServerFnChatRoute, ThreadsRoute: ThreadsRoute, + ApiCapabilityDemoRoute: ApiCapabilityDemoRoute, ApiImageGenRoute: ApiImageGenRoute, ApiImageToolReproRoute: ApiImageToolReproRoute, ApiMcpChatRoute: ApiMcpChatRoute, diff --git a/examples/ts-react-chat/src/routes/api.capability-demo.ts b/examples/ts-react-chat/src/routes/api.capability-demo.ts new file mode 100644 index 000000000..ad7e0646c --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.capability-demo.ts @@ -0,0 +1,151 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + EventType, + chat, + chatParamsFromRequestBody, + createCapability, + createChatMiddleware, + createChatOptions, + defineChatMiddleware, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import type { ChatMiddleware, StreamChunk } from '@tanstack/ai' + +/** + * Capability middleware demo. + * + * Shows the new capability primitives end-to-end: + * - `createCapability()('name')` returns a `[get, provide]` tuple that is + * also its own identity for `requires`/`provides`. + * - a PROVIDER middleware supplies the value in `setup` (runs first), + * - a CONSUMER middleware reads it in `onConfig` and injects a system prompt, + * - `chat()` validates coverage. With the provider present we wire the + * middleware through the type-safe `createChatMiddleware().use().build()` + * builder (compile-time order check). With it absent we pass a plain + * middleware array, so `chat()` throws the runtime validation error. + */ + +// The capability: a "persona" string the assistant should adopt. The value +// type is explicit; the name literal is inferred from the argument. +const personaCapability = createCapability()('demo-persona') +const [getPersona, providePersona] = personaCapability + +function readForwardedString(value: unknown, fallback: string): string { + return typeof value === 'string' && value.trim() ? value : fallback +} + +export const Route = createFileRoute('/api/capability-demo')({ + server: { + handlers: { + POST: async ({ request }) => { + const abortController = new AbortController() + + let params + try { + params = await chatParamsFromRequestBody(await request.json()) + } catch (error) { + return new Response( + error instanceof Error ? error.message : 'Bad request', + { status: 400 }, + ) + } + + const persona = readForwardedString( + params.forwardedProps.capabilityValue, + 'a cheerful pirate captain', + ) + // When false, the provider middleware is omitted so the consumer's + // `requires: [personaCapability]` is unmet — demonstrating the runtime + // validation error. + const provideCapability = + params.forwardedProps.provideCapability !== false + + // PROVIDER — supplies the persona capability in `setup` (runs first, + // before `onConfig`, so later middleware can consume it). + const personaProvider = defineChatMiddleware({ + name: 'persona-provider', + provides: [personaCapability], + setup(ctx) { + providePersona(ctx, persona) + }, + }) + + // CONSUMER — reads the persona in `onConfig` and injects a system + // prompt. `getPersona(ctx)` is typed `string` (throws if unprovided). + const personaConsumer = defineChatMiddleware({ + name: 'persona-consumer', + requires: [personaCapability], + onConfig(ctx, config) { + const activePersona = getPersona(ctx) + return { + systemPrompts: [ + ...config.systemPrompts, + `You are ${activePersona}. Stay fully in character for the entire reply, and open by introducing yourself in that persona.`, + ], + } + }, + }) + + try { + const options = createChatOptions({ + adapter: openaiText('gpt-5.5'), + }) + + if (provideCapability) { + // Happy path: the order-aware builder enforces provider-before- + // consumer at COMPILE time and accumulates the provided set. + const stream = chat({ + ...options, + middleware: createChatMiddleware() + .use(personaProvider) + .use(personaConsumer) + .use({ + name: 'logging-middleware', + onConfig(_ctx, config) { + console.log('onConfig', { config }) + return config + }, + }) + .build(), + messages: params.messages, + threadId: params.threadId, + runId: params.runId, + abortController, + }) + return toServerSentEventsResponse(stream, { abortController }) + } + + // Omit path: a plain `ChatMiddleware[]` (widened, so the compile-time + // coverage check can't prove the gap) — the unmet `requires` is caught + // by `chat()`'s RUNTIME validation, which throws and is surfaced below. + const middleware: Array = [personaConsumer] + const stream = chat({ + ...options, + middleware, + messages: params.messages, + threadId: params.threadId, + runId: params.runId, + abortController, + }) + return toServerSentEventsResponse(stream, { abortController }) + } catch (error) { + // Surface the message (capability validation error when the provider + // is omitted, or a missing-API-key error) as an SSE RUN_ERROR so the + // client shows the real text instead of a generic "HTTP 500". + const message = + error instanceof Error ? error.message : 'An error occurred' + const errorStream = (async function* (): AsyncGenerator { + yield { + type: EventType.RUN_ERROR, + message, + timestamp: Date.now(), + error: { message }, + } + })() + return toServerSentEventsResponse(errorStream, { abortController }) + } + }, + }, + }, +}) diff --git a/examples/ts-react-chat/src/routes/capability-demo.tsx b/examples/ts-react-chat/src/routes/capability-demo.tsx new file mode 100644 index 000000000..7625084e6 --- /dev/null +++ b/examples/ts-react-chat/src/routes/capability-demo.tsx @@ -0,0 +1,228 @@ +import { useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { Send, Sparkles, Square } from 'lucide-react' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import type { UIMessage } from '@tanstack/ai-react' + +interface SessionConfig { + persona: string + provideCapability: boolean + /** Bumped on every Apply so the chat surface remounts with a fresh session. */ + seq: number +} + +function Messages({ messages }: { messages: Array }) { + const visible = messages.filter((m) => + m.parts.some((p) => p.type === 'text' && p.content.trim()), + ) + + if (!visible.length) { + return ( +
+

+ Send a message — the assistant should answer in the persona supplied + by the capability. +

+
+ ) + } + + return ( +
+ {visible.map((message) => ( +
+
+
+ {message.role === 'assistant' ? 'AI' : 'U'} +
+
+ {message.parts.map((part, index) => + part.type === 'text' && part.content ? ( +

+ {part.content} +

+ ) : null, + )} +
+
+
+ ))} +
+ ) +} + +function ChatSurface({ config }: { config: SessionConfig }) { + const { messages, sendMessage, isLoading, error, stop } = useChat({ + threadId: `capability-demo-${config.seq}`, + connection: fetchServerSentEvents('/api/capability-demo'), + // Forwarded to the route as `forwardedProps`: the provider middleware + // provides `capabilityValue`; `provideCapability=false` omits the provider + // so chat() throws the capability validation error. + body: { + capabilityValue: config.persona, + provideCapability: config.provideCapability, + }, + }) + + const [input, setInput] = useState('') + + const handleSend = () => { + if (!input.trim()) return + sendMessage(input.trim()) + setInput('') + } + + return ( +
+ + + {error && ( +
+ chat() error: {error.message} +
+ )} + +
+
+ {isLoading && ( +
+ +
+ )} +
+