Skip to content

Commit fd36fac

Browse files
committed
feat(ai): add typed runtime context
1 parent e144a53 commit fd36fac

60 files changed

Lines changed: 3213 additions & 235 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/advanced/middleware.md

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -396,9 +396,50 @@ Every hook receives a `ChatMiddlewareContext` as its first argument. It provides
396396
| `chunkIndex` | `number` | Running count of chunks yielded |
397397
| `signal` | `AbortSignal \| undefined` | External abort signal |
398398
| `abort(reason?)` | `function` | Abort the run from within middleware |
399-
| `context` | `unknown` | User-provided context value |
399+
| `context` | `TContext` | User-provided runtime context value |
400400
| `defer(promise)` | `function` | Register a non-blocking side-effect |
401401

402+
## Typed Runtime Context
403+
404+
`ChatMiddleware` accepts a context generic. This lets reusable middleware declared outside `chat()` access the same typed runtime context as your tools.
405+
406+
```typescript
407+
import { chat, type ChatMiddleware } from "@tanstack/ai";
408+
409+
type AppContext = {
410+
userId: string;
411+
audit: {
412+
write(event: { userId: string; requestId: string }): Promise<void>;
413+
};
414+
};
415+
416+
export const auditMiddleware: ChatMiddleware<AppContext> = {
417+
name: "audit",
418+
onStart(ctx) {
419+
ctx.defer(
420+
ctx.context.audit.write({
421+
userId: ctx.context.userId,
422+
requestId: ctx.requestId,
423+
})
424+
);
425+
},
426+
};
427+
428+
chat({
429+
adapter,
430+
messages,
431+
middleware: [auditMiddleware],
432+
context: {
433+
userId: session.user.id,
434+
audit,
435+
},
436+
});
437+
```
438+
439+
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.
440+
441+
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.
442+
402443
### Aborting from Middleware
403444

404445
Call `ctx.abort()` to gracefully stop the run. This triggers the `onAbort` terminal hook:

docs/advanced/runtime-context.md

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
---
2+
title: Runtime Context
3+
id: runtime-context
4+
order: 2
5+
description: "Pass typed runtime dependencies to TanStack AI tools and middleware without serializing them to the model or AG-UI protocol context."
6+
keywords:
7+
- tanstack ai
8+
- runtime context
9+
- typed context
10+
- tools context
11+
- middleware context
12+
- ag-ui context
13+
---
14+
15+
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.
16+
17+
Runtime context is not prompt context and is not the AG-UI `RunAgentInput.context` field. It is never sent to the model automatically.
18+
19+
## How Type Safety Works
20+
21+
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.
22+
23+
The source of truth is:
24+
25+
- `toolDefinition(...).server<TContext>(...)` for server tools.
26+
- `toolDefinition(...).client<TContext>(...)` for client tools.
27+
- `ChatMiddleware<TContext>` for middleware.
28+
29+
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.
30+
31+
```typescript
32+
import { chat, toolDefinition, type ChatMiddleware } from "@tanstack/ai";
33+
34+
type UserContext = {
35+
userId: string;
36+
};
37+
38+
type TenantContext = {
39+
tenantId: string;
40+
};
41+
42+
const currentUserTool = toolDefinition({
43+
name: "current_user",
44+
description: "Read the current user",
45+
}).server<UserContext>((_input, ctx) => {
46+
return { userId: ctx.context.userId };
47+
});
48+
49+
const tenantMiddleware: ChatMiddleware<TenantContext> = {
50+
name: "tenant",
51+
onStart(ctx) {
52+
console.log(ctx.context.tenantId);
53+
},
54+
};
55+
56+
chat({
57+
adapter,
58+
messages,
59+
tools: [currentUserTool],
60+
middleware: [tenantMiddleware],
61+
context: {
62+
userId: "user_123",
63+
tenantId: "tenant_456",
64+
},
65+
});
66+
```
67+
68+
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.
69+
70+
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.
71+
72+
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.
73+
74+
The same rule applies on the client:
75+
76+
```typescript
77+
import { clientTools } from "@tanstack/ai-client";
78+
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
79+
import { toolDefinition } from "@tanstack/ai";
80+
81+
type ClientRuntimeContext = {
82+
currentTabId: string;
83+
};
84+
85+
const inspectClientContext = toolDefinition({
86+
name: "inspect_client_context",
87+
description: "Inspect local browser context",
88+
}).client<ClientRuntimeContext & { mode: "debug" }>((_input, ctx) => {
89+
return {
90+
tabId: ctx.context.currentTabId,
91+
mode: ctx.context.mode,
92+
};
93+
});
94+
95+
useChat({
96+
connection: fetchServerSentEvents("/api/chat"),
97+
tools: clientTools(inspectClientContext),
98+
context: {
99+
currentTabId: "settings",
100+
mode: "debug",
101+
},
102+
});
103+
```
104+
105+
Because the client tool declares `ClientRuntimeContext & { mode: "debug" }`, `useChat()` requires a `context` value with both `currentTabId` and the literal `mode: "debug"`.
106+
107+
## Server Runtime Context
108+
109+
Define the context type once, use it in server tools and middleware, then pass the matching `context` value to `chat()`.
110+
111+
```typescript
112+
import {
113+
chat,
114+
toServerSentEventsResponse,
115+
toolDefinition,
116+
type ChatMiddleware,
117+
} from "@tanstack/ai";
118+
import { openaiText } from "@tanstack/ai-openai";
119+
import { z } from "zod";
120+
121+
type AppContext = {
122+
userId: string;
123+
tenantId: string;
124+
db: {
125+
notes: {
126+
findMany(args: { userId: string; tenantId: string }): Promise<Array<{ title: string }>>;
127+
};
128+
};
129+
};
130+
131+
const listNotes = toolDefinition({
132+
name: "list_notes",
133+
description: "List notes for the current user",
134+
inputSchema: z.object({}),
135+
outputSchema: z.array(z.object({ title: z.string() })),
136+
}).server<AppContext>(async (_input, ctx) => {
137+
return ctx.context.db.notes.findMany({
138+
userId: ctx.context.userId,
139+
tenantId: ctx.context.tenantId,
140+
});
141+
});
142+
143+
const auditMiddleware: ChatMiddleware<AppContext> = {
144+
name: "audit",
145+
onStart(ctx) {
146+
console.log("chat started", {
147+
requestId: ctx.requestId,
148+
userId: ctx.context.userId,
149+
tenantId: ctx.context.tenantId,
150+
});
151+
},
152+
};
153+
154+
export async function POST(request: Request) {
155+
const { messages } = await request.json();
156+
const user = await requireUser(request);
157+
158+
const stream = chat({
159+
adapter: openaiText("gpt-4o"),
160+
messages,
161+
tools: [listNotes],
162+
middleware: [auditMiddleware],
163+
context: {
164+
userId: user.id,
165+
tenantId: user.tenantId,
166+
db,
167+
},
168+
});
169+
170+
return toServerSentEventsResponse(stream);
171+
}
172+
```
173+
174+
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`.
175+
176+
## Client Runtime Context
177+
178+
Client runtime context is local to `ChatClient` and framework hooks. It is passed to client tool implementations and is not serialized to the server.
179+
180+
```typescript
181+
import { createChatClientOptions, clientTools } from "@tanstack/ai-client";
182+
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
183+
import { toolDefinition } from "@tanstack/ai";
184+
185+
type ClientContext = {
186+
currentTabId: string;
187+
toast(message: string): void;
188+
};
189+
190+
const notifyUser = toolDefinition({
191+
name: "notify_user",
192+
description: "Show a notification in the current browser tab",
193+
}).client<ClientContext>((_input, ctx) => {
194+
ctx.context.toast(`Updated tab ${ctx.context.currentTabId}`);
195+
return { ok: true };
196+
});
197+
198+
const chatOptions = createChatClientOptions({
199+
connection: fetchServerSentEvents("/api/chat"),
200+
tools: clientTools(notifyUser),
201+
context: {
202+
currentTabId: "settings",
203+
toast: (message) => window.alert(message),
204+
},
205+
});
206+
207+
const chat = useChat(chatOptions);
208+
```
209+
210+
Use client context for local dependencies only. Do not put values there expecting the server to receive them.
211+
212+
## Client-to-Server Handoff
213+
214+
To send serializable client data to the server, use `forwardedProps`, validate it in your route, and explicitly map it into the server runtime context.
215+
216+
```typescript
217+
// Client
218+
useChat({
219+
connection: fetchServerSentEvents("/api/chat"),
220+
forwardedProps: {
221+
tenantId: selectedTenantId,
222+
},
223+
context: clientRuntimeContext,
224+
});
225+
```
226+
227+
```typescript
228+
// Server
229+
import {
230+
chat,
231+
chatParamsFromRequest,
232+
toServerSentEventsResponse,
233+
} from "@tanstack/ai";
234+
235+
type AppContext = {
236+
userId: string;
237+
tenantId: string;
238+
};
239+
240+
export async function POST(request: Request) {
241+
const params = await chatParamsFromRequest(request);
242+
const user = await requireUser(request);
243+
244+
const tenantId =
245+
typeof params.forwardedProps.tenantId === "string"
246+
? params.forwardedProps.tenantId
247+
: user.defaultTenantId;
248+
249+
const stream = chat({
250+
adapter,
251+
messages: params.messages,
252+
tools,
253+
context: {
254+
userId: user.id,
255+
tenantId,
256+
} satisfies AppContext,
257+
});
258+
259+
return toServerSentEventsResponse(stream);
260+
}
261+
```
262+
263+
Treat `forwardedProps` as client-controlled input. Validate and allowlist every field before using it to build server runtime context.
264+
265+
## AG-UI Context
266+
267+
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 })`.
268+
269+
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:
270+
271+
```typescript
272+
const params = await chatParamsFromRequest(request);
273+
274+
const stream = chat({
275+
adapter,
276+
messages: params.messages,
277+
tools,
278+
context: buildRuntimeContextFrom(params.context),
279+
});
280+
```

docs/api/ai-client.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ const client = new ChatClient({
4949
- `threadId?` - Thread ID for AG-UI run correlation. Persists across sends; auto-generated if omitted
5050
- `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field
5151
- `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
52+
- `context?` - Typed client-local runtime context passed to client tool implementations. This value is not serialized to the server
5253
- `onResponse?` - Callback when response is received
5354
- `onChunk?` - Callback when stream chunk is received
5455
- `onFinish?` - Callback when response finishes
@@ -248,6 +249,28 @@ const chatOptions = createChatClientOptions({
248249
type ChatMessages = InferChatMessages<typeof chatOptions>;
249250
```
250251

252+
`createChatClientOptions` also preserves typed client runtime context:
253+
254+
```typescript
255+
type ClientContext = {
256+
activeProjectId: string;
257+
};
258+
259+
const tool = projectTool.client<ClientContext>((input, ctx) => {
260+
return runProjectAction(ctx.context.activeProjectId, input);
261+
});
262+
263+
const chatOptions = createChatClientOptions({
264+
connection: fetchServerSentEvents("/api/chat"),
265+
tools: clientTools(tool),
266+
context: {
267+
activeProjectId: "project_123",
268+
},
269+
});
270+
```
271+
272+
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 })`.
273+
251274
## Types
252275

253276
### `UIMessage`

docs/api/ai-preact.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ Extends `ChatClientOptions` from `@tanstack/ai-client`:
6868
- `threadId?` - Thread ID for AG-UI run correlation. Persists across sends; auto-generated if omitted
6969
- `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field (e.g., `{ provider: 'openai', model: 'gpt-4o' }`)
7070
- `body?` - **Deprecated.** Use `forwardedProps` instead. Still works for backward compatibility; values are merged into `forwardedProps` on the wire
71+
- `context?` - Typed client-local runtime context passed to client tool implementations. This value is not serialized to the server
7172
- `onResponse?` - Callback when response is received
7273
- `onChunk?` - Callback when stream chunk is received
7374
- `onFinish?` - Callback when response finishes
@@ -309,7 +310,7 @@ Re-exported from `@tanstack/ai-client`:
309310
- `ThinkingPart` - Thinking content part
310311
- `ToolCallPart<TTools>` - Tool call part (discriminated union)
311312
- `ToolResultPart` - Tool result part
312-
- `ChatClientOptions<TTools>` - Chat client options
313+
- `ChatClientOptions<TTools, TContext>` - Chat client options with typed client runtime context
313314
- `ConnectionAdapter` - Connection adapter interface
314315
- `InferChatMessages<T>` - Extract message type from options
315316

0 commit comments

Comments
 (0)