diff --git a/docs/advanced/middleware.md b/docs/advanced/middleware.md index d76e95c8f..7f7d45bf6 100644 --- a/docs/advanced/middleware.md +++ b/docs/advanced/middleware.md @@ -396,9 +396,50 @@ Every hook receives a `ChatMiddlewareContext` as its first argument. It provides | `chunkIndex` | `number` | Running count of chunks yielded | | `signal` | `AbortSignal \| undefined` | External abort signal | | `abort(reason?)` | `function` | Abort the run from within middleware | -| `context` | `unknown` | User-provided context value | +| `context` | `TContext` | User-provided runtime context value | | `defer(promise)` | `function` | Register a non-blocking side-effect | +## Typed Runtime Context + +`ChatMiddleware` accepts a context generic. This lets reusable middleware declared outside `chat()` access the same typed runtime context as your tools. + +```typescript +import { chat, type ChatMiddleware } from "@tanstack/ai"; + +type AppContext = { + userId: string; + audit: { + write(event: { userId: string; requestId: string }): Promise; + }; +}; + +export const auditMiddleware: ChatMiddleware = { + name: "audit", + onStart(ctx) { + ctx.defer( + ctx.context.audit.write({ + userId: ctx.context.userId, + requestId: ctx.requestId, + }) + ); + }, +}; + +chat({ + adapter, + messages, + middleware: [auditMiddleware], + context: { + userId: session.user.id, + audit, + }, +}); +``` + +When typed middleware or typed tools are present, `chat()` checks that the provided `context` matches the required shape. Existing middleware typed as plain `ChatMiddleware` still works; its `ctx.context` remains `unknown` and does not force a `context` option. + +Runtime context is process-local application state. It is separate from AG-UI `RunAgentInput.context`, which is protocol metadata parsed by `chatParamsFromRequest`. See [Runtime Context](./runtime-context) for server, client, and client-to-server handoff patterns. + ### Aborting from Middleware Call `ctx.abort()` to gracefully stop the run. This triggers the `onAbort` terminal hook: diff --git a/docs/advanced/runtime-context.md b/docs/advanced/runtime-context.md new file mode 100644 index 000000000..415f37aad --- /dev/null +++ b/docs/advanced/runtime-context.md @@ -0,0 +1,280 @@ +--- +title: Runtime Context +id: runtime-context +order: 2 +description: "Pass typed runtime dependencies to TanStack AI tools and middleware without serializing them to the model or AG-UI protocol context." +keywords: + - tanstack ai + - runtime context + - typed context + - tools context + - middleware context + - ag-ui context +--- + +Runtime context is application state you pass to tool implementations and middleware. Use it for request-scoped or client-local dependencies such as authenticated users, database clients, tenancy, feature flags, audit loggers, or browser services. + +Runtime context is not prompt context and is not the AG-UI `RunAgentInput.context` field. It is never sent to the model automatically. + +## How Type Safety Works + +Runtime context is checked from the point of view of the code that consumes it. Tools and middleware declare the context shape they need, and `chat()`, `ChatClient`, and framework hooks check that the `context` value you pass satisfies those requirements. + +The source of truth is: + +- `toolDefinition(...).server(...)` for server tools. +- `toolDefinition(...).client(...)` for client tools. +- `ChatMiddleware` for middleware. + +This means the context value is the implementation detail you provide at runtime, while tools and middleware are the contract. TanStack AI infers the required context from every typed tool and middleware in the call, merges those requirements, and checks your `context` option against the result. + +```typescript +import { chat, toolDefinition, type ChatMiddleware } from "@tanstack/ai"; + +type UserContext = { + userId: string; +}; + +type TenantContext = { + tenantId: string; +}; + +const currentUserTool = toolDefinition({ + name: "current_user", + description: "Read the current user", +}).server((_input, ctx) => { + return { userId: ctx.context.userId }; +}); + +const tenantMiddleware: ChatMiddleware = { + name: "tenant", + onStart(ctx) { + console.log(ctx.context.tenantId); + }, +}; + +chat({ + adapter, + messages, + tools: [currentUserTool], + middleware: [tenantMiddleware], + context: { + userId: "user_123", + tenantId: "tenant_456", + }, +}); +``` + +In this example, the tool requires `UserContext` and the middleware requires `TenantContext`, so the `context` value must satisfy both. If you remove `tenantId`, TypeScript reports an error because `tenantMiddleware` declared that it needs it. + +This is intentional. The `context` object alone should not decide what tools and middleware are allowed to read. The consumers define their requirements, and the call site proves that it supplied them. Untyped tools and middleware still work; they receive `unknown` context and do not force a `context` option. + +This inference also works when reusable tools or middleware are declared outside the `chat()` call and passed in as arrays. A consumer can opt into optional runtime context by declaring `TContext | undefined`; then the `context` option can be omitted when all typed consumers accept `undefined`. If a context value is provided, it still has to satisfy every typed consumer. + +The same rule applies on the client: + +```typescript +import { clientTools } from "@tanstack/ai-client"; +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; +import { toolDefinition } from "@tanstack/ai"; + +type ClientRuntimeContext = { + currentTabId: string; +}; + +const inspectClientContext = toolDefinition({ + name: "inspect_client_context", + description: "Inspect local browser context", +}).client((_input, ctx) => { + return { + tabId: ctx.context.currentTabId, + mode: ctx.context.mode, + }; +}); + +useChat({ + connection: fetchServerSentEvents("/api/chat"), + tools: clientTools(inspectClientContext), + context: { + currentTabId: "settings", + mode: "debug", + }, +}); +``` + +Because the client tool declares `ClientRuntimeContext & { mode: "debug" }`, `useChat()` requires a `context` value with both `currentTabId` and the literal `mode: "debug"`. + +## Server Runtime Context + +Define the context type once, use it in server tools and middleware, then pass the matching `context` value to `chat()`. + +```typescript +import { + chat, + toServerSentEventsResponse, + toolDefinition, + type ChatMiddleware, +} from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; +import { z } from "zod"; + +type AppContext = { + userId: string; + tenantId: string; + db: { + notes: { + findMany(args: { userId: string; tenantId: string }): Promise>; + }; + }; +}; + +const listNotes = toolDefinition({ + name: "list_notes", + description: "List notes for the current user", + inputSchema: z.object({}), + outputSchema: z.array(z.object({ title: z.string() })), +}).server(async (_input, ctx) => { + return ctx.context.db.notes.findMany({ + userId: ctx.context.userId, + tenantId: ctx.context.tenantId, + }); +}); + +const auditMiddleware: ChatMiddleware = { + name: "audit", + onStart(ctx) { + console.log("chat started", { + requestId: ctx.requestId, + userId: ctx.context.userId, + tenantId: ctx.context.tenantId, + }); + }, +}; + +export async function POST(request: Request) { + const { messages } = await request.json(); + const user = await requireUser(request); + + const stream = chat({ + adapter: openaiText("gpt-4o"), + messages, + tools: [listNotes], + middleware: [auditMiddleware], + context: { + userId: user.id, + tenantId: user.tenantId, + db, + }, + }); + + return toServerSentEventsResponse(stream); +} +``` + +When any tool or middleware in a `chat()` call declares a concrete context type, TypeScript checks the `context` value against that type. Existing untyped tools and middleware continue to work; their `ctx.context` type remains `unknown`. + +## Client Runtime Context + +Client runtime context is local to `ChatClient` and framework hooks. It is passed to client tool implementations and is not serialized to the server. + +```typescript +import { createChatClientOptions, clientTools } from "@tanstack/ai-client"; +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; +import { toolDefinition } from "@tanstack/ai"; + +type ClientContext = { + currentTabId: string; + toast(message: string): void; +}; + +const notifyUser = toolDefinition({ + name: "notify_user", + description: "Show a notification in the current browser tab", +}).client((_input, ctx) => { + ctx.context.toast(`Updated tab ${ctx.context.currentTabId}`); + return { ok: true }; +}); + +const chatOptions = createChatClientOptions({ + connection: fetchServerSentEvents("/api/chat"), + tools: clientTools(notifyUser), + context: { + currentTabId: "settings", + toast: (message) => window.alert(message), + }, +}); + +const chat = useChat(chatOptions); +``` + +Use client context for local dependencies only. Do not put values there expecting the server to receive them. + +## Client-to-Server Handoff + +To send serializable client data to the server, use `forwardedProps`, validate it in your route, and explicitly map it into the server runtime context. + +```typescript +// Client +useChat({ + connection: fetchServerSentEvents("/api/chat"), + forwardedProps: { + tenantId: selectedTenantId, + }, + context: clientRuntimeContext, +}); +``` + +```typescript +// Server +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; + +type AppContext = { + userId: string; + tenantId: string; +}; + +export async function POST(request: Request) { + const params = await chatParamsFromRequest(request); + const user = await requireUser(request); + + const tenantId = + typeof params.forwardedProps.tenantId === "string" + ? params.forwardedProps.tenantId + : user.defaultTenantId; + + const stream = chat({ + adapter, + messages: params.messages, + tools, + context: { + userId: user.id, + tenantId, + } satisfies AppContext, + }); + + return toServerSentEventsResponse(stream); +} +``` + +Treat `forwardedProps` as client-controlled input. Validate and allowlist every field before using it to build server runtime context. + +## AG-UI Context + +AG-UI also defines `RunAgentInput.context`, usually as protocol-level context entries for interoperable agents. TanStack AI surfaces that field through `chatParamsFromRequest`, but it is separate from `chat({ context })`. + +TanStack AI does not automatically copy AG-UI `params.context` into runtime context. If you want to use AG-UI context values, validate and map them yourself: + +```typescript +const params = await chatParamsFromRequest(request); + +const stream = chat({ + adapter, + messages: params.messages, + tools, + context: buildRuntimeContextFrom(params.context), +}); +``` diff --git a/docs/api/ai-client.md b/docs/api/ai-client.md index bf9f6eda6..68c09f991 100644 --- a/docs/api/ai-client.md +++ b/docs/api/ai-client.md @@ -49,6 +49,7 @@ const client = new ChatClient({ - `threadId?` - Thread ID for AG-UI run correlation. Persists across sends; auto-generated if omitted - `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field - `body?` - **Deprecated.** Use `forwardedProps` instead. Still works — values are merged into `forwardedProps` on the wire and mirrored under the legacy `data` field for backward compatibility +- `context?` - Typed client-local runtime context passed to client tool implementations. This value is not serialized to the server - `onResponse?` - Callback when response is received - `onChunk?` - Callback when stream chunk is received - `onFinish?` - Callback when response finishes @@ -248,6 +249,28 @@ const chatOptions = createChatClientOptions({ type ChatMessages = InferChatMessages; ``` +`createChatClientOptions` also preserves typed client runtime context: + +```typescript +type ClientContext = { + activeProjectId: string; +}; + +const tool = projectTool.client((input, ctx) => { + return runProjectAction(ctx.context.activeProjectId, input); +}); + +const chatOptions = createChatClientOptions({ + connection: fetchServerSentEvents("/api/chat"), + tools: clientTools(tool), + context: { + activeProjectId: "project_123", + }, +}); +``` + +Client runtime context is local to the client instance. Use `forwardedProps` for explicit client-to-server handoff of serializable values, then validate and map those values into server `chat({ context })`. + ## Types ### `UIMessage` diff --git a/docs/api/ai-preact.md b/docs/api/ai-preact.md index 17dd706eb..a3c2390f7 100644 --- a/docs/api/ai-preact.md +++ b/docs/api/ai-preact.md @@ -68,6 +68,7 @@ Extends `ChatClientOptions` from `@tanstack/ai-client`: - `threadId?` - Thread ID for AG-UI run correlation. Persists across sends; auto-generated if omitted - `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field (e.g., `{ provider: 'openai', model: 'gpt-4o' }`) - `body?` - **Deprecated.** Use `forwardedProps` instead. Still works for backward compatibility; values are merged into `forwardedProps` on the wire +- `context?` - Typed client-local runtime context passed to client tool implementations. This value is not serialized to the server - `onResponse?` - Callback when response is received - `onChunk?` - Callback when stream chunk is received - `onFinish?` - Callback when response finishes @@ -309,7 +310,7 @@ Re-exported from `@tanstack/ai-client`: - `ThinkingPart` - Thinking content part - `ToolCallPart` - Tool call part (discriminated union) - `ToolResultPart` - Tool result part -- `ChatClientOptions` - Chat client options +- `ChatClientOptions` - Chat client options with typed client runtime context - `ConnectionAdapter` - Connection adapter interface - `InferChatMessages` - Extract message type from options diff --git a/docs/api/ai-react.md b/docs/api/ai-react.md index ac10e1667..65d0b7ac7 100644 --- a/docs/api/ai-react.md +++ b/docs/api/ai-react.md @@ -68,6 +68,7 @@ Extends `ChatClientOptions` from `@tanstack/ai-client`: - `threadId?` - Thread ID for AG-UI run correlation. Persists across sends; auto-generated if omitted - `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field (e.g., `{ provider: 'openai', model: 'gpt-4o' }`) - `body?` - **Deprecated.** Use `forwardedProps` instead. Still works for backward compatibility; values are merged into `forwardedProps` on the wire +- `context?` - Typed client-local runtime context passed to client tool implementations. This value is not serialized to the server - `onResponse?` - Callback when response is received - `onChunk?` - Callback when stream chunk is received - `onFinish?` - Callback when response finishes @@ -309,7 +310,7 @@ Re-exported from `@tanstack/ai-client`: - `ThinkingPart` - Thinking content part - `ToolCallPart` - Tool call part (discriminated union) - `ToolResultPart` - Tool result part -- `ChatClientOptions` - Chat client options +- `ChatClientOptions` - Chat client options with typed client runtime context - `ConnectionAdapter` - Connection adapter interface - `InferChatMessages` - Extract message type from options diff --git a/docs/api/ai-solid.md b/docs/api/ai-solid.md index 8bf22bf7a..6365ed5a2 100644 --- a/docs/api/ai-solid.md +++ b/docs/api/ai-solid.md @@ -69,6 +69,7 @@ Extends `ChatClientOptions` from `@tanstack/ai-client`: - `threadId?` - Thread ID for AG-UI run correlation. Persists across sends; auto-generated if omitted - `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field (e.g., `{ provider: 'openai', model: 'gpt-4o' }`) - `body?` - **Deprecated.** Use `forwardedProps` instead. Still works for backward compatibility; values are merged into `forwardedProps` on the wire +- `context?` - Typed client-local runtime context passed to client tool implementations. This value is not serialized to the server - `onResponse?` - Callback when response is received - `onChunk?` - Callback when stream chunk is received - `onFinish?` - Callback when response finishes @@ -324,7 +325,7 @@ Re-exported from `@tanstack/ai-client`: - `ThinkingPart` - Thinking content part - `ToolCallPart` - Tool call part (discriminated union) - `ToolResultPart` - Tool result part -- `ChatClientOptions` - Chat client options +- `ChatClientOptions` - Chat client options with typed client runtime context - `ConnectionAdapter` - Connection adapter interface - `InferChatMessages` - Extract message type from options - `ChatRequestBody` - Request body type diff --git a/docs/api/ai-svelte.md b/docs/api/ai-svelte.md index 770e3d90f..e4dbb2865 100644 --- a/docs/api/ai-svelte.md +++ b/docs/api/ai-svelte.md @@ -64,6 +64,7 @@ Extends `ChatClientOptions` from `@tanstack/ai-client` (minus internal state cal - `threadId?` - Thread ID for AG-UI run correlation. Persists across sends; auto-generated if omitted - `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field (e.g., `{ provider: 'openai', model: 'gpt-4o' }`) - `body?` - **Deprecated.** Use `forwardedProps` instead. Still works for backward compatibility; values are merged into `forwardedProps` on the wire +- `context?` - Typed client-local runtime context passed to client tool implementations. This value is not serialized to the server - `live?` - Enable live subscription mode (subscribes on creation) - `onResponse?` - Callback when response is received - `onChunk?` - Callback when stream chunk is received @@ -77,7 +78,7 @@ Extends `ChatClientOptions` from `@tanstack/ai-client` (minus internal state cal ### Returns ```typescript -interface CreateChatReturn { +interface CreateChatReturn { readonly messages: UIMessage[]; sendMessage: (content: string | MultimodalContent) => Promise; append: (message: ModelMessage | UIMessage) => Promise; @@ -105,6 +106,7 @@ interface CreateChatReturn { /** @deprecated Use `updateForwardedProps` instead. */ updateBody: (body: Record) => void; updateForwardedProps: (forwardedProps: Record) => void; + updateContext: (context: TContext) => void; } ``` @@ -339,7 +341,7 @@ Re-exported from `@tanstack/ai-client`: - `ThinkingPart` - Thinking content part - `ToolCallPart` - Tool call part (discriminated union) - `ToolResultPart` - Tool result part -- `ChatClientOptions` - Chat client options +- `ChatClientOptions` - Chat client options with typed client runtime context - `ConnectionAdapter` - Connection adapter interface - `InferChatMessages` - Extract message type from options - `ChatRequestBody` - Request body type diff --git a/docs/api/ai-vue.md b/docs/api/ai-vue.md index d1831b6c1..34d7b5048 100644 --- a/docs/api/ai-vue.md +++ b/docs/api/ai-vue.md @@ -64,6 +64,7 @@ Extends `ChatClientOptions` from `@tanstack/ai-client` (minus internal state cal - `threadId?` - Thread ID for AG-UI run correlation. Persists across sends; auto-generated if omitted - `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field (reactive -- changes are synced automatically via `watch`) - `body?` - **Deprecated.** Use `forwardedProps` instead. Still works for backward compatibility; values are merged into `forwardedProps` on the wire (reactive) +- `context?` - Typed client-local runtime context passed to client tool implementations (reactive). This value is not serialized to the server - `live?` - Enable live subscription mode (auto-subscribes/unsubscribes) - `onResponse?` - Callback when response is received - `onChunk?` - Callback when stream chunk is received @@ -340,7 +341,7 @@ Re-exported from `@tanstack/ai-client`: - `ThinkingPart` - Thinking content part - `ToolCallPart` - Tool call part (discriminated union) - `ToolResultPart` - Tool result part -- `ChatClientOptions` - Chat client options +- `ChatClientOptions` - Chat client options with typed client runtime context - `ConnectionAdapter` - Connection adapter interface - `InferChatMessages` - Extract message type from options - `ChatRequestBody` - Request body type diff --git a/docs/api/ai.md b/docs/api/ai.md index 0f50d74cd..3175dc4f8 100644 --- a/docs/api/ai.md +++ b/docs/api/ai.md @@ -43,6 +43,7 @@ const stream = chat({ - `adapter` - An AI adapter instance with model (e.g., `openaiText('gpt-5.2')`, `anthropicText('claude-sonnet-4-5')`) - `messages` - Array of chat messages. Accepts mixed `UIMessage | ModelMessage` arrays — internal conversion handles AG-UI fan-out dedup, drops `reasoning`/`activity`, and collapses `developer` → `system` - `tools?` - Array of tools for function calling +- `context?` - Typed runtime context passed to server tools and middleware. If a tool or middleware declares a concrete context type, `chat()` requires a compatible value here - `systemPrompts?` - System prompts to prepend to messages - `agentLoopStrategy?` - Strategy for agent loops (default: `maxIterations(5)`) - `abortController?` - AbortController for cancellation @@ -130,6 +131,29 @@ chat({ }); ``` +Tools can declare typed runtime context for request-scoped dependencies: + +```typescript +type AppContext = { + userId: string; + db: { users: { findName(id: string): Promise } }; +}; + +const currentUser = toolDefinition({ + name: "current_user", + description: "Get the current user", +}).server(async (_input, ctx) => { + return { name: await ctx.context.db.users.findName(ctx.context.userId) }; +}); + +chat({ + adapter: openaiText("gpt-5.2"), + messages, + tools: [currentUser], + context: { userId: session.user.id, db }, +}); +``` + ### Parameters - `name` - Tool name (must be unique) @@ -221,6 +245,8 @@ export async function POST(req: Request) { A promise resolving to `{ messages, threadId, runId, parentRunId?, tools, forwardedProps, state, context }`. +The returned `context` is the AG-UI protocol `RunAgentInput.context` field. It is not the same as TanStack AI runtime `chat({ context })`; validate and map it explicitly if you want those values available to tools or middleware. + > **Framework note.** Next.js Route Handlers, SvelteKit, Hono, and raw Node do not auto-handle thrown `Response` objects. In those, wrap with try/catch or use `chatParamsFromRequestBody(await req.json())` directly. ## `chatParamsFromRequestBody(body)` @@ -329,18 +355,78 @@ Stream chunks represent different types of data in the stream: ### `Tool` ```typescript -interface Tool { - type: "function"; - function: { - name: string; - description: string; - parameters: Record; - }; - execute?: (args: any) => Promise | any; +interface Tool { + name: string; + description: string; + inputSchema?: SchemaInput; + outputSchema?: SchemaInput; + execute?: ( + args: any, + context?: ToolExecutionContext + ) => Promise | any; needsApproval?: boolean; + lazy?: boolean; + metadata?: Record; +} +``` + +### `ToolExecutionContext` + +```typescript +type ToolExecutionContext = { + toolCallId?: string; + emitCustomEvent: (eventName: string, value: Record) => void; +} & (unknown extends TContext ? { context?: TContext } : { context: TContext }); +``` + +`context` is the runtime value from `chat({ context })` for server tools, or from `ChatClient` / framework hook options for client tools. It is required when a tool declares a concrete `TContext` and optional for untyped tools where the context type is `unknown`. + +### `ChatMiddleware` + +```typescript +interface ChatMiddleware { + name?: string; + onStart?: (ctx: ChatMiddlewareContext) => void | Promise; + onChunk?: ( + ctx: ChatMiddlewareContext, + chunk: StreamChunk + ) => void | StreamChunk | StreamChunk[] | null | Promise; + onBeforeToolCall?: ( + ctx: ChatMiddlewareContext, + hookCtx: ToolCallHookContext + ) => BeforeToolCallDecision | Promise; + onAfterToolCall?: ( + ctx: ChatMiddlewareContext, + info: AfterToolCallInfo + ) => void | Promise; + onFinish?: ( + ctx: ChatMiddlewareContext, + info: FinishInfo + ) => void | Promise; + onAbort?: ( + ctx: ChatMiddlewareContext, + info: AbortInfo + ) => void | Promise; + onError?: ( + ctx: ChatMiddlewareContext, + info: ErrorInfo + ) => void | Promise; +} + +interface ChatMiddlewareContext { + requestId: string; + streamId: string; + threadId: string; + phase: ChatMiddlewarePhase; + iteration: number; + context: TContext; + abort(reason?: string): void; + defer(promise: Promise): void; } ``` +See [Runtime Context](../advanced/runtime-context) for the recommended context patterns. + ## Usage Examples ```typescript diff --git a/docs/config.json b/docs/config.json index 370c839a5..5fbcef3e3 100644 --- a/docs/config.json +++ b/docs/config.json @@ -192,6 +192,10 @@ "label": "Middleware", "to": "advanced/middleware" }, + { + "label": "Runtime Context", + "to": "advanced/runtime-context" + }, { "label": "Debug Logging", "to": "advanced/debug-logging" diff --git a/docs/migration/ag-ui-compliance.md b/docs/migration/ag-ui-compliance.md index 438b297b3..f99679fc5 100644 --- a/docs/migration/ag-ui-compliance.md +++ b/docs/migration/ag-ui-compliance.md @@ -220,6 +220,31 @@ chat({ }) ``` +### Mapping forwarded values into runtime context + +TanStack AI's `chat({ context })` is typed runtime context for tools and middleware. It is separate from AG-UI `RunAgentInput.context` and is not populated automatically from protocol fields. + +If a client value should become available to server tools or middleware, validate it from `forwardedProps` and build the runtime context explicitly: + +```ts +const params = await chatParamsFromRequest(req) + +const tenantId = + typeof params.forwardedProps.tenantId === 'string' + ? params.forwardedProps.tenantId + : defaultTenantId + +const stream = chat({ + adapter: openaiText('gpt-4o'), + messages: params.messages, + tools: serverTools, + context: { + userId: session.user.id, + tenantId, + }, +}) +``` + ## Client-side: nothing required, one rename recommended `useChat` and the connection adapters (`fetchServerSentEvents`, `fetchHttpStream`) handle the new wire format internally. Existing `UIMessage` state is unchanged. `clientTools(...)` declarations are now automatically advertised to the server in the request payload. @@ -291,5 +316,5 @@ Pure AG-UI `RunAgentInput` payloads (no TanStack `parts` field) work end-to-end: ## Out of scope (existing behavior preserved) - **Reasoning replay to LLM providers.** TanStack still drops `ThinkingPart` at the `UIMessage`→`ModelMessage` boundary (pre-existing behavior). Providers like Anthropic that require thinking blocks to be replayed for extended thinking continuation remain a separate concern, tracked outside this migration. -- **AG-UI `state` and `context` fields.** Surfaced on `chatParamsFromRequestBody`'s return value but not yet wired into `chat()`. They're available for your endpoint to inspect/forward, but the runtime ignores them. +- **AG-UI `state` and `context` fields.** Surfaced on `chatParamsFromRequestBody`'s return value but not automatically wired into `chat()`. They are protocol-level fields available for your endpoint to inspect/forward. TanStack AI's typed runtime context is the separate `chat({ context })` option; validate and map AG-UI values into it yourself if you want tools or middleware to read them. - **PHP and Python server packages.** No `chatParamsFromRequestBody` parity yet. Their examples temporarily lag on the old shape until the matching helpers ship. diff --git a/docs/migration/migration-from-vercel-ai.md b/docs/migration/migration-from-vercel-ai.md index f83cd6e0b..a17bb2a1c 100644 --- a/docs/migration/migration-from-vercel-ai.md +++ b/docs/migration/migration-from-vercel-ai.md @@ -146,7 +146,7 @@ Options accepted by `streamText` as of AI SDK v6, and where each lives in TanSta | `stopWhen: [a, b]` | `agentLoopStrategy: combineStrategies([a, b])` | Multiple conditions, AND semantics | | `prepareStep` | `middleware` with `onConfig`/`onIteration` | See [Middleware](#middleware) | | `experimental_transform` | `middleware.onChunk` (transform / drop / expand chunks) | See [Middleware](#middleware) | -| `experimental_context` | `context` (root-level) | Passed through to every middleware hook | +| `experimental_context` | `context` (root-level) | Typed runtime context passed to middleware hooks and tool implementations | | `experimental_telemetry` | `middleware` + your tracer of choice | See [Observability](#observability-logging-metrics-tracing) | | `experimental_repairToolCall` | `middleware.onBeforeToolCall` | Return transformed args or a decision | | `experimental_download` | Preprocess your `messages` before calling `chat()` | No built-in hook | @@ -856,8 +856,10 @@ const result = streamText({ model: wrapped, messages }) import { chat, type ChatMiddleware } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' -const loggingMiddleware: ChatMiddleware = { - onStart: (ctx) => console.log('start', { requestId: ctx.requestId, model: ctx.model }), +type AppContext = { userId: string } + +const loggingMiddleware: ChatMiddleware = { + onStart: (ctx) => console.log('start', { requestId: ctx.requestId, userId: ctx.context.userId }), onConfig: (ctx, config) => console.log('config', config), onChunk: (ctx, chunk) => { /* observe or transform; return null to drop */ }, onUsage: (ctx, usage) => console.log('usage', usage), @@ -869,7 +871,7 @@ const stream = chat({ adapter: openaiText('gpt-4o'), messages, middleware: [loggingMiddleware], - context: { userId: 'u_123' }, // passed to every hook as ctx.context + context: { userId: 'u_123' }, // passed to every hook as typed ctx.context }) ``` @@ -891,7 +893,7 @@ Each middleware is a plain object. Every hook is optional, so pick what you need | `onAbort(ctx, info)` | Run aborted (terminal) | — | | `onError(ctx, info)` | Unhandled error (terminal) | — | -`ctx` carries `requestId`, `streamId`, `conversationId`, `iteration`, `model`, `provider`, `systemPrompts`, `toolNames`, `messages`, `context` (your opaque value), `abort(reason)`, `defer(promise)`, `createId(prefix)`, and more. See [the middleware guide](../advanced/middleware) for the full reference. +`ctx` carries `requestId`, `streamId`, `threadId`, `iteration`, `model`, `provider`, `systemPrompts`, `toolNames`, `messages`, `context` (your typed runtime value), `abort(reason)`, `defer(promise)`, `createId(prefix)`, and more. See [the middleware guide](../advanced/middleware) and [runtime context guide](../advanced/runtime-context) for the full reference. ### Built-in: tool-call cache diff --git a/docs/tools/client-tools.md b/docs/tools/client-tools.md index f65cde7b6..4da988f97 100644 --- a/docs/tools/client-tools.md +++ b/docs/tools/client-tools.md @@ -216,6 +216,42 @@ Client tools are **automatically executed** when the model calls them. No manual 4. Result is sent back to server 5. Conversation continues with the result +## Client Runtime Context + +Client tools can receive typed runtime context as their second argument. This context is local to the `ChatClient` or framework hook instance and is not serialized to the server. + +```typescript +import { createChatClientOptions, clientTools } from "@tanstack/ai-client"; +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; +import { toolDefinition } from "@tanstack/ai"; + +type ClientContext = { + activeProjectId: string; + toast(message: string): void; +}; + +const showToast = toolDefinition({ + name: "show_toast", + description: "Show a browser notification", +}).client((_input, ctx) => { + ctx.context.toast(`Project ${ctx.context.activeProjectId} updated`); + return { ok: true }; +}); + +const chatOptions = createChatClientOptions({ + connection: fetchServerSentEvents("/api/chat"), + tools: clientTools(showToast), + context: { + activeProjectId, + toast: (message) => toast(message), + }, +}); + +const chat = useChat(chatOptions); +``` + +Use `context` for local browser dependencies. If the server also needs a value from the client, send it with `forwardedProps`, validate it in your route, and map it into server `chat({ context })` explicitly. See [Runtime Context](../advanced/runtime-context) for the full pattern. + ## Type Safety Benefits The isomorphic architecture provides complete end-to-end type safety: @@ -335,4 +371,3 @@ chat({ adapter: openaiText('gpt-5.2'), messages: [], tools: [addToCartServer] }) - [How Tools Work](./tools) - Deep dive into the tool architecture - [Server Tools](./server-tools) - Learn about server-side tool execution - [Tool Approval Flow](./tool-approval) - Add approval workflows for sensitive operations - diff --git a/docs/tools/server-tools.md b/docs/tools/server-tools.md index 69bf1552d..5333ffbcd 100644 --- a/docs/tools/server-tools.md +++ b/docs/tools/server-tools.md @@ -166,6 +166,54 @@ export async function POST(request: Request) { } ``` +## Runtime Context + +Server tools can receive typed runtime context as their second argument. Use this for request-scoped dependencies like authenticated users, database clients, tenant IDs, or audit loggers. + +```typescript +import { chat, toolDefinition } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; +import { z } from "zod"; + +type AppContext = { + userId: string; + db: { + users: { + findUnique(args: { where: { id: string } }): Promise<{ name: string } | null>; + }; + }; +}; + +const getCurrentUser = toolDefinition({ + name: "get_current_user", + description: "Get the current authenticated user", + inputSchema: z.object({}), + outputSchema: z.object({ + name: z.string().nullable(), + }), +}).server(async (_input, ctx) => { + const user = await ctx.context.db.users.findUnique({ + where: { id: ctx.context.userId }, + }); + + return { name: user?.name ?? null }; +}); + +chat({ + adapter: openaiText("gpt-5.2"), + messages, + tools: [getCurrentUser], + context: { + userId: session.user.id, + db, + }, +}); +``` + +If a server tool declares a context generic, `chat()` requires a compatible `context` value. Untyped tools keep working and receive `unknown` context. + +For middleware and client-to-server handoff patterns, see [Runtime Context](../advanced/runtime-context). + ## Tool Organization Pattern For better organization, define tool schemas and implementations separately: diff --git a/examples/ts-code-mode-web/src/lib/execute-prompt.ts b/examples/ts-code-mode-web/src/lib/execute-prompt.ts index 43ba09765..993b1cd6b 100644 --- a/examples/ts-code-mode-web/src/lib/execute-prompt.ts +++ b/examples/ts-code-mode-web/src/lib/execute-prompt.ts @@ -62,5 +62,5 @@ export function executePrompt( getSkillBindings, }), }) - return tool.execute!({ prompt }) + return Promise.resolve(tool.execute!({ prompt })) } diff --git a/examples/ts-code-mode-web/src/lib/structured-output.ts b/examples/ts-code-mode-web/src/lib/structured-output.ts index f5abfa196..16bafbc00 100644 --- a/examples/ts-code-mode-web/src/lib/structured-output.ts +++ b/examples/ts-code-mode-web/src/lib/structured-output.ts @@ -4,7 +4,7 @@ import { createSkillsSystemPrompt, skillsToTools, } from '@tanstack/ai-code-mode-skills' -import type { AnyTextAdapter, SchemaInput, Tool } from '@tanstack/ai' +import type { AnyTextAdapter, AnyTool, SchemaInput } from '@tanstack/ai' import type { CodeModeTool, IsolateDriver } from '@tanstack/ai-code-mode' import type { SkillStorage, TrustStrategy } from '@tanstack/ai-code-mode-skills' @@ -13,7 +13,7 @@ export interface StructuredOutputOptions { prompt: string outputSchema: TSchema codeMode: { - tool: Tool + tool: AnyTool systemPrompt: string driver: IsolateDriver codeTools: Array @@ -24,7 +24,7 @@ export interface StructuredOutputOptions { timeout?: number memoryLimit?: number } - tools?: Array> + tools?: Array maxIterations?: number maxTokens?: number } @@ -66,7 +66,7 @@ RULES: - Do NOT produce conversational text. No greetings, no narration. Only tool calls and the final structured response. ${skillGuidance}` - let allTools: Array> = [codeMode.tool, ...tools] + let allTools: Array = [codeMode.tool, ...tools] const systemPrompts = [systemPrompt, codeMode.systemPrompt] if (skills) { diff --git a/examples/ts-react-chat/src/components/Header.tsx b/examples/ts-react-chat/src/components/Header.tsx index d0b02b740..642749b4d 100644 --- a/examples/ts-react-chat/src/components/Header.tsx +++ b/examples/ts-react-chat/src/components/Header.tsx @@ -2,6 +2,7 @@ import { Link } from '@tanstack/react-router' import { useState } from 'react' import { + BadgeCheck, Braces, FileAudio, FileText, @@ -198,6 +199,19 @@ export default function Header() { Guitar Demo + setIsOpen(false)} + className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-1" + activeProps={{ + className: + 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-1', + }} + > + + Runtime Context + + setIsOpen(false)} diff --git a/examples/ts-react-chat/src/lib/guitar-tools.ts b/examples/ts-react-chat/src/lib/guitar-tools.ts index b984f5ee0..0582658d3 100644 --- a/examples/ts-react-chat/src/lib/guitar-tools.ts +++ b/examples/ts-react-chat/src/lib/guitar-tools.ts @@ -2,6 +2,56 @@ import { toolDefinition } from '@tanstack/ai' import { z } from 'zod' import guitars from '@/data/example-guitars' +export const runtimeLoyaltyTiers = ['standard', 'gold', 'platinum'] as const +export const runtimePreferredStyles = [ + 'acoustic', + 'electric', + 'experimental', +] as const + +export type RuntimeLoyaltyTier = (typeof runtimeLoyaltyTiers)[number] +export type RuntimePreferredStyle = (typeof runtimePreferredStyles)[number] + +export type ClientRuntimeContext = { + userId: string + tenantId: string + loyaltyTier: RuntimeLoyaltyTier + preferredStyle: RuntimePreferredStyle +} + +export type ServerRuntimeContext = ClientRuntimeContext & { + requestSource: 'react-chat' + serverRegion: string +} + +const runtimeContextBaseOutputSchema = z.object({ + userId: z.string(), + tenantId: z.string(), + loyaltyTier: z.enum(runtimeLoyaltyTiers), + preferredStyle: z.enum(runtimePreferredStyles), +}) + +export const inspectClientRuntimeContextToolDef = toolDefinition({ + name: 'inspectClientRuntimeContext', + description: 'Read typed runtime context that is available in the browser.', + inputSchema: z.object({}), + outputSchema: runtimeContextBaseOutputSchema.extend({ + source: z.literal('client'), + }), +}) + +export const inspectServerRuntimeContextToolDef = toolDefinition({ + name: 'inspectServerRuntimeContext', + description: + 'Read typed runtime context that the server mapped from the chat request.', + inputSchema: z.object({}), + outputSchema: runtimeContextBaseOutputSchema.extend({ + requestSource: z.literal('react-chat'), + serverRegion: z.string(), + source: z.literal('server'), + }), +}) + // Tool definition for getting guitars export const getGuitarsToolDef = toolDefinition({ name: 'getGuitars', diff --git a/examples/ts-react-chat/src/routeTree.gen.ts b/examples/ts-react-chat/src/routeTree.gen.ts index d0966d88a..d4cb009af 100644 --- a/examples/ts-react-chat/src/routeTree.gen.ts +++ b/examples/ts-react-chat/src/routeTree.gen.ts @@ -21,6 +21,7 @@ import { Route as GenerationsStructuredChatRouteImport } from './routes/generati import { Route as GenerationsSpeechRouteImport } from './routes/generations.speech' import { Route as GenerationsImageRouteImport } from './routes/generations.image' import { Route as GenerationsAudioRouteImport } from './routes/generations.audio' +import { Route as ExampleRuntimeContextRouteImport } from './routes/example.runtime-context' import { Route as ApiTranscribeRouteImport } from './routes/api.transcribe' import { Route as ApiTanchatRouteImport } from './routes/api.tanchat' import { Route as ApiSummarizeRouteImport } from './routes/api.summarize' @@ -97,6 +98,11 @@ const GenerationsAudioRoute = GenerationsAudioRouteImport.update({ path: '/generations/audio', getParentRoute: () => rootRouteImport, } as any) +const ExampleRuntimeContextRoute = ExampleRuntimeContextRouteImport.update({ + id: '/example/runtime-context', + path: '/example/runtime-context', + getParentRoute: () => rootRouteImport, +} as any) const ApiTranscribeRoute = ApiTranscribeRouteImport.update({ id: '/api/transcribe', path: '/api/transcribe', @@ -169,6 +175,7 @@ export interface FileRoutesByFullPath { '/api/summarize': typeof ApiSummarizeRoute '/api/tanchat': typeof ApiTanchatRoute '/api/transcribe': typeof ApiTranscribeRoute + '/example/runtime-context': typeof ExampleRuntimeContextRoute '/generations/audio': typeof GenerationsAudioRoute '/generations/image': typeof GenerationsImageRoute '/generations/speech': typeof GenerationsSpeechRoute @@ -195,6 +202,7 @@ export interface FileRoutesByTo { '/api/summarize': typeof ApiSummarizeRoute '/api/tanchat': typeof ApiTanchatRoute '/api/transcribe': typeof ApiTranscribeRoute + '/example/runtime-context': typeof ExampleRuntimeContextRoute '/generations/audio': typeof GenerationsAudioRoute '/generations/image': typeof GenerationsImageRoute '/generations/speech': typeof GenerationsSpeechRoute @@ -222,6 +230,7 @@ export interface FileRoutesById { '/api/summarize': typeof ApiSummarizeRoute '/api/tanchat': typeof ApiTanchatRoute '/api/transcribe': typeof ApiTranscribeRoute + '/example/runtime-context': typeof ExampleRuntimeContextRoute '/generations/audio': typeof GenerationsAudioRoute '/generations/image': typeof GenerationsImageRoute '/generations/speech': typeof GenerationsSpeechRoute @@ -250,6 +259,7 @@ export interface FileRouteTypes { | '/api/summarize' | '/api/tanchat' | '/api/transcribe' + | '/example/runtime-context' | '/generations/audio' | '/generations/image' | '/generations/speech' @@ -276,6 +286,7 @@ export interface FileRouteTypes { | '/api/summarize' | '/api/tanchat' | '/api/transcribe' + | '/example/runtime-context' | '/generations/audio' | '/generations/image' | '/generations/speech' @@ -302,6 +313,7 @@ export interface FileRouteTypes { | '/api/summarize' | '/api/tanchat' | '/api/transcribe' + | '/example/runtime-context' | '/generations/audio' | '/generations/image' | '/generations/speech' @@ -329,6 +341,7 @@ export interface RootRouteChildren { ApiSummarizeRoute: typeof ApiSummarizeRoute ApiTanchatRoute: typeof ApiTanchatRoute ApiTranscribeRoute: typeof ApiTranscribeRoute + ExampleRuntimeContextRoute: typeof ExampleRuntimeContextRoute GenerationsAudioRoute: typeof GenerationsAudioRoute GenerationsImageRoute: typeof GenerationsImageRoute GenerationsSpeechRoute: typeof GenerationsSpeechRoute @@ -431,6 +444,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof GenerationsAudioRouteImport parentRoute: typeof rootRouteImport } + '/example/runtime-context': { + id: '/example/runtime-context' + path: '/example/runtime-context' + fullPath: '/example/runtime-context' + preLoaderRoute: typeof ExampleRuntimeContextRouteImport + parentRoute: typeof rootRouteImport + } '/api/transcribe': { id: '/api/transcribe' path: '/api/transcribe' @@ -529,6 +549,7 @@ const rootRouteChildren: RootRouteChildren = { ApiSummarizeRoute: ApiSummarizeRoute, ApiTanchatRoute: ApiTanchatRoute, ApiTranscribeRoute: ApiTranscribeRoute, + ExampleRuntimeContextRoute: ExampleRuntimeContextRoute, GenerationsAudioRoute: GenerationsAudioRoute, GenerationsImageRoute: GenerationsImageRoute, GenerationsSpeechRoute: GenerationsSpeechRoute, diff --git a/examples/ts-react-chat/src/routes/api.tanchat.ts b/examples/ts-react-chat/src/routes/api.tanchat.ts index 8349399c8..d7d3304f2 100644 --- a/examples/ts-react-chat/src/routes/api.tanchat.ts +++ b/examples/ts-react-chat/src/routes/api.tanchat.ts @@ -22,8 +22,12 @@ import { compareGuitars, getGuitars, getPersonalGuitarPreferenceToolDef, + inspectServerRuntimeContextToolDef, recommendGuitarToolDef, + runtimeLoyaltyTiers, + runtimePreferredStyles, searchGuitars, + type ServerRuntimeContext, } from '@/lib/guitar-tools' type Provider = @@ -50,6 +54,8 @@ IMPORTANT: - ONLY recommend guitars from our inventory (use getGuitars first) - The recommendGuitar tool has a buy button - this is how customers purchase - Do NOT describe the guitar yourself - let the recommendGuitar tool do it +- When the user asks about runtime context, call the inspectClientRuntimeContext + and/or inspectServerRuntimeContext tool named in the request. Example workflow: User: "I want an acoustic guitar" @@ -58,6 +64,20 @@ Step 2: Call recommendGuitar(id: "6") Step 3: Done - do NOT add any text after calling recommendGuitar ` +function isAllowedValue( + value: unknown, + allowedValues: ReadonlyArray, +): value is T { + return ( + typeof value === 'string' && + allowedValues.some((allowedValue) => allowedValue === value) + ) +} + +function readForwardedString(value: unknown, fallback: string) { + return typeof value === 'string' ? value : fallback +} + const addToCartToolServer = addToCartToolDef.server((args, context) => { context?.emitCustomEvent('tool:progress', { tool: 'addToCart', @@ -77,12 +97,28 @@ const addToCartToolServer = addToCartToolDef.server((args, context) => { } }) +const inspectServerRuntimeContextToolServer = + inspectServerRuntimeContextToolDef.server( + (_, executionContext) => { + executionContext.emitCustomEvent('runtime-context:server', { + userId: executionContext.context.userId, + tenantId: executionContext.context.tenantId, + }) + + return { + ...executionContext.context, + source: 'server' as const, + } + }, + ) + const serverTools = [ getGuitars, // Server tool recommendGuitarToolDef, // No server execute - client will handle addToCartToolServer, addToWishListToolDef, getPersonalGuitarPreferenceToolDef, + inspectServerRuntimeContextToolServer, // Lazy tools - discovered on demand compareGuitars, calculateFinancing, @@ -122,6 +158,22 @@ const loggingMiddleware: ChatMiddleware = { }, } +const runtimeContextMiddleware: ChatMiddleware = { + name: 'runtime-context', + onStart(ctx) { + console.log( + `[runtime-context] onStart user=${ctx.context.userId} tenant=${ctx.context.tenantId} tier=${ctx.context.loyaltyTier}`, + ) + }, + onBeforeToolCall(ctx, toolCtx) { + if (toolCtx.toolName.includes('RuntimeContext')) { + console.log( + `[runtime-context] onBeforeToolCall tool=${toolCtx.toolName} source=${ctx.context.requestSource}`, + ) + } + }, +} + export const Route = createFileRoute('/api/tanchat')({ server: { handlers: { @@ -157,6 +209,30 @@ export const Route = createFileRoute('/api/tanchat')({ typeof params.forwardedProps.model === 'string' ? params.forwardedProps.model : 'gpt-4o' + const runtimeContext: ServerRuntimeContext = { + userId: readForwardedString( + params.forwardedProps.runtimeUserId, + 'user_guest', + ), + tenantId: readForwardedString( + params.forwardedProps.runtimeTenantId, + 'public-store', + ), + loyaltyTier: isAllowedValue( + params.forwardedProps.runtimeLoyaltyTier, + runtimeLoyaltyTiers, + ) + ? params.forwardedProps.runtimeLoyaltyTier + : 'standard', + preferredStyle: isAllowedValue( + params.forwardedProps.runtimePreferredStyle, + runtimePreferredStyles, + ) + ? params.forwardedProps.runtimePreferredStyle + : 'acoustic', + requestSource: 'react-chat', + serverRegion: 'local-dev', + } // Pre-define typed adapter configurations with full type inference // Model is passed to the adapter factory function for type-safe autocomplete @@ -231,7 +307,8 @@ export const Route = createFileRoute('/api/tanchat')({ const stream = chat({ ...options, tools: Object.values(mergedTools), - middleware: [loggingMiddleware], + middleware: [loggingMiddleware, runtimeContextMiddleware], + context: runtimeContext, systemPrompts: [SYSTEM_PROMPT], agentLoopStrategy: maxIterations(20), messages: params.messages, diff --git a/examples/ts-react-chat/src/routes/example.runtime-context.tsx b/examples/ts-react-chat/src/routes/example.runtime-context.tsx new file mode 100644 index 000000000..49031d02b --- /dev/null +++ b/examples/ts-react-chat/src/routes/example.runtime-context.tsx @@ -0,0 +1,486 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { + BadgeCheck, + MonitorSmartphone, + Send, + Server, + Square, + UserRound, +} from 'lucide-react' +import ReactMarkdown from 'react-markdown' +import rehypeRaw from 'rehype-raw' +import rehypeSanitize from 'rehype-sanitize' +import rehypeHighlight from 'rehype-highlight' +import remarkGfm from 'remark-gfm' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { clientTools } from '@tanstack/ai-client' +import type { UIMessage } from '@tanstack/ai-react' +import { + inspectClientRuntimeContextToolDef, + inspectServerRuntimeContextToolDef, + type ClientRuntimeContext, +} from '@/lib/guitar-tools' +import { DEFAULT_MODEL_OPTION, MODEL_OPTIONS } from '@/lib/model-selection' +import type { ModelOption } from '@/lib/model-selection' + +type RuntimeProfile = ClientRuntimeContext & { + label: string + description: string +} + +const RUNTIME_PROFILES: Array = [ + { + label: 'Studio Buyer', + description: 'Northstar Music platinum account', + userId: 'user_studio_42', + tenantId: 'northstar-music', + loyaltyTier: 'platinum', + preferredStyle: 'electric', + }, + { + label: 'Acoustic Collector', + description: 'Cedar Room returning customer', + userId: 'user_acoustic_18', + tenantId: 'cedar-room', + loyaltyTier: 'gold', + preferredStyle: 'acoustic', + }, + { + label: 'Experimental Artist', + description: 'Signal Lab first-session account', + userId: 'user_signal_07', + tenantId: 'signal-lab', + loyaltyTier: 'standard', + preferredStyle: 'experimental', + }, +] + +const inspectClientRuntimeContextToolClient = + inspectClientRuntimeContextToolDef.client( + (_, executionContext) => ({ + ...executionContext.context, + source: 'client' as const, + }), + ) + +const runtimeContextTools = clientTools( + inspectClientRuntimeContextToolClient, + inspectServerRuntimeContextToolDef, +) + +type RuntimeContextMessage = UIMessage + +function RuntimeContextSummary({ profile }: { profile: RuntimeProfile }) { + const values = [ + ['User', profile.userId], + ['Tenant', profile.tenantId], + ['Tier', profile.loyaltyTier], + ['Style', profile.preferredStyle], + ] + + return ( +
+ {values.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+ ) +} + +function RuntimeContextToolResult({ + name, + output, +}: { + name: string + output: unknown +}) { + const isClientTool = name === 'inspectClientRuntimeContext' + + return ( +
+
+ {isClientTool ? ( + + ) : ( + + )} + {isClientTool ? 'Client runtime context' : 'Server runtime context'} +
+
+        {JSON.stringify(output, null, 2)}
+      
+
+ ) +} + +function RuntimeMessages({ + messages, +}: { + messages: Array +}) { + const messagesContainerRef = useRef(null) + const visibleMessages = messages.filter((message) => + message.parts.some((part) => { + if (part.type === 'text' && part.content.trim()) return true + if ( + part.type === 'tool-call' && + (part.name === 'inspectClientRuntimeContext' || + part.name === 'inspectServerRuntimeContext') + ) { + return true + } + return false + }), + ) + + useEffect(() => { + if (!messagesContainerRef.current) return + messagesContainerRef.current.scrollTop = + messagesContainerRef.current.scrollHeight + }, [visibleMessages]) + + if (!visibleMessages.length) { + return ( +
+
+
+ +
+

+ Runtime Context Lab +

+

+ Pick a profile, then run the client, server, or combined context + prompt. +

+
+
+ ) + } + + return ( +
+ {visibleMessages.map((message) => ( +
+
+
+ {message.role === 'assistant' ? 'AI' : 'U'} +
+
+ {message.parts.map((part, index) => { + if (part.type === 'text' && part.content) { + return ( +
+ + {part.content} + +
+ ) + } + + if ( + part.type === 'tool-call' && + (part.name === 'inspectClientRuntimeContext' || + part.name === 'inspectServerRuntimeContext') + ) { + if (part.output === undefined) { + return ( +
+ Running {part.name} +
+ ) + } + + return ( + + ) + } + + return null + })} +
+
+
+ ))} +
+ ) +} + +function RuntimeContextExamplePage() { + const [selectedModel, setSelectedModel] = + useState(DEFAULT_MODEL_OPTION) + const [runtimeProfileIndex, setRuntimeProfileIndex] = useState(0) + const [input, setInput] = useState('') + const runtimeProfile = + RUNTIME_PROFILES[runtimeProfileIndex] ?? RUNTIME_PROFILES[0] + const runtimeContext = useMemo( + () => ({ + userId: runtimeProfile.userId, + tenantId: runtimeProfile.tenantId, + loyaltyTier: runtimeProfile.loyaltyTier, + preferredStyle: runtimeProfile.preferredStyle, + }), + [ + runtimeProfile.loyaltyTier, + runtimeProfile.preferredStyle, + runtimeProfile.tenantId, + runtimeProfile.userId, + ], + ) + const forwardedProps = useMemo( + () => ({ + provider: selectedModel.provider, + model: selectedModel.model, + runtimeUserId: runtimeContext.userId, + runtimeTenantId: runtimeContext.tenantId, + runtimeLoyaltyTier: runtimeContext.loyaltyTier, + runtimePreferredStyle: runtimeContext.preferredStyle, + }), + [ + runtimeContext.loyaltyTier, + runtimeContext.preferredStyle, + runtimeContext.tenantId, + runtimeContext.userId, + selectedModel.model, + selectedModel.provider, + ], + ) + const { messages, sendMessage, isLoading, error, stop } = useChat({ + id: 'runtime-context-example', + connection: fetchServerSentEvents('/api/tanchat'), + tools: runtimeContextTools, + context: runtimeContext, + forwardedProps, + }) + + const sendPrompt = (prompt: string) => { + if (isLoading) return + sendMessage(prompt) + } + + const sendTypedMessage = () => { + const prompt = input.trim() + if (!prompt || isLoading) return + sendMessage(prompt) + setInput('') + } + + return ( +
+
+
+
+ + +
+ +
+ + +
+ {runtimeProfile.description} +
+
+ +
+ + + +
+
+
+ +
+ + +
+
+ +
+ + {error && ( +
+ {error.message} +
+ )} + +
+ {isLoading && ( +
+ +
+ )} +
+