diff --git a/.agent/domain-map.json b/.agent/domain-map.json index b09f64e809..9ba603e418 100644 --- a/.agent/domain-map.json +++ b/.agent/domain-map.json @@ -133,6 +133,11 @@ ], "talksTo": ["core"] }, + { + "id": "effect", + "owns": ["packages/ai-effect"], + "talksTo": ["core"] + }, { "id": "e2e", "owns": ["testing/e2e"], @@ -141,6 +146,7 @@ "code-mode", "computer-use", "core", + "effect", "harness", "middleware", "persistence", diff --git a/.changeset/ai-effect.md b/.changeset/ai-effect.md new file mode 100644 index 0000000000..82feca9b0f --- /dev/null +++ b/.changeset/ai-effect.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-effect': minor +--- + +Add `@tanstack/ai-effect`, a new package that runs every TanStack AI activity as an Effect or a Stream and lets you write server tools as Effects. diff --git a/docs/api/ai-effect.md b/docs/api/ai-effect.md new file mode 100644 index 0000000000..402acc9feb --- /dev/null +++ b/docs/api/ai-effect.md @@ -0,0 +1,144 @@ +--- +title: "@tanstack/ai-effect" +id: tanstack-ai-effect-api +order: 11 +description: "API reference for @tanstack/ai-effect: chat, serverTool, toolSchema, AIError, and every TanStack AI activity as an Effect 4 Effect or Stream." +keywords: + - tanstack ai + - "@tanstack/ai-effect" + - effect + - serverTool + - toolSchema + - AIError + - api reference +--- + +Effect 4 bindings for TanStack AI. Each function takes the options of the core function and returns an Effect `Effect` or `Stream`. For a walkthrough, start with [Effect](../effect/index). + +## Installation + + + +react: @tanstack/ai @tanstack/ai-effect effect + + + +Peer dependencies: `@tanstack/ai` and `effect` 4.1 or later. + +## `chat(options)` + +Runs core `chat()` as an Effect. See [Effect](../effect/index). + +```ts ignore +import * as AI from '@tanstack/ai-effect' +import { openaiText } from '@tanstack/ai-openai' + +const stream = AI.chat({ + adapter: openaiText('gpt-6.1-sol'), + messages: [{ role: 'user', content: 'Hello' }], + threadId: 'hello', +}) +``` + +### Options + +The options of core [`chat`](./ai#chat), with two changes: + +- `abortController` is not an option. Interruption aborts the run. +- `tools` can also hold `serverTool` values. + +### Returns + +| Options | Returns | +| --- | --- | +| Default | `Stream.Stream` | +| `stream: false` | `Effect.Effect`. `ChatResult` is `{ text, chunks }`. | +| `outputSchema` | `Effect.Effect`. `T` is the type of the schema. | +| `outputSchema` and `stream: true` | `Stream.Stream` | + +`R` is the union of the services of the `serverTool` values in `tools`. With no `serverTool`, `R` is `never`. + +## `serverTool(definition, execute)` + +Makes a server tool from a tool definition and a function that returns an Effect. See [Effect tools](../effect/tools). + +```ts ignore +function serverTool( + definition: TDef, + execute: ( + input: InferSchemaType, + context: ToolExecutionContext, + ) => Effect.Effect, E, R>, +): EffectServerTool +``` + +- `definition`: the result of `toolDefinition()` from `@tanstack/ai`. +- `execute`: gets the parsed input and the tool context. It returns an Effect of the output. + +A failure of the Effect goes to the model as a tool error. The run continues. If the run aborts, the tool fiber is interrupted. + +## `toolSchema(schema)` + +Turns an Effect Schema into an `inputSchema` or `outputSchema` for `toolDefinition()`. + +```ts ignore +function toolSchema( + schema: S, +): StandardJSONSchemaV1 +``` + +- Every object in the JSON Schema has `additionalProperties: false`. This lets providers such as OpenAI use strict mode. +- The tool input in `serverTool` gets the type of the schema. + +## `EffectServerTool` + +The type that `serverTool` returns. Pass it in the `tools` of `chat`. + +| Field | Type | +| --- | --- | +| `_tag` | `'EffectServerTool'` | +| `def` | `TDef` | +| `execute` | `(input, ctx) => Effect.Effect` | + +## `AIError` + +The error of every function in this package. It wraps a throw or a rejection from TanStack AI. + +```ts ignore +class AIError extends Data.TaggedError('AIError')<{ + readonly message: string + readonly cause: unknown +}> {} +``` + +- `_tag`: `'AIError'`. Catch it with `Effect.catchTag('AIError', ...)`. +- `message`: the message of the original error. +- `cause`: the original error. + +A `RUN_ERROR` chunk does not fail a `Stream`. It is a normal chunk. See [Effect activities](../effect/activities#a-run_error-chunk-does-not-fail-the-stream). + +## Activities + +Each activity takes the options of its core function, without `abortSignal`. It returns `Effect.Effect`. With `stream: true`, it returns `Stream.Stream`. + +| Function | Core function | Supports `stream: true` | +| --- | --- | --- | +| `summarize` | `summarize` | Yes | +| `generateImage` | `generateImage` | Yes | +| `generateAudio` | `generateAudio` | Yes | +| `generateSpeech` | `generateSpeech` | Yes | +| `generateVoice` | `generateVoice` | Yes | +| `generateTranscription` | `generateTranscription` | Yes | +| `generateVideo` | `generateVideo` | Yes | +| `generateWorld` | `generateWorld` | Yes | +| `generateLiveVideo` | `generateLiveVideo` | Yes | +| `getVideoJobStatus` | `getVideoJobStatus` | No | +| `embed` | `embed` | No | +| `rerank` | `rerank` | No | +| `decide` | `decide` | No | +| `listVoices` | `listVoices` | No | +| `uploadFile` | `uploadFile` | No | +| `getFile` | `getFile` | No | +| `deleteFile` | `deleteFile` | No | + +For the core options and results, see [`@tanstack/ai`](./ai). For examples, see [Effect activities](../effect/activities). diff --git a/docs/config.json b/docs/config.json index b4eca00ace..d1f636d5b8 100644 --- a/docs/config.json +++ b/docs/config.json @@ -104,6 +104,11 @@ "label": "Subscribe to MCP Events", "to": "tutorials/mcp-events", "addedAt": "2026-10-17" + }, + { + "label": "Effect Chat", + "to": "tutorials/effect-chat", + "addedAt": "2026-11-06" } ] }, @@ -155,6 +160,11 @@ "label": "MCP Events", "to": "framework/react/examples/mcp-events", "addedAt": "2026-10-17" + }, + { + "label": "Effect Chat", + "to": "framework/react/examples/effect-chat", + "addedAt": "2026-11-06" } ] }, @@ -1674,6 +1684,27 @@ ], "tab": "guides" }, + { + "label": "Effect", + "tab": "guides", + "children": [ + { + "label": "Stream a Chat", + "to": "effect/index", + "addedAt": "2026-11-06" + }, + { + "label": "Tools", + "to": "effect/tools", + "addedAt": "2026-11-06" + }, + { + "label": "Activities", + "to": "effect/activities", + "addedAt": "2026-11-06" + } + ] + }, { "label": "Adapters", "children": [ @@ -2062,6 +2093,11 @@ "to": "api/ai-remix", "addedAt": "2026-09-01", "updatedAt": "2026-10-21" + }, + { + "label": "@tanstack/ai-effect", + "to": "api/ai-effect", + "addedAt": "2026-11-06" } ] }, diff --git a/docs/effect/activities.md b/docs/effect/activities.md new file mode 100644 index 0000000000..bb37b6ab6e --- /dev/null +++ b/docs/effect/activities.md @@ -0,0 +1,188 @@ +--- +title: Effect Activities +id: effect-activities +order: 3 +description: "Run TanStack AI image, audio, speech, embedding, and other activities as Effects or Streams with @tanstack/ai-effect. Handle AIError with catchTag and stop requests with interruption." +keywords: + - tanstack ai + - effect + - ai-effect + - generateImage + - embed + - AIError + - interruption +--- + +You also generate images, embed text, or make speech in your Effect code. The core functions return promises, so their errors are not typed and you cannot interrupt them. + +Each activity in `@tanstack/ai-effect` takes the options of the core function. It returns an `Effect`, or a `Stream` with `stream: true`. Every failure is an `AIError`. + +## 1. Run an activity as an Effect + +```ts ignore +import * as AI from '@tanstack/ai-effect' +import { openaiImage } from '@tanstack/ai-openai' +import { Effect } from 'effect' + +const program = Effect.gen(function* () { + const { images } = yield* AI.generateImage({ + adapter: openaiImage('gpt-image-2.5-flare'), + prompt: 'A red fox in the snow', + threadId: 'fox', + size: '1024x1024', + }) + return images[0]?.url +}) + +const url = await Effect.runPromise(program) +``` + +The Effect is lazy. Each run sends a new request. TypeScript checks `size` and `modelOptions` against the model, the same as in core. + +## 2. Stream an activity + +Pass `stream: true` to get a `Stream` of chunks. Send it to the browser the same way as a chat: + +```ts ignore +import { + generationParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import * as AI from '@tanstack/ai-effect' +import { openaiImage } from '@tanstack/ai-openai' +import { Stream } from 'effect' + +export async function POST(request: Request) { + const { input, threadId } = await generationParamsFromRequest('image', request) + if (typeof input.prompt !== 'string') { + return new Response('Send a text prompt.', { status: 400 }) + } + + const stream = AI.generateImage({ + adapter: openaiImage('gpt-image-2.5-flare'), + prompt: input.prompt, + threadId, + stream: true, + }) + + return toServerSentEventsResponse(Stream.toAsyncIterable(stream)) +} +``` + +On the client, read it with a generation hook. The client does not use Effect: + +```tsx +import { fetchServerSentEvents, useGenerateImage } from '@tanstack/ai-react' + +export function FoxImage() { + const { generate, result, isLoading } = useGenerateImage({ + connection: fetchServerSentEvents('/api/generate/image'), + threadId: 'fox', + }) + + return ( +
+ + {result?.images.map((image, index) => ( + A red fox + ))} +
+ ) +} +``` + +Click **Generate**. The image appears when the stream sends its result. For the other hooks, see [Generation Hooks](../media/generation-hooks). + +## Handle AIError + +Every activity fails with `AIError` when the core call throws or rejects: + +- `message` is the message of the original error. +- `cause` is the original error. +- `_tag` is `'AIError'`, so `Effect.catchTag` can catch it. + +```ts ignore +import * as AI from '@tanstack/ai-effect' +import { openaiEmbedding } from '@tanstack/ai-openai' +import { Effect } from 'effect' + +const vectors = AI.embed({ + adapter: openaiEmbedding('text-embedding-3-small'), + input: ['a red guitar', 'a blue drum kit'], +}).pipe( + Effect.map((result) => result.embeddings.map((embedding) => embedding.vector)), + Effect.catchTag('AIError', (error) => + Effect.logError('Embedding failed', error.cause).pipe(Effect.as([])), + ), +) +``` + +### A RUN_ERROR chunk does not fail the Stream + +In a `Stream`, a `RUN_ERROR` chunk is a normal chunk. The Stream sends it on, and the client shows the error. The Stream fails with `AIError` only when the core iterator throws. + +To act on a run error on the server, look for the chunk: + +```ts ignore +import * as AI from '@tanstack/ai-effect' +import { openaiText } from '@tanstack/ai-openai' +import { Effect, Stream } from 'effect' + +const stream = AI.chat({ + adapter: openaiText('gpt-6.1-sol'), + messages: [{ role: 'user', content: 'Hello' }], + threadId: 'hello', +}).pipe( + Stream.tap((chunk) => + chunk.type === 'RUN_ERROR' ? Effect.logError(chunk.message) : Effect.void, + ), +) +``` + +## Stop a request + +You do not pass an `abortSignal`. Interruption aborts the request: + +- If you interrupt the fiber, the request to the provider stops. +- If a `Stream` stops early, for example with `Stream.take`, the request stops. +- `Effect.timeout` interrupts the Effect, so it also stops the request. + +```ts ignore +import * as AI from '@tanstack/ai-effect' +import { openaiImage } from '@tanstack/ai-openai' +import { Effect } from 'effect' + +const quick = AI.generateImage({ + adapter: openaiImage('gpt-image-2.5-flare'), + prompt: 'A red fox in the snow', + threadId: 'fox', +}).pipe(Effect.timeout('30 seconds')) +``` + +## All wrapped activities + +| Function | Returns | With `stream: true` | +| --- | --- | --- | +| `summarize` | `Effect` of the summary | `Stream` of chunks | +| `generateImage` | `Effect` of the images | `Stream` of chunks | +| `generateAudio` | `Effect` of the audio | `Stream` of chunks | +| `generateSpeech` | `Effect` of the speech | `Stream` of chunks | +| `generateVoice` | `Effect` of the voices | `Stream` of chunks | +| `generateTranscription` | `Effect` of the text | `Stream` of chunks | +| `generateVideo` | `Effect` of the job | `Stream` of the job lifecycle | +| `generateWorld` | `Effect` of the world | `Stream` of chunks | +| `generateLiveVideo` | `Effect` of the session | `Stream` of chunks | +| `getVideoJobStatus` | `Effect` of the job status | | +| `embed` | `Effect` of the vectors | | +| `rerank` | `Effect` of the ranked documents | | +| `decide` | `Effect` of the answers | | +| `listVoices` | `Effect` of the voices | | +| `uploadFile`, `getFile` | `Effect` of the file handle | | +| `deleteFile` | `Effect` of `void` | | + +`chat` has its own page: [Effect](./index). For the exact types, see the [API reference](../api/ai-effect). diff --git a/docs/effect/index.md b/docs/effect/index.md new file mode 100644 index 0000000000..bb003d1c58 --- /dev/null +++ b/docs/effect/index.md @@ -0,0 +1,130 @@ +--- +title: Effect +id: effect +order: 1 +description: "Stream a TanStack AI chat from an Effect 4 server. AI.chat from @tanstack/ai-effect returns an Effect Stream that you send to useChat as Server-Sent Events." +keywords: + - tanstack ai + - effect + - effect 4 + - ai-effect + - stream + - server-sent events + - useChat +--- + +Your server code uses Effect 4. Core `chat()` returns an async iterable and throws plain errors, so it does not fit in your Effect program. + +`@tanstack/ai-effect` wraps TanStack AI for Effect. `AI.chat` returns an Effect `Stream` of chunks. Its errors are typed, and interruption stops the model request. + +## 1. Install the packages + + + +react: @tanstack/ai @tanstack/ai-effect @tanstack/ai-openai @tanstack/ai-react effect + + + +The package needs Effect 4 (`effect` 4.1 or later). + +## 2. Stream the chat from your server route + +```ts ignore +import { chatParamsFromRequest, toServerSentEventsResponse } from '@tanstack/ai' +import * as AI from '@tanstack/ai-effect' +import { openaiText } from '@tanstack/ai-openai' +import { Stream } from 'effect' + +export async function POST(request: Request) { + const params = await chatParamsFromRequest(request) + + const stream = AI.chat({ + adapter: openaiText('gpt-6.1-sol'), + messages: params.messages, + threadId: params.threadId, + runId: params.runId, + }) + + return toServerSentEventsResponse(Stream.toAsyncIterable(stream)) +} +``` + +What each piece does: + +- `AI.chat` takes the options of core `chat()`. It returns a `Stream`. +- `Stream.toAsyncIterable` turns the `Stream` into the async iterable that `toServerSentEventsResponse` reads. +- If the browser closes the request, the stream stops. Then `AI.chat` aborts the request to the model. + +## 3. Show the chat on the client + +The client does not use Effect. It reads the Server-Sent Events with `useChat`: + +```tsx +import { useState } from 'react' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' + +export function Chat() { + const [input, setInput] = useState('') + const { messages, sendMessage, isLoading } = useChat({ + connection: fetchServerSentEvents('/api/chat'), + threadId: 'effect-chat', + }) + + return ( +
+ {messages.map((message) => ( +

+ {message.role}:{' '} + {message.parts.map((part) => + part.type === 'text' ? part.content : null, + )} +

+ ))} +
{ + event.preventDefault() + sendMessage(input) + setInput('') + }} + > + setInput(event.target.value)} /> + +
+
+ ) +} +``` + +Send a message. The reply streams in token by token from your Effect server. + +## Get the whole reply as an Effect + +If you do not need tokens as they arrive, pass `stream: false`. Then `AI.chat` returns an `Effect` of `{ text, chunks }`: + +```ts ignore +import * as AI from '@tanstack/ai-effect' +import { openaiText } from '@tanstack/ai-openai' +import { Effect } from 'effect' + +const program = Effect.gen(function* () { + const { text } = yield* AI.chat({ + adapter: openaiText('gpt-6.1-sol'), + messages: [{ role: 'user', content: 'Write a haiku about rivers.' }], + threadId: 'haiku', + stream: false, + }) + return text +}) + +const haiku = await Effect.runPromise(program) +``` + +With `outputSchema`, `AI.chat` returns an `Effect` of the parsed object. See [Structured Outputs](../structured-outputs/overview) for the schema options. + +## Next + +- Build the full app step by step: [Effect Chat tutorial](../tutorials/effect-chat). +- Give your tools Effect services: [Effect tools](./tools). +- Run images, embeddings, and speech as Effects: [Effect activities](./activities). diff --git a/docs/effect/tools.md b/docs/effect/tools.md new file mode 100644 index 0000000000..8885fc995b --- /dev/null +++ b/docs/effect/tools.md @@ -0,0 +1,131 @@ +--- +title: Effect Tools +id: effect-tools +order: 2 +description: "Write TanStack AI server tools as Effects with AI.serverTool. The tool reads your Effect services, and a Layer provides them to the AI.chat stream." +keywords: + - tanstack ai + - effect + - ai-effect + - serverTool + - layer + - services + - tools +--- + +Your tools need your database and your other Effect services. A core server tool is an async function, so it cannot read those services, and its errors are not typed. + +`AI.serverTool` makes a tool from a function that returns an Effect. The services of that Effect become requirements of the `AI.chat` stream. You provide them with a Layer. + +This page uses the server route from [Effect](./index). The client does not change. + +## 1. Define the service and its Layer + +```ts ignore +import { Context, Data, Effect, Layer } from 'effect' + +export class OrderNotFound extends Data.TaggedError('OrderNotFound')<{ + readonly id: string +}> {} + +export class Db extends Context.Service< + Db, + { + readonly findOrder: ( + id: string, + ) => Effect.Effect<{ status: string }, OrderNotFound> + } +>()('Db') {} + +// A fixed order. Read from your real database here. +export const DbLive = Layer.succeed(Db, { + findOrder: (id) => + id === 'A-100' + ? Effect.succeed({ status: 'shipped' }) + : Effect.fail(new OrderNotFound({ id })), +}) +``` + +## 2. Write the tool as an Effect + +```ts ignore +import { toolDefinition } from '@tanstack/ai' +import * as AI from '@tanstack/ai-effect' +import { Effect, Schema } from 'effect' +import { Db } from './db' + +const getOrderDef = toolDefinition({ + name: 'get_order', + description: 'Get the status of an order', + inputSchema: AI.toolSchema(Schema.Struct({ id: Schema.String })), + outputSchema: AI.toolSchema(Schema.Struct({ status: Schema.String })), +}) + +export const getOrder = AI.serverTool(getOrderDef, ({ id }) => + Effect.gen(function* () { + const db = yield* Db + return yield* db.findOrder(id) + }), +) +``` + +`AI.toolSchema` turns an Effect Schema into a tool schema. It closes every object (`additionalProperties: false`), so OpenAI can check the tool arguments in strict mode. + +`AI.serverTool` takes a tool definition and a function. The function gets the parsed input and returns an Effect of the output. The type of `getOrder` keeps `Db` as a requirement. + +## 3. Pass the tool and provide the Layer + +```ts ignore +import { chatParamsFromRequest, toServerSentEventsResponse } from '@tanstack/ai' +import * as AI from '@tanstack/ai-effect' +import { openaiText } from '@tanstack/ai-openai' +import { Stream } from 'effect' +import { DbLive } from './db' +import { getOrder } from './tools' + +export async function POST(request: Request) { + const params = await chatParamsFromRequest(request) + + const stream = AI.chat({ + adapter: openaiText('gpt-6.1-sol'), + messages: params.messages, + threadId: params.threadId, + runId: params.runId, + tools: [getOrder], + }).pipe(Stream.provide(DbLive)) + + return toServerSentEventsResponse(Stream.toAsyncIterable(stream)) +} +``` + +The stream needs `Db` until you provide `DbLive`. If you remove `Stream.provide(DbLive)`, `Stream.toAsyncIterable` does not type-check. + +Ask "Where is order A-100?". The model calls `get_order`, your Effect reads `Db`, and the reply says the order shipped. + +## When the tool fails + +If the tool Effect fails, the model gets a tool error: + +- The error message goes back to the model as the tool result. The model can tell the user or try again. +- The run continues. The stream does not fail. +- A typed failure (`OrderNotFound`) and a defect (`Effect.die`) both work this way. + +Ask for order B-200. `findOrder` fails with `OrderNotFound`. The model gets the tool error and answers the user. + +To send the model a normal result instead, catch the error in the tool. For example, `Effect.catchTag('OrderNotFound', () => Effect.succeed({ status: 'unknown' }))`. + +## When the run stops + +The tool Effect runs with the services of the `AI.chat` stream. It stops when the run stops: + +- The browser closes the request. +- Your code stops the stream early, for example with `Stream.take`. +- Your code interrupts the fiber that runs the stream. + +In each case, the running tool fiber is interrupted. Its finalizers run, so `Effect.acquireRelease` and `Effect.onInterrupt` clean up as usual. + +## Mix with other tools + +The `tools` array can also hold core tools, for example `toolDefinition(...).server(...)` or client tools. Only the `AI.serverTool` values add services to the stream. + +The full app is in the [Effect Chat tutorial](../tutorials/effect-chat). For images, embeddings, and other activities, see [Effect activities](./activities). diff --git a/docs/tutorials/effect-chat.md b/docs/tutorials/effect-chat.md new file mode 100644 index 0000000000..b562d4eac7 --- /dev/null +++ b/docs/tutorials/effect-chat.md @@ -0,0 +1,250 @@ +--- +title: Effect Chat +id: effect-chat +order: 9 +description: "Build a streaming React chat with an Effect server. AI.chat from @tanstack/ai-effect streams the reply, and a get_weather tool is an Effect that reads a service from a Layer." +keywords: + - tanstack ai + - tutorial + - effect + - ai-effect + - serverTool + - layer + - useChat + - openai + - tanstack start +--- + +Your server code uses Effect, and you want a chat that streams tokens. Core `chat()` gives an async iterable, and its tools return promises. Your Effect services do not reach those tools. + +`@tanstack/ai-effect` connects the two. `AI.chat` returns an Effect `Stream`. A tool made with `AI.serverTool` returns an Effect, and a Layer gives the tool its services. + +In this tutorial, the model calls a `get_weather` tool, and the tool reads a `Weather` service. This tutorial uses React, TanStack Start, and OpenAI. It takes about 10 minutes. + +## 1. Create a Start app + +```bash +npx @tanstack/cli@latest create +``` + +Pick React. Then install the packages: + + + +react: @tanstack/ai @tanstack/ai-react @tanstack/ai-openai @tanstack/ai-effect effect + + + +Put your OpenAI key in `.env.local`: + +```bash +OPENAI_API_KEY=sk-... +``` + +## Client and server + +- **The server** runs the chat in Effect. It owns the tool and the Layer that gives the tool its service. +- **The client** sends the messages and shows the reply. It does not use Effect. + +The next steps build the server first. Then they build the client. + +## 2. Add a service and a tool + +Create `src/lib/weather.ts`: + +```typescript ignore +import { toolDefinition } from '@tanstack/ai' +import * as AI from '@tanstack/ai-effect' +import { Context, Effect, Layer, Schema } from 'effect' + +export class Weather extends Context.Service< + Weather, + { readonly forecast: (city: string) => Effect.Effect } +>()('Weather') {} + +// A fixed forecast. Call a real weather API here. +export const WeatherLive = Layer.succeed(Weather, { + forecast: (city) => Effect.succeed(`Sunny and 22 C in ${city}`), +}) + +const getWeatherDef = toolDefinition({ + name: 'get_weather', + description: 'Get the weather forecast for a city', + inputSchema: AI.toolSchema(Schema.Struct({ city: Schema.String })), + outputSchema: AI.toolSchema(Schema.Struct({ forecast: Schema.String })), +}) + +export const getWeather = AI.serverTool(getWeatherDef, ({ city }) => + Effect.gen(function* () { + const weather = yield* Weather + return { forecast: yield* weather.forecast(city) } + }), +) +``` + +This file has three parts: + +- `Weather` is an Effect service with one function, `forecast`. +- `WeatherLive` is a Layer that gives a fixed forecast. Put a call to a real weather API here. +- `getWeather` is the tool. `AI.serverTool` takes a tool definition and a function that returns an Effect. + +The type of `getWeather` keeps `Weather` as a requirement. If the Effect fails, the model gets a tool error, and the run continues. + +## 3. Add the server route + +Create `src/routes/api.chat.ts`, next to `index.tsx`. Start maps this file name to the `/api/chat` path. + +```typescript ignore +import { createFileRoute } from '@tanstack/react-router' +import { chatParamsFromRequest, toServerSentEventsResponse } from '@tanstack/ai' +import * as AI from '@tanstack/ai-effect' +import { openaiText } from '@tanstack/ai-openai' +import { Stream } from 'effect' +import { WeatherLive, getWeather } from '@/lib/weather' + +export async function POST({ request }: { request: Request }) { + const params = await chatParamsFromRequest(request) + + const stream = AI.chat({ + adapter: openaiText('gpt-6.1-sol'), + messages: params.messages, + threadId: params.threadId, + runId: params.runId, + tools: [getWeather], + }).pipe(Stream.provide(WeatherLive)) + + return toServerSentEventsResponse(Stream.toAsyncIterable(stream)) +} + +export const Route = createFileRoute('/api/chat')({ + server: { + handlers: { + POST, + }, + }, +}) +``` + +What each piece does: + +- `chatParamsFromRequest` reads the messages and the thread id from the POST body. +- `AI.chat` takes the options of core `chat()`. It returns a `Stream` of chunks, and `Weather` is in its requirements. +- `Stream.provide(WeatherLive)` gives the `Weather` service to the tool. If you remove this line, `Stream.toAsyncIterable` does not type-check. +- `toServerSentEventsResponse` sends the chunks to the browser as Server-Sent Events. + +If the browser closes the request, the stream stops. Then `AI.chat` aborts the request to OpenAI. + +## 4. Add the chat page + +Replace `src/routes/index.tsx` with this file: + +```tsx ignore +import { useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' + +function ChatPage() { + const [input, setInput] = useState('') + const { messages, sendMessage, isLoading, error, stop } = useChat({ + connection: fetchServerSentEvents('/api/chat'), + threadId: 'effect-chat', + }) + + const handleSendMessage = () => { + if (!input.trim()) return + sendMessage(input.trim()) + setInput('') + } + + return ( +
+
+ {messages.length === 0 ? ( +

+ Ask for the weather in a city. +

+ ) : null} + {messages.map((message) => ( +
+

{message.role}

+ {message.parts.map((part, index) => { + if (part.type === 'text') { + return ( +

+ {part.content} +

+ ) + } + if (part.type === 'tool-call') { + return ( +

+ Called {part.name} +

+ ) + } + return null + })} +
+ ))} +
+ {error ?

{error.message}

: null} +
+