diff --git a/.agent/self-learning/lessons/2026-06-23-authoring-kiira-doc-snippets.md b/.agent/self-learning/lessons/2026-06-23-authoring-kiira-doc-snippets.md
index b1cf7dfd82..5ee5c64644 100644
--- a/.agent/self-learning/lessons/2026-06-23-authoring-kiira-doc-snippets.md
+++ b/.agent/self-learning/lessons/2026-06-23-authoring-kiira-doc-snippets.md
@@ -35,9 +35,14 @@ is a means (keep examples honest), not the end (a green check).
- an adapter → `import { openaiText } from '@tanstack/ai-openai'; const adapter = openaiText('gpt-5.5')`.
- tools → define inline with `toolDefinition({ name, description, inputSchema })`.
- any other arbitrary placeholder (db, env, a client, a token) → import it from
- a relative module: `import { db } from './db'`. kiira ignores unresolved
- _relative_ imports, so the name is a usable `any` and the reader sees they
- must supply it.
+ a relative module: `import { db } from './db'`. kiira hides the "cannot find
+ module" error for _relative_ imports, and the reader sees they must supply it.
+ **But the binding is TypeScript's error type, not `any`:** every other error
+ in a call that uses it is silently dropped. `chat({ ..., tools: serverTools })`
+ with `serverTools` from `./tools` passes even with no `threadId`. So never pass
+ a relative-import value into a TanStack call (tools, schemas, agents,
+ messages, options) in a fence that must prove the call is right. Define it
+ inline or in an earlier fence of the same file instead.
2. **No invisible "ambient" fixture for `messages`/`message`.** Same reason as #1
— the reader sees a variable appear from nowhere. Use real visible setup.
diff --git a/.agents/skills/add-example-tutorial/SKILL.md b/.agents/skills/add-example-tutorial/SKILL.md
index 9d837628cb..85eec6dc2c 100644
--- a/.agents/skills/add-example-tutorial/SKILL.md
+++ b/.agents/skills/add-example-tutorial/SKILL.md
@@ -41,7 +41,7 @@ Add other files only when this tutorial needs them (a key form, a tool, a store)
## 3. Tutorial page
-Load `docs`. Run its persona and tone gates unless this conversation already chose them.
+Load `docs`. State the readers and the tone it picks, then keep going.
Open with the problem the reader has, why it matters, and how this tutorial solves it (one short block). Then walk through steps. Each step teaches one piece of that solution and says why that piece exists.
diff --git a/.agents/skills/docs/SKILL.md b/.agents/skills/docs/SKILL.md
index 72b5815c31..b35a535e1d 100644
--- a/.agents/skills/docs/SKILL.md
+++ b/.agents/skills/docs/SKILL.md
@@ -1,6 +1,6 @@
---
name: docs
-description: Use when writing, editing, or organizing documentation, when planning what docs a feature needs, and whenever planning or implementing a new feature or change in a repo (docs ship with the code). Also use when tempted to write docs without showing the discovered readers to the user, without asking for tone, or without loading simple-english and i-have-adhd. Triggers on "write docs for X", "document this feature", "add a guide", "update the docs", "reorganize the docs", "plan feature X", "implement X", or /docs.
+description: Use when writing, editing, or organizing documentation, when planning what docs a feature needs, and whenever planning or implementing a new feature or change in a repo (docs ship with the code). Also use when tempted to write docs without naming the readers, without picking a tone, or without loading simple-english and i-have-adhd. Triggers on "write docs for X", "document this feature", "add a guide", "update the docs", "reorganize the docs", "plan feature X", "implement X", or /docs.
---
# docs
@@ -11,19 +11,19 @@ Write docs a real person wants to read. Short, plain, built around someone tryin
Work in two phases. First plan the story: who reads this, what they want, and how many pages it should be. Then write.
-The plan is not private. The user must see the readers and must choose the tone before any page is written.
+Do not ask the user to approve the plan or the tone. Pick sane defaults, state them in one line each, and keep going. The user can correct you after.
```text
Find docs
→ read neighbors
→ Phase 1: readers + pages
- → PERSONA GATE (show the list, then stop)
- → TONE GATE (ask, then stop) [when writing pages]
+ → state the readers + pages (one line each, no question)
+ → state the tone (match neighbors, no question) [when writing pages]
→ load simple-english + i-have-adhd [when writing pages]
→ Phase 2: write
```
-A doc-impact list in a feature plan still runs the persona gate. It does not run the tone gate or load the writing skills until pages are actually written.
+A doc-impact list in a feature plan still names the reader for each page. It does not pick a tone or load the writing skills until pages are actually written.
## When to run
@@ -48,7 +48,7 @@ If either file is missing, stop and tell the user. Do not write the pages withou
How they compose:
-- This skill owns who the page is for, the page split, problem-then-fix, show-don't-tell, no history leak, forbidden glyphs, and neighbor structure.
+- This skill owns who the page is for, the page split, the call-first opener, show-don't-tell, no history leak, forbidden glyphs, and neighbor structure.
- `simple-english` owns the sentences. Use pragmatic mode. Run its self-check before you call a page done.
- `i-have-adhd` owns the shape of the page: next action first, numbered steps, lists capped at 5 (split must vs later, or split the page), no preamble, a visible win at the end.
@@ -59,8 +59,8 @@ When `i-have-adhd` is loaded from this skill, apply it to the pages only. Do not
Before writing anything, find where docs live.
1. Look for a docs folder. Check `docs/` first, then `documentation/`, `content/docs/`, `site/`, `website/docs/`.
-2. Found nothing? Ask the user where docs should go. Do not guess and do not create a folder on a hunch.
-3. Open 2 or 3 existing pages near where the new content belongs. Read them for copy, structure, frontmatter fields, and components. Note the apparent tone as a *candidate* for the tone gate. Do not adopt it yet.
+2. Found nothing? Put the page in the README or the closest existing docs file, and say where you put it. Do not create a new docs folder on a hunch.
+3. Open 2 or 3 existing pages near where the new content belongs. Read them for copy, structure, frontmatter fields, and components. Note their tone. That is the tone you write in.
4. Note which components the site already uses (steps, tabs, callouts, cards, accordions, code groups, and so on). Different sites have different ones.
5. Reuse those components to tell the story. If the site has a steps component, use it for walkthroughs. If it has tabs, use them for framework variations. If the site has none, use plain markdown. Never invent a component the site does not have.
@@ -100,8 +100,6 @@ If there is no page like the one you are about to write, read the closest one yo
Matching neighbors is about **copy, structure, frontmatter, and components**: whether headings are sentence case, how much setup a section gets, which components carry the walkthrough, how code samples are introduced, what the frontmatter fields are.
-It is **not** a substitute for the tone gate. Neighbors can suggest a default. They cannot answer for the user.
-
It is **not** permission to copy a neighbor's bad habits. Everything in [Forbidden](#forbidden) and every rule in this skill still applies to the content you write, no matter what the surrounding pages do. An existing page full of em dashes does not license one more.
So: never reason "the other pages do it, so I will too" about a rule this skill states. If existing pages break a rule and you think the whole set should be brought in line, that is a separate cleanup to raise with the user, not something to settle by quietly matching the violation.
@@ -117,40 +115,11 @@ Do this before you write a word of content.
The page split comes out of this step. Do not skip it.
-### Gate: show the personas
-
-
-The user must see the list. Listing readers in a thought, a todo, or a buried plan is not this gate.
-
-After step 4, send a message that contains only:
-
-1. Each reader, one user story, and the page you will write for them.
-2. One question: confirm, drop a reader, or add one.
-
-If the harness has an AskQuestion (or similar) tool, use it for that question. If it does not, use a numbered list.
-
-Then pick one path:
-
-**Default: stop.** This message is the entire turn. End the turn. Do not write files. Do not start Phase 2. Do not say you will proceed unless they object.
-
-**Skip the wait: continue in this same turn.** Use this path only when one of these is true:
+### State the readers
-- This conversation already has the user's confirmation of this persona list.
-- The user said "use sane defaults", "just write it", or "don't ask questions". Still SHOW the list in this turn, then continue.
-- The change is a tiny copy edit: typo, broken link, code-fence language, or a factual fix. No new page, no new section, no rewrite of a journey.
+After step 4, put the readers and their pages in your message to the user, one line per reader: the user story and the page. Do not ask a question and do not wait. Continue to Phase 2 in the same turn.
-On this path, do not end the turn. After you show the list (or after a tiny copy edit, with no list), continue to the next step.
-
-These are not skips:
-
-- "The readers are obvious."
-- "The user asked for docs, so they do not want a question."
-- "I already named them in the plan."
-- "There is only one reader."
-- "This is part of implementing a feature, keep going."
-
-Writing docs is why this gate exists. It is not a reason to skip it.
-
+The user reads the list and corrects you if a reader is wrong. A list that only lives in your thinking or a todo does not count. Skip the list only for a tiny copy edit (typo, broken link, code-fence language, factual fix).
## Less is more: split, do not cram
@@ -173,47 +142,15 @@ Good: a small set of linked pages
Each page is short and does one thing. The overview stitches them into a story.
-## Gate: ask for tone
-
-This gate sits between Phase 1 and Phase 2. Run it before you write any page. Do not nest it inside Phase 2.
-
-
-This gate runs before any page content is written. It does not run for a doc-impact list that is only a plan.
-
-Neighbors can suggest a default. They cannot answer for the user.
-
-Send a message that contains only:
-
-1. The tone you inferred from neighboring pages, in one line (how formal, how much setup, second person or not).
-2. One question with options: use that tone, more casual, more formal, or the user names another.
+## Pick the tone
-If the harness has an AskQuestion (or similar) tool, use it. If it does not, use a numbered list.
+Use the tone of the neighboring pages. State it in one line (how formal, how much setup, second person or not) and continue. Do not ask.
-Then pick one path:
-
-**Default: stop.** This message is the entire turn. End the turn. Do not write page content.
-
-**Skip the wait: continue in this same turn.** Use this path only when one of these is true:
-
-- This conversation already has the user's tone choice, including an explicit "match the existing pages".
-- The user said "use sane defaults", "just write it", or "don't ask questions". Still STATE the inferred tone in one line, then continue.
-- Tiny copy edit, same carve-out as the persona gate.
-
-On this path, do not end the turn. After you state the tone (or after a tiny copy edit, with no question), continue to Phase 2.
-
-These are not skips:
-
-- "I can tell from the neighbors."
-- "The site already has a voice."
-- "Tone does not matter for a reference page."
-- "I'll match neighbors and mention it later."
-
+If the user already named a tone in this conversation, use theirs.
## Phase 2: write like a human
-Do not write page content until the persona gate has passed, the tone gate has passed, and `simple-english` plus `i-have-adhd` are loaded.
-
-The tone gate is the heading above this one. Run that gate first. Then load the two skills. Then write.
+Do not write page content until you have stated the readers and the tone, and `simple-english` plus `i-have-adhd` are loaded.
Legibility is the goal, above everything else.
@@ -224,28 +161,29 @@ Legibility is the goal, above everything else.
- Second person, action first. Start with what the reader has now and what they will have at the end. No "In this guide we will explore..." openings. Just start.
- Shape the page with `i-have-adhd`: the first line is something the reader can do, multi-step work is numbered, a list longer than 5 is split, the page ends on a visible win.
-### Lead with the problem, then solve it
-
-Every page opens with the problem the reader came for, in their own words, before any API. Name the situation they are stuck in. Then say in a sentence or two how the feature solves it. Only after that do you go into the technical parts and the code.
+### Open with the call
-A reader who sees the problem first knows in seconds whether they are on the right page. A reader who hits an API signature first has to reverse-engineer what it is even for.
-
-The first line of the how is the next action (from `i-have-adhd`). The problem still comes first so they know they are on the right page.
+The first one or two sentences name the call and what the reader gets from it. A reader who lands from search knows in one line whether this page has their answer.
```text
Bad:
- Call useInterrupts() and pass a resolver. The resolver runs once per
- pending item inside a transaction...
+ Some tool calls shouldn't run without a human saying yes: moving money,
+ deleting data. An interrupt pauses the run for that decision...
Good:
- Some tool calls shouldn't run without a human saying yes: moving money,
- deleting data. An interrupt pauses the run for that decision, then picks up
- where it left off. Here is how to gate a tool behind an approval:
+ Set `needsApproval: true` on a tool. The run pauses before the tool runs,
+ and continues after a person approves it.
- [code]
+Good:
+ `chat()` returns each token while the model writes the reply. `useChat`
+ renders those tokens. Call `stop()` to cancel the run.
```
-The order for a guide is problem, one-line fix, then the how (steps, snippets, API). Keep the problem to a couple of sentences, not a background essay. The code shows how it is solved, so do not narrate the solution in prose first.
+The opener is the page's only summary. The next line starts the steps or the code. Each idea in the opener is said once on the page, so the paragraph after it adds something new.
+
+When the reader must know a limit to pick the right page, put it in the opener: "`chat()` with `outputSchema` returns one typed object. It does not stream or run tools."
+
+The order for a guide is the call, then the steps and code. The code shows how it works, so the prose between snippets stays short.
### Show, do not tell
@@ -309,10 +247,9 @@ If a list grows past five items, split it. Put "do now" first. Put the rest on a
### Page shape for a guide
-Problem, fix, steps, done.
+Call, steps, done.
-- Open with the problem the reader has, in their words, and what they will have at the end.
-- Say in a sentence how the feature solves it.
+- Open with the call and what the reader gets from it, in one or two sentences.
- Walk through steps they can follow and test as they go, with the code doing the explaining.
- End when they reach the goal. Show what now works. Do not close with a vague "next steps" dump.
@@ -397,7 +334,8 @@ Then run the `simple-english` self-check on the same added text.
| You catch yourself | Do instead |
|---|---|
-| Opening with an API signature or config before the problem | Name the problem the reader has first, then the one-line fix, then the code. |
+| Opening with a scene, a background story, or "In this guide" | Open with the call and its result in one or two sentences, then the steps. |
+| A second paragraph that restates the opener | Cut it. The paragraph after the opener starts the steps or adds a new fact. |
| Writing three paragraphs before any code | Cut to one sentence plus a snippet. Show it. |
| Writing a sentence that names two or more things and explains each | Make it a bulleted list, one item per bullet. |
| A paragraph with commas or semicolons separating a set of options/stores/flags | Same fix: that is a list, format it as one. |
@@ -407,9 +345,9 @@ Then run the `simple-english` self-check on the same added text.
| Reaching for an em dash | Rewrite with a comma, period, or parentheses. |
| "The surrounding pages do X, so I will too" where X breaks a rule here | Match neighbors on structure only. Follow this skill on everything it covers, and raise the existing violations as their own cleanup. |
| Using a big word (utilize, leverage, facilitate) | Swap in the plain word. `simple-english` owns this. |
-| One page for everyone | Name the readers. Give each a path or a page. Show the list. Stop. |
+| One page for everyone | Name the readers. Give each a path or a page. State the list. |
| Posting a snippet "they can adapt" | Make it complete and runnable. |
-| Guessing where docs go | Find the docs folder, or ask. Read neighbors first. |
+| Guessing where docs go | Find the docs folder. Read neighbors first. |
| Calling a feature done with no doc change | If behavior changed, docs change too. Write them before you finish. |
| Planning a feature without a doc-impact list | Add the list of new and changed docs to the plan. Name the reader for each page. |
| "Unlike the old X, this now..." / "we renamed X to Y" | The reader never saw X. Describe only what ships now. |
@@ -417,8 +355,5 @@ Then run the `simple-english` self-check on the same added text.
| Warning readers not to do a thing they never knew was possible (e.g. "don't pass locks as a store") | Delete it. If they never saw the old arrangement, the warning only confuses. Describe the thing in its own terms. |
| Inventing a component the site lacks | Use only components the site already has. |
| Stacking `npm` / `pnpm` / `yarn` in one install fence | Use ``. |
-| Skipping the persona message because readers are obvious | Show the list. Stop. Obvious is not a skip. |
-| Skipping the tone question because neighbors have a voice | Neighbors are the proposed default. Ask. Then stop. |
| Writing pages without loading simple-english and i-have-adhd | Load both. If either is missing, stop. |
-| Treating "write the docs" as permission to skip the gates | Writing docs is why the gates exist. |
-| Listing personas inside a larger plan and continuing | That is not the gate. The gate is a dedicated message that ends the turn. |
+| Asking the user to confirm readers or tone, then ending the turn | Do not ask. State them in one line each and keep writing. |
diff --git a/.changeset/v1-release.md b/.changeset/v1-release.md
new file mode 100644
index 0000000000..3fa6440d11
--- /dev/null
+++ b/.changeset/v1-release.md
@@ -0,0 +1,96 @@
+---
+'@tanstack/ai-acp': major
+'@tanstack/ai-angular': major
+'@tanstack/ai-anthropic': major
+'@tanstack/ai-bedrock': major
+'@tanstack/ai-byteplus': major
+'@tanstack/ai-claude-code': major
+'@tanstack/ai-client': major
+'@tanstack/ai-cloudflare': major
+'@tanstack/ai-code-mode-snippets': major
+'@tanstack/ai-code-mode': major
+'@tanstack/ai-codex': major
+'@tanstack/ai-cohere': major
+'@tanstack/ai-compaction': major
+'@tanstack/ai-computer-local': major
+'@tanstack/ai-computer-playwright': major
+'@tanstack/ai-computer-sandbox': major
+'@tanstack/ai-computer-use': major
+'@tanstack/ai-devtools-core': major
+'@tanstack/ai-durable-stream': major
+'@tanstack/ai-elevenlabs': major
+'@tanstack/ai-event-client': major
+'@tanstack/ai-fal': major
+'@tanstack/ai-gemini': major
+'@tanstack/ai-grok-build': major
+'@tanstack/ai-grok': major
+'@tanstack/ai-groq': major
+'@tanstack/ai-isolate-cloudflare': major
+'@tanstack/ai-isolate-daytona': major
+'@tanstack/ai-isolate-e2b': major
+'@tanstack/ai-isolate-node': major
+'@tanstack/ai-isolate-quickjs-bun': major
+'@tanstack/ai-isolate-quickjs': major
+'@tanstack/ai-llmgateway': major
+'@tanstack/ai-lovable': major
+'@tanstack/ai-mcp': major
+'@tanstack/ai-memory': major
+'@tanstack/ai-mistral': major
+'@tanstack/ai-octane': major
+'@tanstack/ai-ollama': major
+'@tanstack/ai-ollaya': major
+'@tanstack/ai-openai': major
+'@tanstack/ai-opencode': major
+'@tanstack/ai-openrouter': major
+'@tanstack/ai-perplexity': major
+'@tanstack/ai-persistence': major
+'@tanstack/ai-preact': major
+'@tanstack/ai-react': major
+'@tanstack/ai-reactor': major
+'@tanstack/ai-remix': major
+'@tanstack/ai-sandbox-blaxel': major
+'@tanstack/ai-sandbox-boxd': major
+'@tanstack/ai-sandbox-cloudflare': major
+'@tanstack/ai-sandbox-daytona': major
+'@tanstack/ai-sandbox-docker': major
+'@tanstack/ai-sandbox-e2b': major
+'@tanstack/ai-sandbox-local-process': major
+'@tanstack/ai-sandbox-railway': major
+'@tanstack/ai-sandbox-sprites': major
+'@tanstack/ai-sandbox-upstash-box': major
+'@tanstack/ai-sandbox-vercel': major
+'@tanstack/ai-sandbox': major
+'@tanstack/ai-skills': major
+'@tanstack/ai-solid': major
+'@tanstack/ai-svelte': major
+'@tanstack/ai-typesafe': major
+'@tanstack/ai-utils': major
+'@tanstack/ai-vercel-gateway': major
+'@tanstack/ai-vertex': major
+'@tanstack/ai-vue': major
+'@tanstack/ai-worldlabs': major
+'@tanstack/ai': major
+'@tanstack/openai-base': major
+'@tanstack/preact-ai-devtools': major
+'@tanstack/react-ai-devtools': major
+'@tanstack/solid-ai-devtools': major
+'@tanstack/svelte-ai-devtools': major
+---
+
+TanStack AI 1.0.0. This release removes every API that was deprecated in 0.x. Read the upgrade guide before you upgrade: https://tanstack.com/ai/latest/docs/migration/v1
+
+- Removed all `@deprecated` exports, options, fields, and type aliases. Each one has a replacement that already shipped in 0.x.
+- Removed `addToolApprovalResponse()` from `ChatClient` and every chat hook. Resolve the bound `tool-approval` interrupt with `resolveInterrupt` instead. The devtools event `tools:approval:responded` is also removed.
+- Removed the old wire formats: the CUSTOM `approval-requested` and `tool-input-available` events, the `[DONE]` SSE line, the `data` copy of `forwardedProps`, pre-binding interrupt descriptors and their `approval_required` and `client_tool_input` reasons, and the STEP events that adapters sent next to `REASONING_*` events. Use a 1.0 client with a 1.0 server.
+- Removed the `toolName` alias from `TOOL_CALL_START` chunks. Read `toolCallName` instead.
+- `threadId` is now required on `useChat` and every other chat hook, `ChatClient`, the generation hooks and clients, `chat()`, `summarize()`, the generation functions, `codeModeWithSnippets()`, `selectRelevantSnippets()`, `CompactionContext`, and `SummarizeInput`. TanStack AI no longer makes a thread id for you. Pass a stable id for each conversation. `createChatHook` factory options no longer take `threadId`: pass it on each hook call. Octane generation hooks take `threadId` instead of `id`. `@tanstack/ai-sandbox` removes `resolveDurableThreadId` and `DurableThreadIdRequiredError`.
+- The published `@tanstack/ai-octane/ui` types are now strict. `UI.Messages`, `UI.Message`, `UI.Part`, and `UI.Interrupts` type their render children. `ToolProps` types `part.input` and `part.output` for the named tool. `toolsComponents` and `interruptsComponents` are required when the chat options declare tools or named interrupts. Code that relied on the old loose types can fail to compile.
+- A connection adapter's `connect()` and `send()` need the run context (with `threadId`) as their fourth argument. `ChatClient` and the generation clients pass it.
+- `@tanstack/ai-sandbox`: `createToolBridgeCore()`, `startHostToolBridge()`, a `ToolBridgeProvisioner`, and `executeHostTool()` take a required `threadId`. Bridged tools get it in their context.
+- `@tanstack/ai-sandbox`: the harness tool bridge and `executeHostTool()` refuse a tool with `needsApproval`. Before, the tool ran with no approval. Remove `needsApproval`, or run the tool with an HTTP adapter.
+- `stream()` and `rpcStream()` pass the AG-UI `RunAgentInput` for the run as the fourth factory argument, the same body the HTTP adapters send. Read it on the server with `chatParamsFromRequestBody()` or `generationParamsFromBody()`, so `threadId`, `runId`, and interrupt `resume` reach a server function. `RunAgentInput` is exported from `@tanstack/ai/client`.
+- A generation `fetcher` gets `threadId` and `runId` in its second argument, next to `signal` and `headers`. Pass them to the server generation call.
+- `generationParamsFromBody()` and `generationParamsFromRequest()` read the generation input from `forwardedProps`. A server that reads `body.data` gets `undefined`. Read `body.forwardedProps` instead. They also require `threadId` and `runId` and return them as strings, the same as `chatParamsFromRequestBody()` and the AG-UI `RunAgentInput`. A body that holds only the input is rejected.
+- Removed the readers for data saved in old 0.x formats (old Cloudflare run-log records, a bare `UIMessage[]` from client persistence, artifact records without `blobKey`).
+- Removed provider models that are shut down.
+- `@tanstack/ai-react-ui`, `@tanstack/ai-solid-ui`, and `@tanstack/ai-vue-ui` get no 1.0 release. Import from `@tanstack/ai-react/ui`, `@tanstack/ai-solid/ui`, or `@tanstack/ai-vue/ui`.
diff --git a/.claude/skills/add-example-tutorial/SKILL.md b/.claude/skills/add-example-tutorial/SKILL.md
index 9d837628cb..85eec6dc2c 100644
--- a/.claude/skills/add-example-tutorial/SKILL.md
+++ b/.claude/skills/add-example-tutorial/SKILL.md
@@ -41,7 +41,7 @@ Add other files only when this tutorial needs them (a key form, a tool, a store)
## 3. Tutorial page
-Load `docs`. Run its persona and tone gates unless this conversation already chose them.
+Load `docs`. State the readers and the tone it picks, then keep going.
Open with the problem the reader has, why it matters, and how this tutorial solves it (one short block). Then walk through steps. Each step teaches one piece of that solution and says why that piece exists.
diff --git a/.claude/skills/docs/SKILL.md b/.claude/skills/docs/SKILL.md
index 72b5815c31..b35a535e1d 100644
--- a/.claude/skills/docs/SKILL.md
+++ b/.claude/skills/docs/SKILL.md
@@ -1,6 +1,6 @@
---
name: docs
-description: Use when writing, editing, or organizing documentation, when planning what docs a feature needs, and whenever planning or implementing a new feature or change in a repo (docs ship with the code). Also use when tempted to write docs without showing the discovered readers to the user, without asking for tone, or without loading simple-english and i-have-adhd. Triggers on "write docs for X", "document this feature", "add a guide", "update the docs", "reorganize the docs", "plan feature X", "implement X", or /docs.
+description: Use when writing, editing, or organizing documentation, when planning what docs a feature needs, and whenever planning or implementing a new feature or change in a repo (docs ship with the code). Also use when tempted to write docs without naming the readers, without picking a tone, or without loading simple-english and i-have-adhd. Triggers on "write docs for X", "document this feature", "add a guide", "update the docs", "reorganize the docs", "plan feature X", "implement X", or /docs.
---
# docs
@@ -11,19 +11,19 @@ Write docs a real person wants to read. Short, plain, built around someone tryin
Work in two phases. First plan the story: who reads this, what they want, and how many pages it should be. Then write.
-The plan is not private. The user must see the readers and must choose the tone before any page is written.
+Do not ask the user to approve the plan or the tone. Pick sane defaults, state them in one line each, and keep going. The user can correct you after.
```text
Find docs
→ read neighbors
→ Phase 1: readers + pages
- → PERSONA GATE (show the list, then stop)
- → TONE GATE (ask, then stop) [when writing pages]
+ → state the readers + pages (one line each, no question)
+ → state the tone (match neighbors, no question) [when writing pages]
→ load simple-english + i-have-adhd [when writing pages]
→ Phase 2: write
```
-A doc-impact list in a feature plan still runs the persona gate. It does not run the tone gate or load the writing skills until pages are actually written.
+A doc-impact list in a feature plan still names the reader for each page. It does not pick a tone or load the writing skills until pages are actually written.
## When to run
@@ -48,7 +48,7 @@ If either file is missing, stop and tell the user. Do not write the pages withou
How they compose:
-- This skill owns who the page is for, the page split, problem-then-fix, show-don't-tell, no history leak, forbidden glyphs, and neighbor structure.
+- This skill owns who the page is for, the page split, the call-first opener, show-don't-tell, no history leak, forbidden glyphs, and neighbor structure.
- `simple-english` owns the sentences. Use pragmatic mode. Run its self-check before you call a page done.
- `i-have-adhd` owns the shape of the page: next action first, numbered steps, lists capped at 5 (split must vs later, or split the page), no preamble, a visible win at the end.
@@ -59,8 +59,8 @@ When `i-have-adhd` is loaded from this skill, apply it to the pages only. Do not
Before writing anything, find where docs live.
1. Look for a docs folder. Check `docs/` first, then `documentation/`, `content/docs/`, `site/`, `website/docs/`.
-2. Found nothing? Ask the user where docs should go. Do not guess and do not create a folder on a hunch.
-3. Open 2 or 3 existing pages near where the new content belongs. Read them for copy, structure, frontmatter fields, and components. Note the apparent tone as a *candidate* for the tone gate. Do not adopt it yet.
+2. Found nothing? Put the page in the README or the closest existing docs file, and say where you put it. Do not create a new docs folder on a hunch.
+3. Open 2 or 3 existing pages near where the new content belongs. Read them for copy, structure, frontmatter fields, and components. Note their tone. That is the tone you write in.
4. Note which components the site already uses (steps, tabs, callouts, cards, accordions, code groups, and so on). Different sites have different ones.
5. Reuse those components to tell the story. If the site has a steps component, use it for walkthroughs. If it has tabs, use them for framework variations. If the site has none, use plain markdown. Never invent a component the site does not have.
@@ -100,8 +100,6 @@ If there is no page like the one you are about to write, read the closest one yo
Matching neighbors is about **copy, structure, frontmatter, and components**: whether headings are sentence case, how much setup a section gets, which components carry the walkthrough, how code samples are introduced, what the frontmatter fields are.
-It is **not** a substitute for the tone gate. Neighbors can suggest a default. They cannot answer for the user.
-
It is **not** permission to copy a neighbor's bad habits. Everything in [Forbidden](#forbidden) and every rule in this skill still applies to the content you write, no matter what the surrounding pages do. An existing page full of em dashes does not license one more.
So: never reason "the other pages do it, so I will too" about a rule this skill states. If existing pages break a rule and you think the whole set should be brought in line, that is a separate cleanup to raise with the user, not something to settle by quietly matching the violation.
@@ -117,40 +115,11 @@ Do this before you write a word of content.
The page split comes out of this step. Do not skip it.
-### Gate: show the personas
-
-
-The user must see the list. Listing readers in a thought, a todo, or a buried plan is not this gate.
-
-After step 4, send a message that contains only:
-
-1. Each reader, one user story, and the page you will write for them.
-2. One question: confirm, drop a reader, or add one.
-
-If the harness has an AskQuestion (or similar) tool, use it for that question. If it does not, use a numbered list.
-
-Then pick one path:
-
-**Default: stop.** This message is the entire turn. End the turn. Do not write files. Do not start Phase 2. Do not say you will proceed unless they object.
-
-**Skip the wait: continue in this same turn.** Use this path only when one of these is true:
+### State the readers
-- This conversation already has the user's confirmation of this persona list.
-- The user said "use sane defaults", "just write it", or "don't ask questions". Still SHOW the list in this turn, then continue.
-- The change is a tiny copy edit: typo, broken link, code-fence language, or a factual fix. No new page, no new section, no rewrite of a journey.
+After step 4, put the readers and their pages in your message to the user, one line per reader: the user story and the page. Do not ask a question and do not wait. Continue to Phase 2 in the same turn.
-On this path, do not end the turn. After you show the list (or after a tiny copy edit, with no list), continue to the next step.
-
-These are not skips:
-
-- "The readers are obvious."
-- "The user asked for docs, so they do not want a question."
-- "I already named them in the plan."
-- "There is only one reader."
-- "This is part of implementing a feature, keep going."
-
-Writing docs is why this gate exists. It is not a reason to skip it.
-
+The user reads the list and corrects you if a reader is wrong. A list that only lives in your thinking or a todo does not count. Skip the list only for a tiny copy edit (typo, broken link, code-fence language, factual fix).
## Less is more: split, do not cram
@@ -173,47 +142,15 @@ Good: a small set of linked pages
Each page is short and does one thing. The overview stitches them into a story.
-## Gate: ask for tone
-
-This gate sits between Phase 1 and Phase 2. Run it before you write any page. Do not nest it inside Phase 2.
-
-
-This gate runs before any page content is written. It does not run for a doc-impact list that is only a plan.
-
-Neighbors can suggest a default. They cannot answer for the user.
-
-Send a message that contains only:
-
-1. The tone you inferred from neighboring pages, in one line (how formal, how much setup, second person or not).
-2. One question with options: use that tone, more casual, more formal, or the user names another.
+## Pick the tone
-If the harness has an AskQuestion (or similar) tool, use it. If it does not, use a numbered list.
+Use the tone of the neighboring pages. State it in one line (how formal, how much setup, second person or not) and continue. Do not ask.
-Then pick one path:
-
-**Default: stop.** This message is the entire turn. End the turn. Do not write page content.
-
-**Skip the wait: continue in this same turn.** Use this path only when one of these is true:
-
-- This conversation already has the user's tone choice, including an explicit "match the existing pages".
-- The user said "use sane defaults", "just write it", or "don't ask questions". Still STATE the inferred tone in one line, then continue.
-- Tiny copy edit, same carve-out as the persona gate.
-
-On this path, do not end the turn. After you state the tone (or after a tiny copy edit, with no question), continue to Phase 2.
-
-These are not skips:
-
-- "I can tell from the neighbors."
-- "The site already has a voice."
-- "Tone does not matter for a reference page."
-- "I'll match neighbors and mention it later."
-
+If the user already named a tone in this conversation, use theirs.
## Phase 2: write like a human
-Do not write page content until the persona gate has passed, the tone gate has passed, and `simple-english` plus `i-have-adhd` are loaded.
-
-The tone gate is the heading above this one. Run that gate first. Then load the two skills. Then write.
+Do not write page content until you have stated the readers and the tone, and `simple-english` plus `i-have-adhd` are loaded.
Legibility is the goal, above everything else.
@@ -224,28 +161,29 @@ Legibility is the goal, above everything else.
- Second person, action first. Start with what the reader has now and what they will have at the end. No "In this guide we will explore..." openings. Just start.
- Shape the page with `i-have-adhd`: the first line is something the reader can do, multi-step work is numbered, a list longer than 5 is split, the page ends on a visible win.
-### Lead with the problem, then solve it
-
-Every page opens with the problem the reader came for, in their own words, before any API. Name the situation they are stuck in. Then say in a sentence or two how the feature solves it. Only after that do you go into the technical parts and the code.
+### Open with the call
-A reader who sees the problem first knows in seconds whether they are on the right page. A reader who hits an API signature first has to reverse-engineer what it is even for.
-
-The first line of the how is the next action (from `i-have-adhd`). The problem still comes first so they know they are on the right page.
+The first one or two sentences name the call and what the reader gets from it. A reader who lands from search knows in one line whether this page has their answer.
```text
Bad:
- Call useInterrupts() and pass a resolver. The resolver runs once per
- pending item inside a transaction...
+ Some tool calls shouldn't run without a human saying yes: moving money,
+ deleting data. An interrupt pauses the run for that decision...
Good:
- Some tool calls shouldn't run without a human saying yes: moving money,
- deleting data. An interrupt pauses the run for that decision, then picks up
- where it left off. Here is how to gate a tool behind an approval:
+ Set `needsApproval: true` on a tool. The run pauses before the tool runs,
+ and continues after a person approves it.
- [code]
+Good:
+ `chat()` returns each token while the model writes the reply. `useChat`
+ renders those tokens. Call `stop()` to cancel the run.
```
-The order for a guide is problem, one-line fix, then the how (steps, snippets, API). Keep the problem to a couple of sentences, not a background essay. The code shows how it is solved, so do not narrate the solution in prose first.
+The opener is the page's only summary. The next line starts the steps or the code. Each idea in the opener is said once on the page, so the paragraph after it adds something new.
+
+When the reader must know a limit to pick the right page, put it in the opener: "`chat()` with `outputSchema` returns one typed object. It does not stream or run tools."
+
+The order for a guide is the call, then the steps and code. The code shows how it works, so the prose between snippets stays short.
### Show, do not tell
@@ -309,10 +247,9 @@ If a list grows past five items, split it. Put "do now" first. Put the rest on a
### Page shape for a guide
-Problem, fix, steps, done.
+Call, steps, done.
-- Open with the problem the reader has, in their words, and what they will have at the end.
-- Say in a sentence how the feature solves it.
+- Open with the call and what the reader gets from it, in one or two sentences.
- Walk through steps they can follow and test as they go, with the code doing the explaining.
- End when they reach the goal. Show what now works. Do not close with a vague "next steps" dump.
@@ -397,7 +334,8 @@ Then run the `simple-english` self-check on the same added text.
| You catch yourself | Do instead |
|---|---|
-| Opening with an API signature or config before the problem | Name the problem the reader has first, then the one-line fix, then the code. |
+| Opening with a scene, a background story, or "In this guide" | Open with the call and its result in one or two sentences, then the steps. |
+| A second paragraph that restates the opener | Cut it. The paragraph after the opener starts the steps or adds a new fact. |
| Writing three paragraphs before any code | Cut to one sentence plus a snippet. Show it. |
| Writing a sentence that names two or more things and explains each | Make it a bulleted list, one item per bullet. |
| A paragraph with commas or semicolons separating a set of options/stores/flags | Same fix: that is a list, format it as one. |
@@ -407,9 +345,9 @@ Then run the `simple-english` self-check on the same added text.
| Reaching for an em dash | Rewrite with a comma, period, or parentheses. |
| "The surrounding pages do X, so I will too" where X breaks a rule here | Match neighbors on structure only. Follow this skill on everything it covers, and raise the existing violations as their own cleanup. |
| Using a big word (utilize, leverage, facilitate) | Swap in the plain word. `simple-english` owns this. |
-| One page for everyone | Name the readers. Give each a path or a page. Show the list. Stop. |
+| One page for everyone | Name the readers. Give each a path or a page. State the list. |
| Posting a snippet "they can adapt" | Make it complete and runnable. |
-| Guessing where docs go | Find the docs folder, or ask. Read neighbors first. |
+| Guessing where docs go | Find the docs folder. Read neighbors first. |
| Calling a feature done with no doc change | If behavior changed, docs change too. Write them before you finish. |
| Planning a feature without a doc-impact list | Add the list of new and changed docs to the plan. Name the reader for each page. |
| "Unlike the old X, this now..." / "we renamed X to Y" | The reader never saw X. Describe only what ships now. |
@@ -417,8 +355,5 @@ Then run the `simple-english` self-check on the same added text.
| Warning readers not to do a thing they never knew was possible (e.g. "don't pass locks as a store") | Delete it. If they never saw the old arrangement, the warning only confuses. Describe the thing in its own terms. |
| Inventing a component the site lacks | Use only components the site already has. |
| Stacking `npm` / `pnpm` / `yarn` in one install fence | Use ``. |
-| Skipping the persona message because readers are obvious | Show the list. Stop. Obvious is not a skip. |
-| Skipping the tone question because neighbors have a voice | Neighbors are the proposed default. Ask. Then stop. |
| Writing pages without loading simple-english and i-have-adhd | Load both. If either is missing, stop. |
-| Treating "write the docs" as permission to skip the gates | Writing docs is why the gates exist. |
-| Listing personas inside a larger plan and continuing | That is not the gate. The gate is a dedicated message that ends the turn. |
+| Asking the user to confirm readers or tone, then ending the turn | Do not ask. State them in one line each and keep writing. |
diff --git a/.grok/skills/add-example-tutorial/SKILL.md b/.grok/skills/add-example-tutorial/SKILL.md
index 9d837628cb..85eec6dc2c 100644
--- a/.grok/skills/add-example-tutorial/SKILL.md
+++ b/.grok/skills/add-example-tutorial/SKILL.md
@@ -41,7 +41,7 @@ Add other files only when this tutorial needs them (a key form, a tool, a store)
## 3. Tutorial page
-Load `docs`. Run its persona and tone gates unless this conversation already chose them.
+Load `docs`. State the readers and the tone it picks, then keep going.
Open with the problem the reader has, why it matters, and how this tutorial solves it (one short block). Then walk through steps. Each step teaches one piece of that solution and says why that piece exists.
diff --git a/.grok/skills/docs/SKILL.md b/.grok/skills/docs/SKILL.md
index 72b5815c31..b35a535e1d 100644
--- a/.grok/skills/docs/SKILL.md
+++ b/.grok/skills/docs/SKILL.md
@@ -1,6 +1,6 @@
---
name: docs
-description: Use when writing, editing, or organizing documentation, when planning what docs a feature needs, and whenever planning or implementing a new feature or change in a repo (docs ship with the code). Also use when tempted to write docs without showing the discovered readers to the user, without asking for tone, or without loading simple-english and i-have-adhd. Triggers on "write docs for X", "document this feature", "add a guide", "update the docs", "reorganize the docs", "plan feature X", "implement X", or /docs.
+description: Use when writing, editing, or organizing documentation, when planning what docs a feature needs, and whenever planning or implementing a new feature or change in a repo (docs ship with the code). Also use when tempted to write docs without naming the readers, without picking a tone, or without loading simple-english and i-have-adhd. Triggers on "write docs for X", "document this feature", "add a guide", "update the docs", "reorganize the docs", "plan feature X", "implement X", or /docs.
---
# docs
@@ -11,19 +11,19 @@ Write docs a real person wants to read. Short, plain, built around someone tryin
Work in two phases. First plan the story: who reads this, what they want, and how many pages it should be. Then write.
-The plan is not private. The user must see the readers and must choose the tone before any page is written.
+Do not ask the user to approve the plan or the tone. Pick sane defaults, state them in one line each, and keep going. The user can correct you after.
```text
Find docs
→ read neighbors
→ Phase 1: readers + pages
- → PERSONA GATE (show the list, then stop)
- → TONE GATE (ask, then stop) [when writing pages]
+ → state the readers + pages (one line each, no question)
+ → state the tone (match neighbors, no question) [when writing pages]
→ load simple-english + i-have-adhd [when writing pages]
→ Phase 2: write
```
-A doc-impact list in a feature plan still runs the persona gate. It does not run the tone gate or load the writing skills until pages are actually written.
+A doc-impact list in a feature plan still names the reader for each page. It does not pick a tone or load the writing skills until pages are actually written.
## When to run
@@ -48,7 +48,7 @@ If either file is missing, stop and tell the user. Do not write the pages withou
How they compose:
-- This skill owns who the page is for, the page split, problem-then-fix, show-don't-tell, no history leak, forbidden glyphs, and neighbor structure.
+- This skill owns who the page is for, the page split, the call-first opener, show-don't-tell, no history leak, forbidden glyphs, and neighbor structure.
- `simple-english` owns the sentences. Use pragmatic mode. Run its self-check before you call a page done.
- `i-have-adhd` owns the shape of the page: next action first, numbered steps, lists capped at 5 (split must vs later, or split the page), no preamble, a visible win at the end.
@@ -59,8 +59,8 @@ When `i-have-adhd` is loaded from this skill, apply it to the pages only. Do not
Before writing anything, find where docs live.
1. Look for a docs folder. Check `docs/` first, then `documentation/`, `content/docs/`, `site/`, `website/docs/`.
-2. Found nothing? Ask the user where docs should go. Do not guess and do not create a folder on a hunch.
-3. Open 2 or 3 existing pages near where the new content belongs. Read them for copy, structure, frontmatter fields, and components. Note the apparent tone as a *candidate* for the tone gate. Do not adopt it yet.
+2. Found nothing? Put the page in the README or the closest existing docs file, and say where you put it. Do not create a new docs folder on a hunch.
+3. Open 2 or 3 existing pages near where the new content belongs. Read them for copy, structure, frontmatter fields, and components. Note their tone. That is the tone you write in.
4. Note which components the site already uses (steps, tabs, callouts, cards, accordions, code groups, and so on). Different sites have different ones.
5. Reuse those components to tell the story. If the site has a steps component, use it for walkthroughs. If it has tabs, use them for framework variations. If the site has none, use plain markdown. Never invent a component the site does not have.
@@ -100,8 +100,6 @@ If there is no page like the one you are about to write, read the closest one yo
Matching neighbors is about **copy, structure, frontmatter, and components**: whether headings are sentence case, how much setup a section gets, which components carry the walkthrough, how code samples are introduced, what the frontmatter fields are.
-It is **not** a substitute for the tone gate. Neighbors can suggest a default. They cannot answer for the user.
-
It is **not** permission to copy a neighbor's bad habits. Everything in [Forbidden](#forbidden) and every rule in this skill still applies to the content you write, no matter what the surrounding pages do. An existing page full of em dashes does not license one more.
So: never reason "the other pages do it, so I will too" about a rule this skill states. If existing pages break a rule and you think the whole set should be brought in line, that is a separate cleanup to raise with the user, not something to settle by quietly matching the violation.
@@ -117,40 +115,11 @@ Do this before you write a word of content.
The page split comes out of this step. Do not skip it.
-### Gate: show the personas
-
-
-The user must see the list. Listing readers in a thought, a todo, or a buried plan is not this gate.
-
-After step 4, send a message that contains only:
-
-1. Each reader, one user story, and the page you will write for them.
-2. One question: confirm, drop a reader, or add one.
-
-If the harness has an AskQuestion (or similar) tool, use it for that question. If it does not, use a numbered list.
-
-Then pick one path:
-
-**Default: stop.** This message is the entire turn. End the turn. Do not write files. Do not start Phase 2. Do not say you will proceed unless they object.
-
-**Skip the wait: continue in this same turn.** Use this path only when one of these is true:
+### State the readers
-- This conversation already has the user's confirmation of this persona list.
-- The user said "use sane defaults", "just write it", or "don't ask questions". Still SHOW the list in this turn, then continue.
-- The change is a tiny copy edit: typo, broken link, code-fence language, or a factual fix. No new page, no new section, no rewrite of a journey.
+After step 4, put the readers and their pages in your message to the user, one line per reader: the user story and the page. Do not ask a question and do not wait. Continue to Phase 2 in the same turn.
-On this path, do not end the turn. After you show the list (or after a tiny copy edit, with no list), continue to the next step.
-
-These are not skips:
-
-- "The readers are obvious."
-- "The user asked for docs, so they do not want a question."
-- "I already named them in the plan."
-- "There is only one reader."
-- "This is part of implementing a feature, keep going."
-
-Writing docs is why this gate exists. It is not a reason to skip it.
-
+The user reads the list and corrects you if a reader is wrong. A list that only lives in your thinking or a todo does not count. Skip the list only for a tiny copy edit (typo, broken link, code-fence language, factual fix).
## Less is more: split, do not cram
@@ -173,47 +142,15 @@ Good: a small set of linked pages
Each page is short and does one thing. The overview stitches them into a story.
-## Gate: ask for tone
-
-This gate sits between Phase 1 and Phase 2. Run it before you write any page. Do not nest it inside Phase 2.
-
-
-This gate runs before any page content is written. It does not run for a doc-impact list that is only a plan.
-
-Neighbors can suggest a default. They cannot answer for the user.
-
-Send a message that contains only:
-
-1. The tone you inferred from neighboring pages, in one line (how formal, how much setup, second person or not).
-2. One question with options: use that tone, more casual, more formal, or the user names another.
+## Pick the tone
-If the harness has an AskQuestion (or similar) tool, use it. If it does not, use a numbered list.
+Use the tone of the neighboring pages. State it in one line (how formal, how much setup, second person or not) and continue. Do not ask.
-Then pick one path:
-
-**Default: stop.** This message is the entire turn. End the turn. Do not write page content.
-
-**Skip the wait: continue in this same turn.** Use this path only when one of these is true:
-
-- This conversation already has the user's tone choice, including an explicit "match the existing pages".
-- The user said "use sane defaults", "just write it", or "don't ask questions". Still STATE the inferred tone in one line, then continue.
-- Tiny copy edit, same carve-out as the persona gate.
-
-On this path, do not end the turn. After you state the tone (or after a tiny copy edit, with no question), continue to Phase 2.
-
-These are not skips:
-
-- "I can tell from the neighbors."
-- "The site already has a voice."
-- "Tone does not matter for a reference page."
-- "I'll match neighbors and mention it later."
-
+If the user already named a tone in this conversation, use theirs.
## Phase 2: write like a human
-Do not write page content until the persona gate has passed, the tone gate has passed, and `simple-english` plus `i-have-adhd` are loaded.
-
-The tone gate is the heading above this one. Run that gate first. Then load the two skills. Then write.
+Do not write page content until you have stated the readers and the tone, and `simple-english` plus `i-have-adhd` are loaded.
Legibility is the goal, above everything else.
@@ -224,28 +161,29 @@ Legibility is the goal, above everything else.
- Second person, action first. Start with what the reader has now and what they will have at the end. No "In this guide we will explore..." openings. Just start.
- Shape the page with `i-have-adhd`: the first line is something the reader can do, multi-step work is numbered, a list longer than 5 is split, the page ends on a visible win.
-### Lead with the problem, then solve it
-
-Every page opens with the problem the reader came for, in their own words, before any API. Name the situation they are stuck in. Then say in a sentence or two how the feature solves it. Only after that do you go into the technical parts and the code.
+### Open with the call
-A reader who sees the problem first knows in seconds whether they are on the right page. A reader who hits an API signature first has to reverse-engineer what it is even for.
-
-The first line of the how is the next action (from `i-have-adhd`). The problem still comes first so they know they are on the right page.
+The first one or two sentences name the call and what the reader gets from it. A reader who lands from search knows in one line whether this page has their answer.
```text
Bad:
- Call useInterrupts() and pass a resolver. The resolver runs once per
- pending item inside a transaction...
+ Some tool calls shouldn't run without a human saying yes: moving money,
+ deleting data. An interrupt pauses the run for that decision...
Good:
- Some tool calls shouldn't run without a human saying yes: moving money,
- deleting data. An interrupt pauses the run for that decision, then picks up
- where it left off. Here is how to gate a tool behind an approval:
+ Set `needsApproval: true` on a tool. The run pauses before the tool runs,
+ and continues after a person approves it.
- [code]
+Good:
+ `chat()` returns each token while the model writes the reply. `useChat`
+ renders those tokens. Call `stop()` to cancel the run.
```
-The order for a guide is problem, one-line fix, then the how (steps, snippets, API). Keep the problem to a couple of sentences, not a background essay. The code shows how it is solved, so do not narrate the solution in prose first.
+The opener is the page's only summary. The next line starts the steps or the code. Each idea in the opener is said once on the page, so the paragraph after it adds something new.
+
+When the reader must know a limit to pick the right page, put it in the opener: "`chat()` with `outputSchema` returns one typed object. It does not stream or run tools."
+
+The order for a guide is the call, then the steps and code. The code shows how it works, so the prose between snippets stays short.
### Show, do not tell
@@ -309,10 +247,9 @@ If a list grows past five items, split it. Put "do now" first. Put the rest on a
### Page shape for a guide
-Problem, fix, steps, done.
+Call, steps, done.
-- Open with the problem the reader has, in their words, and what they will have at the end.
-- Say in a sentence how the feature solves it.
+- Open with the call and what the reader gets from it, in one or two sentences.
- Walk through steps they can follow and test as they go, with the code doing the explaining.
- End when they reach the goal. Show what now works. Do not close with a vague "next steps" dump.
@@ -397,7 +334,8 @@ Then run the `simple-english` self-check on the same added text.
| You catch yourself | Do instead |
|---|---|
-| Opening with an API signature or config before the problem | Name the problem the reader has first, then the one-line fix, then the code. |
+| Opening with a scene, a background story, or "In this guide" | Open with the call and its result in one or two sentences, then the steps. |
+| A second paragraph that restates the opener | Cut it. The paragraph after the opener starts the steps or adds a new fact. |
| Writing three paragraphs before any code | Cut to one sentence plus a snippet. Show it. |
| Writing a sentence that names two or more things and explains each | Make it a bulleted list, one item per bullet. |
| A paragraph with commas or semicolons separating a set of options/stores/flags | Same fix: that is a list, format it as one. |
@@ -407,9 +345,9 @@ Then run the `simple-english` self-check on the same added text.
| Reaching for an em dash | Rewrite with a comma, period, or parentheses. |
| "The surrounding pages do X, so I will too" where X breaks a rule here | Match neighbors on structure only. Follow this skill on everything it covers, and raise the existing violations as their own cleanup. |
| Using a big word (utilize, leverage, facilitate) | Swap in the plain word. `simple-english` owns this. |
-| One page for everyone | Name the readers. Give each a path or a page. Show the list. Stop. |
+| One page for everyone | Name the readers. Give each a path or a page. State the list. |
| Posting a snippet "they can adapt" | Make it complete and runnable. |
-| Guessing where docs go | Find the docs folder, or ask. Read neighbors first. |
+| Guessing where docs go | Find the docs folder. Read neighbors first. |
| Calling a feature done with no doc change | If behavior changed, docs change too. Write them before you finish. |
| Planning a feature without a doc-impact list | Add the list of new and changed docs to the plan. Name the reader for each page. |
| "Unlike the old X, this now..." / "we renamed X to Y" | The reader never saw X. Describe only what ships now. |
@@ -417,8 +355,5 @@ Then run the `simple-english` self-check on the same added text.
| Warning readers not to do a thing they never knew was possible (e.g. "don't pass locks as a store") | Delete it. If they never saw the old arrangement, the warning only confuses. Describe the thing in its own terms. |
| Inventing a component the site lacks | Use only components the site already has. |
| Stacking `npm` / `pnpm` / `yarn` in one install fence | Use ``. |
-| Skipping the persona message because readers are obvious | Show the list. Stop. Obvious is not a skip. |
-| Skipping the tone question because neighbors have a voice | Neighbors are the proposed default. Ask. Then stop. |
| Writing pages without loading simple-english and i-have-adhd | Load both. If either is missing, stop. |
-| Treating "write the docs" as permission to skip the gates | Writing docs is why the gates exist. |
-| Listing personas inside a larger plan and continuing | That is not the gate. The gate is a dedicated message that ends the turn. |
+| Asking the user to confirm readers or tone, then ending the turn | Do not ask. State them in one line each and keep writing. |
diff --git a/CLAUDE.md b/CLAUDE.md
index 826f2b6a6e..877e909bfb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -172,7 +172,6 @@ The `@tanstack/ai` package provides core functions:
- **`chat()`** - Chat completion with streaming, tools, and agent loops
- **`embedding()`** - Generate embeddings
- **`summarize()`** - Summarize text
-- Legacy adapters (monolithic, deprecated) use `openai()`, `anthropic()`, etc.
#### 3. Isomorphic Tool System
@@ -228,7 +227,6 @@ Each framework integration uses the headless `ai-client` under the hood.
- **`index.ts`** - Exports tree-shakeable adapters (openaiText, openaiEmbed, etc.)
- **`adapters/`** - Individual adapter implementations (text.ts, embed.ts, summarize.ts, image.ts)
- **`model-meta.ts`** - Model metadata for type safety (provider options per model)
-- **`openai-adapter.ts`** - Legacy monolithic adapter (deprecated)
## Development Workflow
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index bfe3bdc4aa..10e7f5a23e 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -133,7 +133,7 @@ Add tests covering the code you changed, so each metric is back at 60% or more.
Two known limitations:
- Uncovered `.tsx` files can't be remapped by the coverage provider and are dropped from the report with a `Failed to parse ... Excluding it from coverage` warning. `.tsx` files that tests _do_ load are measured normally, so the UI packages read higher than their real coverage.
-- `ai-react-ui`, `ai-solid-ui`, `ai-vue-ui`, `preact-ai-devtools`, `react-ai-devtools`, `solid-ai-devtools`, and `svelte-ai-devtools` have no tests, so they have no `test:coverage` script. The collect step fails when a measured package reports 0 statements. Add the script back when the package gets tests.
+- `preact-ai-devtools`, `react-ai-devtools`, `solid-ai-devtools`, and `svelte-ai-devtools` have no tests, so they have no `test:coverage` script. The collect step fails when a measured package reports 0 statements. Add the script back when the package gets tests.
A **new package that defines `test:coverage`** shows as `new` and cannot fail the comparison. Packages without that script are not measured.
diff --git a/README.md b/README.md
index de16e634f7..40f036e06e 100644
--- a/README.md
+++ b/README.md
@@ -146,6 +146,7 @@ export async function POST(request: Request) {
const stream = chat({
adapter: openaiText('gpt-5.2'),
messages: body.messages,
+ threadId: body.threadId,
})
return toServerSentEventsResponse(stream)
@@ -203,6 +204,7 @@ const Person = z.object({
const person = await chat({
adapter: openaiText('gpt-5.2'),
messages: [{ role: 'user', content: 'Ada Lovelace, 36' }],
+ threadId: 'thread-1',
outputSchema: Person,
})
```
@@ -249,15 +251,15 @@ on.
## Framework Packages
-| Package | What it provides |
-| ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
-| [`@tanstack/ai-client`](https://tanstack.com/ai/latest/docs/api/ai-client) | Headless chat, realtime, and generation clients |
-| [`@tanstack/ai-react`](https://tanstack.com/ai/latest/docs/api/ai-react) | React hooks including `useChat`, `useRealtimeChat`, and generation hooks |
-| [`@tanstack/ai-solid`](https://tanstack.com/ai/latest/docs/api/ai-solid) | Solid hooks for chat and generations |
-| [`@tanstack/ai-vue`](https://tanstack.com/ai/latest/docs/api/ai-vue) | Vue composables for chat and generations |
-| [`@tanstack/ai-svelte`](https://tanstack.com/ai/latest/docs/api/ai-svelte) | Svelte 5 factories for chat and generations |
-| [`@tanstack/ai-preact`](https://tanstack.com/ai/latest/docs/api/ai-preact) | Preact hooks for chat |
-| `@tanstack/ai-react/ui`, `@tanstack/ai-solid/ui`, `@tanstack/ai-vue/ui`, `@tanstack/ai-svelte/ui` | Headless UI components for chat interfaces. `@tanstack/ai-react-ui`, `@tanstack/ai-solid-ui`, and `@tanstack/ai-vue-ui` are deprecated. |
+| Package | What it provides |
+| ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
+| [`@tanstack/ai-client`](https://tanstack.com/ai/latest/docs/api/ai-client) | Headless chat, realtime, and generation clients |
+| [`@tanstack/ai-react`](https://tanstack.com/ai/latest/docs/api/ai-react) | React hooks including `useChat`, `useRealtimeChat`, and generation hooks |
+| [`@tanstack/ai-solid`](https://tanstack.com/ai/latest/docs/api/ai-solid) | Solid hooks for chat and generations |
+| [`@tanstack/ai-vue`](https://tanstack.com/ai/latest/docs/api/ai-vue) | Vue composables for chat and generations |
+| [`@tanstack/ai-svelte`](https://tanstack.com/ai/latest/docs/api/ai-svelte) | Svelte 5 factories for chat and generations |
+| [`@tanstack/ai-preact`](https://tanstack.com/ai/latest/docs/api/ai-preact) | Preact hooks for chat |
+| `@tanstack/ai-react/ui`, `@tanstack/ai-solid/ui`, `@tanstack/ai-vue/ui`, `@tanstack/ai-svelte/ui` | Headless UI components for chat interfaces. |
## Advanced Docs
diff --git a/codemods/README.md b/codemods/README.md
index c207f2ea4c..2a7ac8bec6 100644
--- a/codemods/README.md
+++ b/codemods/README.md
@@ -2,7 +2,7 @@
[jscodeshift](https://github.com/facebook/jscodeshift) transforms for migrating between API versions of `@tanstack/ai` and friends.
-Each codemod lives in its own subdirectory and is named after the migration it covers. Codemods are **opt-in modernizations** — the deprecated APIs they replace continue to work, so you can run them at your own pace.
+Each codemod lives in its own subdirectory and is named after the migration it covers. In 1.0 the old names that these codemods replace are removed, so run the codemods as part of the upgrade.
## Available codemods
diff --git a/codemods/ag-ui-compliance/README.md b/codemods/ag-ui-compliance/README.md
index dbfc27077f..72e46c5a46 100644
--- a/codemods/ag-ui-compliance/README.md
+++ b/codemods/ag-ui-compliance/README.md
@@ -2,7 +2,7 @@
Migrates client-side TanStack AI code from the legacy field names to the AG-UI–compliant ones introduced in the AG-UI client/server compliance release.
-> **Heads up — nothing breaks if you skip this.** The legacy names continue to work via deprecation bridges. Run this codemod when you want to clean up your codebase and remove deprecation warnings. See [`docs/migration/ag-ui-compliance.md`](../../docs/migration/ag-ui-compliance.md) for the full migration story.
+> **Heads up: 1.0 removes the legacy names.** Run this codemod before you upgrade to 1.0. See [`docs/migration/v1.md`](../../docs/migration/v1.md) for the full 1.0 upgrade and [`docs/migration/ag-ui-compliance.md`](../../docs/migration/ag-ui-compliance.md) for the wire change.
## What it does
@@ -20,7 +20,7 @@ Each rename is gated by an **import-source check** — the codemod only rewrites
- **Server-side `body.data.X` rewrites.** Detecting which `body.data.foo` reads belong to a TanStack AI route handler vs. unrelated code requires more context than a syntactic codemod can reliably provide. Migrate these by hand using the recipes in [`docs/migration/ag-ui-compliance.md`](../../docs/migration/ag-ui-compliance.md).
- **Re-exports and aliases.** If you re-export `useChat` from a barrel file (`export { useChat } from '@tanstack/ai-react'`), call sites that import the re-export won't be matched. Update the import to come directly from the framework package, or run the codemod against the barrel file too.
-- **`chat({ conversationId: })` value-source rewrites.** The codemod renames the property _key_ `conversationId` → `threadId` but does NOT rewrite the value expression. If your server reads from a now-stale source (commonly `body.forwardedProps?.conversationId`, which the upgraded client no longer auto-emits), audit the value after the codemod runs and migrate to `body.threadId` (the AG-UI top-level wire field), `params.threadId` from `chatParamsFromRequest`, or omit `threadId` entirely so the runtime auto-generates one.
+- **`chat({ conversationId: })` value-source rewrites.** The codemod renames the property _key_ `conversationId` → `threadId` but does NOT rewrite the value expression. If your server reads from a now-stale source (commonly `body.forwardedProps?.conversationId`, which the upgraded client no longer auto-emits), audit the value after the codemod runs and migrate to `body.threadId` (the AG-UI top-level wire field), or `params.threadId` from `chatParamsFromRequest`. `threadId` is required in 1.0 and nothing generates one, so pass a stable `threadId` for each conversation.
## Running it
diff --git a/codemods/ag-ui-compliance/transform.ts b/codemods/ag-ui-compliance/transform.ts
index d4924f7a90..cb0ebe9d8a 100644
--- a/codemods/ag-ui-compliance/transform.ts
+++ b/codemods/ag-ui-compliance/transform.ts
@@ -1,12 +1,12 @@
/**
* jscodeshift transform: AG-UI client compliance migration
*
- * Renames the deprecated client-side fields introduced by the AG-UI
- * compliance release of `@tanstack/ai`. Each rename is gated by an
+ * Renames the client-side fields that the AG-UI compliance release of
+ * `@tanstack/ai` replaced, and that 1.0 removed. Each rename is gated by an
* import-source check so we don't touch unrelated code that happens
* to share a property name.
*
- * Transforms (all opt-in — the deprecated names keep working):
+ * Transforms (the old names are removed in 1.0):
*
* 1. `useChat({ body })` → `useChat({ forwardedProps })`
* 2. `new ChatClient({ body })` → `new ChatClient({ forwardedProps })`
diff --git a/docs/adapters/acp-compatible.md b/docs/adapters/acp-compatible.md
index b421de3cde..a930ec8770 100644
--- a/docs/adapters/acp-compatible.md
+++ b/docs/adapters/acp-compatible.md
@@ -1,7 +1,7 @@
---
title: ACP-Compatible Harness
id: acp-compatible-harness
-description: "Plug any Agent Client Protocol (ACP) coding agent into a TanStack AI sandbox with one generic harness adapter — no dedicated package required."
+description: "Run any coding agent that speaks the Agent Client Protocol (ACP) inside a TanStack AI sandbox, with one generic harness adapter and no dedicated package."
keywords:
- tanstack ai
- acp
@@ -12,311 +12,224 @@ keywords:
- custom adapter
---
-Coding-agent CLIs that speak the [Agent Client Protocol](https://agentclientprotocol.com) (ACP) — `grok`, `gemini --acp`, and others — expose a long-lived JSON-RPC session you can drive from a sandbox. Instead of a dedicated package per agent, `acpCompatible` builds a `chat()` adapter for **any** ACP-compliant CLI: configure how to launch it once, select a model per call, and pass it into a sandbox.
+`acpCompatible` builds a `chat()` adapter for an ACP agent CLI that has no `@tanstack/ai-*` package. You pass the start command. The adapter runs the agent in a sandbox.
-It is the harness equivalent of the [OpenAI-Compatible adapter](./openai-compatible). Use it when your agent speaks ACP but has no `@tanstack/ai-*` package. If a dedicated harness adapter exists ([Grok Build](./grok-build), and others), prefer it — those carry curated per-model metadata and vendor-specific behavior.
+It is the harness version of the [OpenAI-Compatible adapter](./openai-compatible). Does a dedicated harness adapter exist for your agent, such as [Grok Build](./grok-build)? Use that one. It knows the models and the special behavior of its agent.
-## Authentication
+You need:
-The default `authMode` is `'api-key'`. Set `'host'` when the agent should use
-a CLI login on the machine. The sandbox type does not pick this. See
-[Harness Auth](../sandbox/auth).
+- A sandbox provider. The samples use Docker, so Docker must run on your machine.
+- An agent CLI that runs an ACP server over stdio, installed inside the sandbox. The samples use the `grok` CLI.
+- The API key of that agent, or a CLI login on the machine.
-```ts
-import { acpCompatibleText } from "@tanstack/ai-acp"
-
-acpCompatibleText("composer-2.5", {
- name: "acp",
- command: ({ model }) => `grok agent -m '${model}' --always-approve stdio`,
- authMethodId: "xai.api_key",
-})
-
-acpCompatibleText("composer-2.5", {
- name: "acp",
- command: ({ model }) => `grok agent -m '${model}' --always-approve stdio`,
- authMode: "host",
-})
-```
+## 1. Install
-- `'api-key'` (default): call `authenticate` with `authMethodId`.
-- `'host'`: skip ACP `authenticate`. Use the CLI login on the machine.
+
-Pass `outputSchema` on the same `chat()` call. `acpCompatible` adds the schema to the prompt and parses the last assistant text. Native harness tools still run on that turn. Read the typed object from `await chat()`, from `useChat().final`, or from the assistant `structured-output` part. See [Harness Agents](../structured-outputs/harnesses).
+react: @tanstack/ai @tanstack/ai-acp @tanstack/ai-sandbox @tanstack/ai-sandbox-docker @tanstack/ai-grok-build
+vue: @tanstack/ai @tanstack/ai-acp @tanstack/ai-sandbox @tanstack/ai-sandbox-docker @tanstack/ai-grok-build
+solid: @tanstack/ai @tanstack/ai-acp @tanstack/ai-sandbox @tanstack/ai-sandbox-docker @tanstack/ai-grok-build
+svelte: @tanstack/ai @tanstack/ai-acp @tanstack/ai-sandbox @tanstack/ai-sandbox-docker @tanstack/ai-grok-build
+preact: @tanstack/ai @tanstack/ai-acp @tanstack/ai-sandbox @tanstack/ai-sandbox-docker @tanstack/ai-grok-build
+angular: @tanstack/ai @tanstack/ai-acp @tanstack/ai-sandbox @tanstack/ai-sandbox-docker @tanstack/ai-grok-build
+vanilla: @tanstack/ai @tanstack/ai-acp @tanstack/ai-sandbox @tanstack/ai-sandbox-docker @tanstack/ai-grok-build
+octane: @tanstack/ai @tanstack/ai-acp @tanstack/ai-sandbox @tanstack/ai-sandbox-docker @tanstack/ai-grok-build
-## Installation
+
-`acpCompatible` ships in `@tanstack/ai-acp`. You drive it inside a sandbox, so install the sandbox package and a provider too:
+The samples import `GROK_CLI_INSTALL_COMMAND` from `@tanstack/ai-grok-build`, to install the `grok` CLI in the sandbox. For another agent, use its own install command and skip that package.
-
+## 2. Set the API key
-react: @tanstack/ai-acp @tanstack/ai @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
-vue: @tanstack/ai-acp @tanstack/ai @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
-solid: @tanstack/ai-acp @tanstack/ai @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
-svelte: @tanstack/ai-acp @tanstack/ai @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
-preact: @tanstack/ai-acp @tanstack/ai @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
-angular: @tanstack/ai-acp @tanstack/ai @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
-vanilla: @tanstack/ai-acp @tanstack/ai @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
-octane: @tanstack/ai-acp @tanstack/ai @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
+Put the key of your agent in the environment of your server. For the `grok` CLI:
-
+```bash
+XAI_API_KEY=xai-...
+```
+
+The sandbox in step 3 gives this key to the agent as a workspace secret. Then the adapter signs in through ACP `authenticate`, with the auth method that you name in `authMethodId`. For Grok, it is `xai.api_key`.
-## Basic Usage
+Did the machine already log in to the agent CLI? Set `authMode: 'host'`. The adapter then skips ACP `authenticate`. See [Harness Auth](../sandbox/auth).
-Configure the harness once with `acpCompatible({ name, command })`, then select a model per call. `command` builds the shell command that launches the agent's ACP server over **stdio** inside the sandbox:
+## 3. Run the agent from the server
+
+Define the sandbox once. It clones your repo into a Docker container and installs the agent CLI:
```ts
-import { chat } from '@tanstack/ai'
-import { acpCompatible } from '@tanstack/ai-acp'
+// sandbox.ts
+import { GROK_CLI_INSTALL_COMMAND } from "@tanstack/ai-grok-build";
import {
createSecrets,
defineSandbox,
defineWorkspace,
githubRepo,
- withSandbox,
-} from '@tanstack/ai-sandbox'
-import { dockerSandbox } from '@tanstack/ai-sandbox-docker'
-import { messages } from './chat-context'
-
-// Configure the "pi" agent harness once:
-const pi = acpCompatible({
- name: 'pi',
- command: ({ model, harnessCwd }) => `pi --acp -m ${model} --cwd ${harnessCwd}`,
- authMethodId: 'pi-api-key', // when the harness advertises an ACP auth method
- refusalMessage: 'Pi refused the request.',
-})
-
-const sandbox = defineSandbox({
- id: 'pi-agent',
- provider: dockerSandbox({ image: 'node:22' }),
- workspace: defineWorkspace({
- source: githubRepo({ repo: 'owner/app' }),
- setup: ['npm install -g pi-cli'], // install the agent CLI into the image
- secrets: createSecrets({ PI_API_KEY: process.env.PI_API_KEY ?? '' }),
- }),
-})
-
-const stream = chat({
- adapter: pi('pi-fast'),
- messages,
- middleware: [withSandbox(sandbox)],
-})
-```
-
-You get the full ACP flow for free: sandbox resolution, `chat()`-tool → MCP bridging, session resume, permission handling, abort, and AG-UI event translation.
-
-## One-Shot Usage
-
-For a single model, skip the harness-factory and build the adapter inline with `acpCompatibleText`:
-
-```ts
-import { chat } from '@tanstack/ai'
-import { acpCompatibleText } from '@tanstack/ai-acp'
-import { withSandbox } from '@tanstack/ai-sandbox'
-import { sandbox } from './sandbox'
-import { messages } from './chat-context'
+} from "@tanstack/ai-sandbox";
+import { dockerSandbox } from "@tanstack/ai-sandbox-docker";
-const stream = chat({
- adapter: acpCompatibleText('pi-fast', {
- name: 'pi',
- command: ({ model }) => `pi --acp -m ${model}`,
+export const sandbox = defineSandbox({
+ id: "acp-agent",
+ provider: dockerSandbox({ image: "node:22" }),
+ workspace: defineWorkspace({
+ source: githubRepo({ repo: "owner/app" }),
+ setup: [GROK_CLI_INSTALL_COMMAND],
+ secrets: createSecrets({
+ XAI_API_KEY: process.env.XAI_API_KEY ?? "",
+ }),
}),
- messages,
- middleware: [withSandbox(sandbox)],
-})
+});
```
-## Typed output
-
-Pass `outputSchema` on the same `chat()` call. The agent runs its native tools. Then the adapter parses the last assistant text as JSON.
+Then build the adapter with `acpCompatibleText` and pass it to `chat()`. `command` returns the shell command that starts the ACP server of the agent over stdio:
```ts
-import { chat, toServerSentEventsResponse } from '@tanstack/ai'
-import { acpCompatibleText } from '@tanstack/ai-acp'
-import { withSandbox } from '@tanstack/ai-sandbox'
-import { z } from 'zod'
-import { sandbox } from './sandbox'
-import { messages } from './chat-context'
+// api/agent.ts
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
+import { acpCompatibleText } from "@tanstack/ai-acp";
+import { withSandbox } from "@tanstack/ai-sandbox";
+import { sandbox } from "./sandbox";
-const ReportSchema = z.object({
- name: z.string(),
- oneLiner: z.string(),
-})
+export async function POST(request: Request) {
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
-export async function POST() {
const stream = chat({
- adapter: acpCompatibleText('pi-fast', {
- name: 'pi',
- command: ({ model }) => `pi --acp -m ${model}`,
+ adapter: acpCompatibleText("composer-2.5", {
+ name: "acp",
+ command: ({ model }) => `grok agent -m '${model}' --always-approve stdio`,
+ authMethodId: "xai.api_key",
}),
messages,
- outputSchema: ReportSchema,
- stream: true,
+ threadId,
+ runId,
middleware: [withSandbox(sandbox)],
- })
- return toServerSentEventsResponse(stream)
+ });
+
+ return toServerSentEventsResponse(stream);
}
```
-On the client, walk `messages[].parts` for tool calls and reasoning. Read the typed object from the `structured-output` part or from `useChat().final`.
+Replace `owner/app` with your repo.
-```tsx
-import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
-import { z } from 'zod'
+## 4. Render the agent's work
-const ReportSchema = z.object({
- name: z.string(),
- oneLiner: z.string(),
-})
+The client is the same as for any chat. Render the text, and show each tool that the agent ran:
-function Report() {
- const { messages, final } = useChat({
- connection: fetchServerSentEvents('/api/report'),
- outputSchema: ReportSchema,
- })
+```tsx
+import { useState } from "react";
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
+
+export function CodingAgent() {
+ const [input, setInput] = useState("");
+ const { messages, sendMessage } = useChat({
+ connection: fetchServerSentEvents("/api/agent"),
+ threadId: "support-chat",
+ });
return (
- <>
+
+ );
}
```
-See [Harness Agents](../structured-outputs/harnesses) for the full part list and the repo-report example.
+Ask it "Fix the failing test in utils.test.ts". You see the agent's text, plus a line for each tool it used. The agent edited the files inside the container.
+
+## How it works
+
+`acpCompatible` gives you the full ACP flow:
-## Typed models & options
+- It finds the sandbox from `withSandbox(...)` and starts the agent there.
+- It bridges your `chat()` tools into the agent over MCP.
+- It handles session resume, permission requests, and abort.
+- It turns the ACP updates of the agent into `chat()` stream chunks.
-Like `openaiCompatible`, you can declare the harness's **models** and its
-per-call **options** so the whole thing is type-checked. `models` constrains the
-factory's argument; `modelOptions` is a type-only brand (`{} as { … }`, unused at
-runtime) describing what `chat({ modelOptions })` accepts. Declared options are
-merged with the base ACP options and handed to `command` / `openTransport` as
-`ctx.modelOptions`, so you can turn them into CLI flags:
+## Configure an agent once
+
+For more than one model, configure the agent once with `acpCompatible`. You get a factory. Then pick the model on each call:
```ts
-import { acpCompatible } from '@tanstack/ai-acp'
+// pi-harness.ts
+import { acpCompatible } from "@tanstack/ai-acp";
-const pi = acpCompatible({
- name: 'pi',
- models: ['pi-fast', 'pi-pro'],
- modelOptions: {} as { reasoningEffort?: 'low' | 'high' },
+export const pi = acpCompatible({
+ name: "pi",
+ models: ["pi-fast", "pi-pro"],
+ modelOptions: {} as { reasoningEffort?: "low" | "high" },
command: ({ model, harnessCwd, modelOptions }) =>
`pi --acp -m ${model} --cwd ${harnessCwd}` +
- (modelOptions?.reasoningEffort ? ` --effort ${modelOptions.reasoningEffort}` : ''),
-})
-
-pi('pi-pro') // ok
-// pi('pi-ultra') // type error — not in `models`
+ (modelOptions?.reasoningEffort
+ ? ` --effort ${modelOptions.reasoningEffort}`
+ : ""),
+ authMethodId: "pi-api-key",
+ refusalMessage: "Pi refused the request.",
+});
```
```ts
-import { chat } from '@tanstack/ai'
-import { withSandbox } from '@tanstack/ai-sandbox'
-import { pi } from './pi-harness'
-import { sandbox } from './sandbox'
-import { messages } from './chat-context'
+import { chat } from "@tanstack/ai";
+import { withSandbox } from "@tanstack/ai-sandbox";
+import { pi } from "./pi-harness";
+import { sandbox } from "./sandbox";
const stream = chat({
- adapter: pi('pi-pro'),
- modelOptions: { reasoningEffort: 'high' }, // typed against the declared options
- messages,
+ adapter: pi("pi-pro"),
+ modelOptions: { reasoningEffort: "high" },
+ messages: [{ role: "user", content: "Fix the failing test." }],
+ threadId: "thread-1",
middleware: [withSandbox(sandbox)],
-})
+});
```
-The base options are always available on `modelOptions` regardless of what you
-declare: `sessionId` (resume), `cwd`, `authMode`, `authMethodId`, and
-`permissionMode`.
-
-## Configuration
-
-| Field | Purpose |
-| --- | --- |
-| `name` (required) | Harness label, log prefix, and the `.session-id` CUSTOM event name. |
-| `models` | The model ids this harness accepts — declaring them makes `harness('id')` type-safe (unknown ids are rejected). Omit to accept any string. |
-| `modelOptions` | Type-only brand for the per-call options accepted via `chat({ modelOptions })`. Declare with `{} as { … }`; merged with the base options and exposed on `ctx.modelOptions` in `command` / `openTransport`. |
-| `command` | Build the **stdio** launch command from `{ model, cwd, harnessCwd, sandbox, env, modelOptions, signal }`. Required unless `openTransport` is given. |
-| `skillsDir` | The harness's skills directory (relative to the workspace root, e.g. `'.pi/skills'`) — its native convention, like Claude Code's `.claude/skills`. `withSandbox` workspace `gitSkill`s are linked here. Omit and gitSkills are left unlinked (warned). |
-| `openTransport` | Open any `AcpSessionTransport` yourself (e.g. boot a `serve` process and connect over WebSocket). Overrides `command`. |
-| `cwd` | Working directory inside the sandbox (default `/workspace`). |
-| `env` | Extra environment variables for the harness process. |
-| `authMode` | `'api-key'` (default) uses `authMethodId`. `'host'` skips ACP `authenticate`. See [Harness Auth](../sandbox/auth). |
-| `authMethodId` | ACP auth method to select before the session starts. Ignored when `authMode` is `'host'`. |
-| `permissionMode` | `'default'` \| `'acceptEdits'` \| `'bypassPermissions'` (default). |
-| `permissions` | `'headless'` (auto-resolve, default) or `'interactive'` (emit approval-requested events for `ask` prompts). |
-| `onPermissionRequest` | Custom permission handler; overrides `permissions`/`permissionMode`. |
-| `refusalMessage` | `RUN_ERROR` message when the harness refuses a request. |
-| `planEventName` | Emit ACP `plan` updates as a CUSTOM event under this name. |
-| `emitDiff` | Emit the post-run `git diff` of `cwd` as a `file.changed` CUSTOM event (off by default). |
-| `onExtNotification` | Handle vendor `_x/…` JSON-RPC notifications. |
-| `buildPrompt` | Override how chat history maps to the harness prompt. |
-
-This adapter ignores [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call), because the harness process sends the model requests.
-
-## WebSocket and Custom Transports
-
-Some harnesses run an ACP server you reach over WebSocket rather than stdio (the `grok agent serve` pattern). Open the transport yourself with `openTransport` — it receives the same context and returns an `AcpSessionTransport`. Put all teardown in the returned transport's `dispose`:
-
-```ts
-import { acpCompatible, startAcpServerInSandbox } from '@tanstack/ai-acp'
-
-const myAgent = acpCompatible({
- name: 'my-agent',
- openTransport: async ({ sandbox, model, harnessCwd, signal }) => {
- const server = await startAcpServerInSandbox(sandbox, {
- port: 9100,
- cwd: harnessCwd,
- command: `my-agent serve --bind 0.0.0.0:9100 -m ${model}`,
- readyMarker: 'listening',
- buildWsUrl: ({ channel, port }) =>
- `${channel.url.replace(/^http/i, 'ws')}:${port}`,
- ...(signal ? { signal } : {}),
- })
- const ws = await server.connect(signal)
- return {
- kind: 'stream',
- stream: ws.stream,
- dispose: async () => {
- ws.close()
- await server.dispose()
- },
- }
- },
-})
-```
+- `models` makes the factory type-safe. `pi("pi-ultra")` is a type error. Without `models`, the factory takes any string.
+- `modelOptions` is a type-only brand. The value `{} as { ... }` is not used at run time. It types what `chat({ modelOptions })` takes.
+- Your declared options join the base options. `command` and `openTransport` get them as `ctx.modelOptions`, so you can turn them into CLI flags.
+- The base options are always there: `sessionId`, `cwd`, `authMode`, `authMethodId`, and `permissionMode`.
## Permissions
-Inside a sandbox the sandbox itself is the security boundary, so the default `'headless'` strategy with `permissionMode: 'bypassPermissions'` lets the agent edit files and run commands without prompting. To surface tool approvals to a client instead, switch to `'interactive'`:
+Inside a sandbox, the sandbox is the security boundary. So by default, the adapter answers each permission request of the agent with `permissionMode: 'bypassPermissions'`. The agent edits files and runs commands with no prompt.
+
+To send tool approvals to a client, switch to `'interactive'`:
```ts
-import { acpCompatible } from '@tanstack/ai-acp'
+import { acpCompatible } from "@tanstack/ai-acp";
-const pi = acpCompatible({
- name: 'pi',
+export const pi = acpCompatible({
+ name: "pi",
command: ({ model }) => `pi --acp -m ${model}`,
- permissions: 'interactive', // emit approval-requested events for `ask` prompts
- permissionMode: 'acceptEdits', // still auto-approve file edits
-})
+ permissions: "interactive", // send approval-requested events for `ask` prompts
+ permissionMode: "acceptEdits", // still allow file edits with no prompt
+});
```
-`chat()`-provided tools bridged into the agent are always auto-approved, regardless of mode.
+- With `'interactive'`, an `ask` prompt sends an approval-requested event. The client approves, then runs the turn again.
+- Your `chat()` tools that the adapter bridges into the agent are always allowed, in every mode.
+- To write your own rules, pass `onPermissionRequest`. It overrides `permissions` and `permissionMode`.
## Tool choice
-You want the agent to see just one of your tools, or none of them. Pass `toolChoice` to `chat()`. It limits only the tools that the adapter bridges into the agent. ACP has no field to turn off the built-in tools of the agent or to force a tool call, so the agent decides when it calls a tool.
+You want the agent to see only one of your tools, or none of them. Pass `toolChoice` to `chat()`. It limits only the tools that the adapter bridges into the agent. ACP has no field to turn off the built-in tools of the agent or to force a tool call. So the agent decides when it calls a tool.
| Value | What the adapter does |
| --- | --- |
@@ -329,84 +242,226 @@ Each value except `'auto'` logs a warning the first time an adapter instance get
For the values on other adapters, see [Choose when the model calls a tool](../tools/tools#choose-when-the-model-calls-a-tool).
-## Session Resume
+## Session resume
-On every run the adapter emits the harness session id as a CUSTOM event named `.session-id` (e.g. `pi.session-id`). Thread that id back through `modelOptions.sessionId` on the next call and the harness resumes the session — only the trailing user message is sent, since the agent already holds the prior context:
+On each run, the adapter sends the session id of the agent as a custom event named `.session-id`, for example `pi.session-id`. Send the id back in `modelOptions.sessionId` on the next call, and the agent continues the session:
```ts
-import { chat, chatParamsFromRequest, toServerSentEventsResponse } from '@tanstack/ai'
-import { withSandbox } from '@tanstack/ai-sandbox'
-import { pi } from './pi-harness' // the configured `acpCompatible(...)` factory
-import { sandbox } from './sandbox'
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
+import { withSandbox } from "@tanstack/ai-sandbox";
+import { pi } from "./pi-harness";
+import { sandbox } from "./sandbox";
export async function POST(request: Request) {
- const params = await chatParamsFromRequest(request)
+ const params = await chatParamsFromRequest(request);
const sessionId =
- typeof params.forwardedProps.sessionId === 'string'
+ typeof params.forwardedProps.sessionId === "string"
? params.forwardedProps.sessionId
- : undefined
+ : undefined;
const stream = chat({
- adapter: pi('pi-fast'),
+ adapter: pi("pi-fast"),
messages: params.messages,
+ threadId: params.threadId,
middleware: [withSandbox(sandbox)],
modelOptions: { sessionId },
- })
+ });
+
+ return toServerSentEventsResponse(stream);
+}
+```
+
+On a resumed session, the adapter sends only the last user message. The agent already has the rest. For the client half, see [Resume a session](./claude-code#resume-a-session) on the Claude Code page. Use your `.session-id` as the event name.
+
+## Typed output
+
+You want a typed object after the agent works. Pass `outputSchema` on the same `chat()` call. The agent runs its own tools. Then the adapter adds the schema to the prompt and parses the last assistant text as JSON.
+
+```ts
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
+import { acpCompatibleText } from "@tanstack/ai-acp";
+import { withSandbox } from "@tanstack/ai-sandbox";
+import { z } from "zod";
+import { sandbox } from "./sandbox";
+
+const ReportSchema = z.object({
+ name: z.string(),
+ oneLiner: z.string(),
+});
+
+export async function POST(request: Request) {
+ const { messages, threadId } = await chatParamsFromRequest(request);
+
+ const stream = chat({
+ adapter: acpCompatibleText("pi-fast", {
+ name: "pi",
+ command: ({ model }) => `pi --acp -m ${model}`,
+ }),
+ messages,
+ threadId,
+ outputSchema: ReportSchema,
+ stream: true,
+ middleware: [withSandbox(sandbox)],
+ });
+
+ return toServerSentEventsResponse(stream);
+}
+```
+
+On the client, walk `messages[].parts` for tool calls and reasoning. Read the typed object from the `structured-output` part or from `useChat().final`:
+
+```tsx
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
+import { z } from "zod";
- return toServerSentEventsResponse(stream)
+const ReportSchema = z.object({
+ name: z.string(),
+ oneLiner: z.string(),
+});
+
+export function Report() {
+ const { messages, final } = useChat({
+ connection: fetchServerSentEvents("/api/report"),
+ threadId: "support-chat",
+ outputSchema: ReportSchema,
+ });
+
+ return (
+ <>
+ {messages.map((message) =>
+ message.parts.map((part, index) => {
+ if (part.type === "tool-call") {
+ return
: null}
+ >
+ );
}
```
-## Workspace skills
+You can also read the object from `await chat()` when you do not stream. See [Harness Agents](../structured-outputs/harnesses) for the full part list and the repo-report example.
+
+## Models and options
+
+`acpCompatible` has no built-in model list. The model is any string that your agent takes, or one of the `models` that you declare.
-When you provision a [workspace](../sandbox/workspace) via `withSandbox`,
-`acpCompatible` projects its skills into the harness — each kind lands where that
-harness expects it:
+### Configuration
-| Workspace input | How `acpCompatible` projects it |
+Pass these to `acpCompatible` or as the second argument of `acpCompatibleText`:
+
+| Field | What it does |
+| --- | --- |
+| `name` (required) | The harness label, the log prefix, and the name of the `.session-id` custom event. |
+| `command` | Builds the stdio launch command from `{ model, cwd, harnessCwd, sandbox, env, modelOptions, signal }`. Required unless you pass `openTransport`. |
+| `openTransport` | Opens any `AcpSessionTransport` yourself, for example a WebSocket. It overrides `command`. See [WebSocket and custom transports](#websocket-and-custom-transports). |
+| `models` | The model ids that the harness takes. With this list, unknown ids are a type error. |
+| `modelOptions` | Type-only brand for the per-call options of `chat({ modelOptions })`. Declare it with `{} as { ... }`. |
+| `cwd` | The working directory inside the sandbox. Default `/workspace`. |
+| `env` | More environment variables for the agent process. |
+| `authMode` | `'api-key'` (default) uses `authMethodId`. `'host'` skips ACP `authenticate`. See [Harness Auth](../sandbox/auth). |
+| `authMethodId` | The ACP auth method to select before the session starts. Ignored when `authMode` is `'host'`. |
+| `permissionMode` | `'default'`, `'acceptEdits'`, or `'bypassPermissions'` (default). |
+| `permissions` | `'headless'` (default) answers each request. `'interactive'` sends approval-requested events for `ask` prompts. |
+| `onPermissionRequest` | Your own permission handler. It overrides `permissions` and `permissionMode`. |
+
+More options, for special cases:
+
+| Field | What it does |
| --- | --- |
-| `mcpSkill(name, config)` | Passed to the agent over **ACP natively** via `newSession`'s `mcpServers` (secrets/bearer headers resolved). No config file — that's the ACP advantage over file-based harnesses. |
-| `gitSkill({ repo })` | Cloned during bootstrap, then linked into your declared [`skillsDir`](#configuration) (e.g. `.pi/skills`). Omit `skillsDir` and it's left unlinked (with a warning). |
-| `fileSkill({ path, content })` | Written into the workspace root during bootstrap (provider-agnostic). |
-| `instructions` | Written to `AGENTS.md` (and symlinks) during bootstrap. |
-| `agentSkill(name)`, `plugins` | No generic ACP primitive — warned and skipped. Provide a `gitSkill` or MCP server instead. |
+| `skillsDir` | The skills directory of the agent, relative to the workspace root, for example `'.pi/skills'`. Workspace `gitSkill`s are linked here. Without it, they stay unlinked and the adapter logs a warning. |
+| `refusalMessage` | The `RUN_ERROR` message when the agent refuses a request. |
+| `planEventName` | Send ACP `plan` updates as a custom event with this name. |
+| `emitDiff` | Send the `git diff` of `cwd` after the run as a `file.changed` custom event. Off by default. Needs a git repo at `cwd`. |
+| `onExtNotification` | Handles vendor `_x/...` JSON-RPC notifications. It must not throw. |
+| `buildPrompt` | Changes how the chat history becomes the prompt of the agent. |
+
+This adapter ignores [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call), because the agent process sends the model requests.
+
+## Advanced
+
+### WebSocket and custom transports
+
+Some agents run an ACP server that you reach over WebSocket, not stdio (the `grok agent serve` pattern). Open the transport yourself with `openTransport`. It gets the same context as `command` and returns an `AcpSessionTransport`. Put all teardown in the `dispose` of the transport:
-`secrets` declared on the workspace are injected into the agent's environment at
-create/resume (never persisted to snapshots), so the harness CLI picks them up
-like any env var.
+```ts
+import { acpCompatible, startAcpServerInSandbox } from "@tanstack/ai-acp";
+
+export const myAgent = acpCompatible({
+ name: "my-agent",
+ openTransport: async ({ sandbox, model, harnessCwd, signal }) => {
+ const server = await startAcpServerInSandbox(sandbox, {
+ port: 9100,
+ cwd: harnessCwd,
+ command: `my-agent serve --bind 0.0.0.0:9100 -m ${model}`,
+ readyMarker: "listening",
+ buildWsUrl: ({ channel, port }) =>
+ `${channel.url.replace(/^http/i, "ws")}:${port}`,
+ ...(signal ? { signal } : {}),
+ });
+ const ws = await server.connect(signal);
+ return {
+ kind: "stream",
+ stream: ws.stream,
+ dispose: async () => {
+ ws.close();
+ await server.dispose();
+ },
+ };
+ },
+});
+```
+
+### Workspace skills
+
+When you set up a [workspace](../sandbox/workspace) with `withSandbox`, `acpCompatible` puts its skills where the agent expects them:
+
+| Workspace input | What `acpCompatible` does |
+| --- | --- |
+| `mcpSkill(name, config)` | Passes the server to the agent over ACP, in the `mcpServers` of `newSession`, with secrets and bearer headers resolved. No config file is needed. |
+| `gitSkill({ repo })` | Clones the repo during bootstrap, then links it into your [`skillsDir`](#configuration). Without `skillsDir`, it stays unlinked and the adapter logs a warning. |
+| `fileSkill({ path, content })` | Writes the file into the workspace root during bootstrap. |
+| `instructions` | Writes them to `AGENTS.md` (and symlinks) during bootstrap. |
+| `agentSkill(name)`, `plugins` | Skipped with a warning, because ACP has no generic way to pass them. Use a `gitSkill` or an MCP server. |
-## Protocol coverage
+The workspace `secrets` go into the environment of the agent on create and on resume. They are never saved to snapshots. The agent CLI reads them like any environment variable.
-`acpCompatible` implements the **client / orchestration** side of ACP — enough to
-drive an agent through a full prompt turn, not the entire protocol surface. It is
-a compliant *minimal* client: everything it doesn't implement is either
-capability-gated (so advertising non-support is the spec-defined behavior) or a
-rendering choice, never a violation.
+### Protocol coverage
-**Covered:**
+`acpCompatible` implements the client side of ACP: enough to drive an agent through a full prompt turn. It is a minimal client that follows the spec. Each part it does not implement is either gated by a capability, so it advertises no support, or a rendering choice.
-- `initialize` handshake — sends `clientInfo` + the protocol version, negotiates the version, advertises capabilities.
-- `authenticate` (when the agent advertises auth methods), `session/new`, `session/load` (resume), `session/prompt`, `session/cancel`.
-- `session/request_permission` with all four option kinds, mapped by [permission mode](#permissions).
-- All streamed `session/update`s that carry turn output: `agent_message_chunk`, `agent_thought_chunk` (→ reasoning), `tool_call` / `tool_call_update`, `plan`.
-- All five stop reasons (`end_turn`, `max_tokens`, `max_turn_requests`, `refusal`, `cancelled`).
+It covers:
-**Surfaced as `CUSTOM` stream events** (the AG-UI chat-event protocol has no
-first-class event for non-text assistant output, so these ride on `CUSTOM`):
+- The `initialize` handshake. It sends `clientInfo` and the protocol version, agrees on the version, and advertises capabilities.
+- `authenticate` (when the agent advertises auth methods), `session/new`, `session/load` (resume), `session/prompt`, and `session/cancel`.
+- `session/request_permission` with all four option kinds, mapped by the [permission mode](#permissions).
+- The streamed `session/update`s that carry turn output: `agent_message_chunk`, `agent_thought_chunk` (as reasoning), `tool_call`, `tool_call_update`, and `plan`.
+- All five stop reasons: `end_turn`, `max_tokens`, `max_turn_requests`, `refusal`, and `cancelled`.
-- `.session-id` — the harness session id, for [resume](#session-resume).
-- `.message-content` — non-text agent content (`image` / `audio` / `resource` / `resource_link` blocks). Its `value` is `{ content: }`. Non-text **tool** content (diffs, terminal, images) is preserved inside the `TOOL_CALL_RESULT` payload.
-- the plan event, when you set `planEventName`.
+Some output arrives as `CUSTOM` stream events, because the AG-UI chat events have no event for non-text assistant output:
-**Not implemented (by design):**
+- `.session-id`: the session id of the agent, for [resume](#session-resume).
+- `.message-content`: non-text agent content (`image`, `audio`, `resource`, or `resource_link` blocks). Its `value` is `{ content: }`. Non-text tool content (diffs, terminal output, images) stays inside the `TOOL_CALL_RESULT` payload.
+- The plan event, when you set `planEventName`.
-- `fs/read_text_file`, `fs/write_text_file`, `terminal/*` — advertised as unsupported. The agent runs inside the sandbox with direct filesystem and shell access, so it never delegates these back to the client.
-- Sending **multimodal prompts** — prompts are sent as text. (Agent multimodal *output* is surfaced via `message-content` above.)
-- Incremental `usage_update` (final turn usage is reported instead), `available_commands_update`, `current_mode_update`, and experimental features (elicitation, NES, providers, session modes/config).
+It does not implement:
-## Next Steps
+- `fs/read_text_file`, `fs/write_text_file`, and `terminal/*`. It advertises them as unsupported. The agent runs inside the sandbox with direct file and shell access, so it never sends these back to the client.
+- Multimodal prompts. Prompts go to the agent as text. Multimodal output of the agent arrives as `message-content`, shown above.
+- Incremental `usage_update` (the final usage of the turn arrives instead), `available_commands_update`, `current_mode_update`, and the experimental features (elicitation, NES, providers, session modes and config).
-- [Sandbox Overview](../sandbox/overview) — how harnesses run inside a sandbox
-- [Grok Build Adapter](./grok-build) — a first-class ACP harness adapter
-- [Sandbox Tools](../sandbox/tools) — bridge your app's tools into the agent
-- [OpenAI-Compatible Adapter](./openai-compatible) — the same idea for model providers
+To find agents, see the [ACP agents list](https://agentclientprotocol.com/get-started/agents). For how harnesses fit with sandboxes, see [Harnesses](../sandbox/harnesses).
diff --git a/docs/adapters/anthropic-advanced.md b/docs/adapters/anthropic-advanced.md
new file mode 100644
index 0000000000..598b4652ab
--- /dev/null
+++ b/docs/adapters/anthropic-advanced.md
@@ -0,0 +1,560 @@
+---
+title: "Anthropic: Advanced"
+id: anthropic-advanced
+order: 2
+description: "Advanced Anthropic setup in TanStack AI: Bearer and OAuth tokens, gateways, Claude on Vertex, the max_tokens default, prompt caching, every provider tool, and the API reference."
+keywords:
+ - tanstack ai
+ - anthropic
+ - claude
+ - claude on vertex
+ - oauth
+ - prompt caching
+ - provider tools
+ - adapter
+---
+
+This page has the details of the Anthropic adapter. To get a first chat working, start at [Anthropic](./anthropic).
+
+## Bearer and OAuth tokens
+
+Got a Bearer token, not an API key? Pass it as the credential and set `auth: "bearer"`. The adapter sends `Authorization: Bearer ` and no `x-api-key` header:
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { createAnthropicChat } from "@tanstack/ai-anthropic";
+
+const adapter = createAnthropicChat("claude-sonnet-5-5", process.env.MY_CLAUDE_TOKEN!, {
+ auth: "bearer",
+});
+
+const stream = chat({
+ adapter,
+ messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
+});
+```
+
+`auth` takes one of three values:
+
+- `"api-key"`: sends the `x-api-key` header. This is the default.
+- `"bearer"`: sends `Authorization: Bearer ` and no `x-api-key` header.
+- `"oauth"`: sends the token like `"bearer"`, plus the Claude Code extras below.
+
+A Claude OAuth token contains `sk-ant-oat`. For such a token, the adapter picks `"oauth"`, so you can leave `auth` out. In OAuth mode, every request also gets:
+
+- the Claude Code identity system block, before your own system prompts.
+- the Claude Code CLI headers (`user-agent` and `x-app`).
+- the `claude-code-20250219` and `oauth-2025-04-20` betas.
+
+An explicit `auth` always wins. To send an `sk-ant-oat` token as a plain Bearer token, without the extras, set `auth: "bearer"`.
+
+Is the token in your environment? Then `anthropicText` finds it and picks `auth` for you. See [Set the API key](./anthropic#2-set-the-api-key) for the order of the variables:
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { anthropicText } from "@tanstack/ai-anthropic";
+
+// ANTHROPIC_AUTH_TOKEN=my-token in your environment
+const stream = chat({
+ adapter: anthropicText("claude-sonnet-5-5"),
+ messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
+});
+```
+
+An `auth` in the config wins over the kind that the adapter picks. To call Claude models through a GitHub Copilot plan, see [GitHub Copilot](./openai-compatible-advanced#claude-models).
+
+## Configuration
+
+```typescript
+import { createAnthropicChat, type AnthropicTextConfig } from "@tanstack/ai-anthropic";
+
+const config: Omit = {
+ baseURL: "https://api.anthropic.com", // Optional, for custom endpoints
+};
+
+const adapter = createAnthropicChat("claude-sonnet-5-5", process.env.ANTHROPIC_API_KEY!, config);
+```
+
+Need a header on each request, or a log of each model call? This adapter supports [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). An adapter with your own client (Claude on Vertex, or `createAnthropicChatWithClient`) ignores it, because the adapter cannot reach the fetch of that client.
+
+## Replay thinking through a gateway
+
+Your gateway speaks the Anthropic protocol, but it returns thinking with no signature. By default, the adapter does not send unsigned thinking back, so the next turn loses it. Turn on `allowEmptySignature`:
+
+```typescript
+import { anthropicText } from "@tanstack/ai-anthropic";
+
+const gateway = anthropicText("claude-sonnet-5-5", {
+ baseURL: "https://gateway.example.com",
+ provider: "my-gateway",
+ allowEmptySignature: true,
+});
+
+console.log(gateway.provider); // "my-gateway"
+```
+
+- `allowEmptySignature` is `false` by default. It sends unsigned thinking back only for history from the same source.
+- Redacted thinking still needs its encrypted data.
+- `provider` names the gateway. History from the gateway then counts as a different source from Anthropic itself. See [Keep saved history when you switch](../advanced/runtime-adapter-switching#keep-saved-history-when-you-switch).
+
+## Claude on Vertex
+
+Use `@tanstack/ai-anthropic/vertex` when Claude must run on Vertex AI. That is the path for regional endpoints and Google Cloud credentials.
+
+Install the Vertex SDK next to the Anthropic adapter:
+
+
+
+react: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
+vue: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
+solid: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
+svelte: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
+preact: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
+angular: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
+vanilla: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
+octane: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
+
+
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { anthropicVertexText } from "@tanstack/ai-anthropic/vertex";
+
+const stream = chat({
+ adapter: anthropicVertexText("claude-sonnet-5", {
+ project: "my-project",
+ location: "europe-west1",
+ }),
+ messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
+});
+```
+
+- `anthropicVertexText` accepts only the Claude models in the Vertex catalog (`ANTHROPIC_VERTEX_CHAT_MODELS`). It does not accept ids that only Anthropic has, such as `claude-opus-5-fast`.
+- `project` and `location` use the same names as `@tanstack/ai-vertex`, so one auth object works for Gemini and Claude.
+- If you omit `project`, Application Default Credentials can still fill it.
+- `location` is required. Pass it on the factory, or set `GOOGLE_CLOUD_LOCATION`, `GOOGLE_VERTEX_LOCATION`, or `CLOUD_ML_REGION`.
+
+Gemini on Vertex is in [`@tanstack/ai-vertex`](./vertex).
+
+## Custom Anthropic client
+
+Already have a client that speaks the Anthropic protocol? Use `createAnthropicChatWithClient`. The adapter only needs `beta.messages.create`. Message mapping, streaming, tools, media, usage, and structured output stay on the same TanStack path.
+
+
+
+react: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
+vue: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
+solid: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
+svelte: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
+preact: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
+angular: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
+vanilla: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
+octane: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
+
+
+
+```typescript
+import { AnthropicVertex } from "@anthropic-ai/vertex-sdk";
+import { createAnthropicChatWithClient } from "@tanstack/ai-anthropic";
+
+const client = new AnthropicVertex({
+ projectId: "my-project",
+ region: "europe-west1",
+});
+
+const adapter = createAnthropicChatWithClient("claude-sonnet-5", client);
+```
+
+The client must implement the Anthropic Beta Messages protocol. Model and feature support of that endpoint is your job.
+
+## `max_tokens` default
+
+The Messages API of Anthropic requires `max_tokens` on every request, so the adapter always sends a value.
+
+- When you do not set `modelOptions.max_tokens`, it is the full output limit of the model (`max_output_tokens` from the model metadata, for example 64K for Sonnet and 128K for Opus).
+- For a model that this package does not know, the adapter uses a safe constant.
+- `max_tokens` is a limit, not a reservation. Anthropic bills the tokens that the model makes, so this default costs nothing extra. A low default can cut a response short (`stop_reason: "max_tokens"`).
+- Set `max_tokens` only to cap the output below the model limit.
+- If a response stops at the default limit, the adapter logs a warning. You see it with [debug logging](../advanced/debug-logging) on.
+
+A streamed response that stops at `max_tokens` ends in a `RUN_ERROR` with `code: 'max_tokens'`. Anthropic bills the tokens of that call, so this `RUN_ERROR` carries the `usage` of the call. The `onUsage` middleware hook fires only for `RUN_FINISHED`. To count these tokens too, read `usage` from the `RUN_ERROR` chunk, in the stream or in an `onChunk` middleware.
+
+One exception: structured output (`chat({ outputSchema })`) on models that use the non-streaming finalization path caps this default at about 21K tokens. The Anthropic SDK rejects a non-streaming request whose `max_tokens` can go past its 10-minute timeout. Streaming chat does not have this cap. To raise the structured output limit toward the true maximum of a model, stream the response.
+
+## Prompt caching
+
+`chat()` caches Claude prompts by default. It adds `cache_control` markers, at most 4, to:
+
+- the last start tool. That is the tool before the deferred placeholder, or the last tool.
+- the last block of the last user message, or of the last system message in the conversation.
+- the system blocks.
+
+Pass `promptCache: 'long'` for the 1-hour cache, or `promptCache: 'none'` to send no markers. For the key and the cost, see [Prompt Caching](../advanced/prompt-caching).
+
+To put the markers in other places, set `cache_control` in the `metadata` of a message part, a system prompt, or a tool. One marker of your own turns the automatic markers off for that request:
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { anthropicText } from "@tanstack/ai-anthropic";
+
+const stream = chat({
+ adapter: anthropicText("claude-opus-5-5"),
+ messages: [
+ {
+ role: "user",
+ content: [
+ {
+ type: "text",
+ content: "What is the capital of France?",
+ metadata: {
+ cache_control: {
+ type: "ephemeral",
+ },
+ },
+ },
+ ],
+ },
+ ],
+ threadId: "thread-1",
+});
+```
+
+- `modelOptions.cache_control` also turns the automatic markers off.
+- `usage.promptTokens` counts the full input, cached tokens included. The cache reads and writes are also on `usage.promptTokensDetails`. See [Token usage](../chat/stream-events#token-usage).
+
+### Tools and prompts added during a conversation
+
+On `claude-opus-4-8`, `claude-opus-5`, `claude-opus-5-5`, `claude-fable-5`, and `claude-fable-5-1`, a tool or a system prompt that you add between model calls goes into the conversation. The cached start of the request stays the same.
+
+What the adapter sends on these models:
+
+- `system` keeps the system prompts of the first call.
+- A request with tools gets the `mid-conversation-tool-changes-2026-07-01` beta and one placeholder tool, `__tanstack_deferred_placeholder__`. The model never calls it.
+- An added tool goes at the end of `tools` with `defer_loading: true`. A `system` message names it in a `tool_addition` block.
+- An added system prompt goes into a `system` message, before the next assistant message or at the end.
+- With a provider tool such as `webSearchTool()`, `tools` is the full list and has no placeholder.
+
+The automatic tool marker stays on the last tool of the first call, so the marked start does not move.
+
+The channels are on by default with the API of Anthropic. A custom `baseURL`, a custom `fetch`, `ANTHROPIC_BASE_URL`, or your own client turns them off. Set `midConversationChannels` to choose:
+
+```typescript
+import { anthropicText } from "@tanstack/ai-anthropic";
+
+export const fullLists = anthropicText("claude-opus-5-5", {
+ midConversationChannels: false,
+});
+
+export const throughProxy = anthropicText("claude-opus-5-5", {
+ baseURL: "https://llm-proxy.example.com",
+ midConversationChannels: true,
+});
+```
+
+If that endpoint sends the request and the `anthropic-beta` header on to Anthropic without changes, set `true`. See [Mid-Conversation Changes](../advanced/mid-conversation-changes).
+
+## Provider tools
+
+Anthropic has tools of its own, next to the function tools that you define. Import them from `@tanstack/ai-anthropic/tools` and pass them into `chat({ tools: [...] })`.
+
+For the concept, a comparison table, and type gating, see [Provider Tools](../tools/provider-tools.md).
+
+### `webSearchTool`
+
+Claude runs the native web search of Anthropic, with inline citations.
+
+- Limit the search with `allowed_domains` or `blocked_domains`. You cannot use both.
+- Set `max_uses` to cap the cost of each turn.
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { anthropicText } from "@tanstack/ai-anthropic";
+import { webSearchTool } from "@tanstack/ai-anthropic/tools";
+
+const stream = chat({
+ adapter: anthropicText("claude-opus-5-5"),
+ messages: [{ role: "user", content: "What's new in AI this week?" }],
+ threadId: "thread-1",
+ tools: [
+ webSearchTool({
+ name: "web_search",
+ type: "web_search_20250305",
+ max_uses: 2,
+ }),
+ ],
+});
+```
+
+**Supported models:** every registered Claude model. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+
+### `webFetchTool`
+
+Claude gets the content of one URL. If the model must read a specific page, not run a search, use it. It needs no arguments. Pass a config object to change the defaults.
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { anthropicText } from "@tanstack/ai-anthropic";
+import { webFetchTool } from "@tanstack/ai-anthropic/tools";
+
+const stream = chat({
+ adapter: anthropicText("claude-sonnet-4-6"),
+ messages: [{ role: "user", content: "Summarise https://example.com" }],
+ threadId: "thread-1",
+ tools: [webFetchTool()],
+});
+```
+
+**Supported models:** Claude Sonnet 4.x and later. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+
+### `codeExecutionTool`
+
+Gives Claude a sandbox that runs code. Claude can run Python, look at data, and return the results inline. Pick the version string of the API revision that you want.
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { anthropicText } from "@tanstack/ai-anthropic";
+import { codeExecutionTool } from "@tanstack/ai-anthropic/tools";
+
+const stream = chat({
+ adapter: anthropicText("claude-sonnet-4-6"),
+ messages: [{ role: "user", content: "Plot a histogram of [1,2,2,3,3,3]" }],
+ threadId: "thread-1",
+ tools: [
+ codeExecutionTool({ name: "code_execution", type: "code_execution_20250825" }),
+ ],
+});
+```
+
+**Supported models:** Claude Sonnet 4.x and later. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+
+#### Attaching hosted skills
+
+Pass a `skills` array as the second argument to load skill bundles that the provider manages into the sandbox. The adapter moves them into the `container.skills` parameter of the API and adds the required beta headers for you.
+
+```typescript
+import { chat, toServerSentEventsResponse } from "@tanstack/ai";
+import { anthropicText } from "@tanstack/ai-anthropic";
+import { codeExecutionTool } from "@tanstack/ai-anthropic/tools";
+
+export async function POST(request: Request) {
+ const { messages, threadId } = await request.json();
+
+ const stream = chat({
+ adapter: anthropicText("claude-sonnet-4-5"),
+ messages,
+ threadId,
+ tools: [
+ codeExecutionTool(
+ { type: "code_execution_20250825", name: "code_execution" },
+ {
+ skills: [{ type: "anthropic", skill_id: "pptx", version: "latest" }],
+ },
+ ),
+ ],
+ });
+
+ return toServerSentEventsResponse(stream);
+}
+```
+
+For the skill shape, the limits, the scope, and the OpenAI equivalent, see [Provider Skills](../tools/provider-skills.md).
+
+### `computerUseTool`
+
+Claude can look at a screen and use the mouse and the keyboard. Your app runs each action and sends back a screenshot. `createComputerUse` from `@tanstack/ai-computer-use` does this on a Playwright browser or an E2B desktop. Each thread gets its own machine:
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { anthropicText } from "@tanstack/ai-anthropic";
+import { createComputerUse } from "@tanstack/ai-computer-use";
+import { playwrightComputer } from "@tanstack/ai-computer-playwright";
+
+declare const threadId: string;
+
+const computerUse = createComputerUse({
+ driver: playwrightComputer({ startUrl: "https://tanstack.com" }),
+});
+
+const stream = chat({
+ adapter: anthropicText("claude-opus-5-5"),
+ messages: [{ role: "user", content: "Find the TanStack AI docs" }],
+ threadId,
+ tools: computerUse.tools,
+ middleware: [computerUse.middleware],
+ interrupts: [computerUse.interrupt],
+});
+```
+
+The adapter sends the `computer` tool to Claude as the newest native computer tool that your model takes.
+
+- For the setup, the threads, and how to close the machines, see [Computer use](../computer-use/computer-use.md).
+- For your own machine, see [Custom driver](../computer-use/custom-driver.md).
+
+#### The raw tool
+
+`computerUseTool` from `@tanstack/ai-anthropic/tools` is the native tool with no `execute`. Use it when your app runs the actions itself. Pick the version that your model takes:
+
+| Version | Models |
+| --- | --- |
+| `computer_toolset_20260801` | Claude Opus 5.5, Sonnet 5.5, Haiku 5.5, Fable 5.1, Fable 5, Opus 5, Sonnet 5, Opus 4.8 |
+| `computer_20251124` | Claude Fable 5.1, Fable 5, Opus 5, Sonnet 5, Opus 4.8, Opus 4.7, Opus 4.6, Sonnet 4.6, Opus 4.5 |
+| `computer_20250124` | Claude Sonnet 4.5, Haiku 4.5, Opus 4.1 |
+
+```typescript
+import { computerUseTool } from "@tanstack/ai-anthropic/tools";
+
+const computer = computerUseTool({ type: "computer_toolset_20260801" });
+```
+
+The earlier versions also take a name and the screen size:
+
+```typescript
+import { computerUseTool } from "@tanstack/ai-anthropic/tools";
+
+const computer = computerUseTool({
+ type: "computer_20251124",
+ name: "computer",
+ display_width_px: 1280,
+ display_height_px: 800,
+});
+```
+
+Add an `execute` that runs the action and returns a result:
+
+- Every version calls `execute` with the action in `input.action`. With the toolset, Claude calls each action as its own tool. The adapter gives you each call as one `computer` call.
+- Return a screenshot as an image part for the `screenshot` action, and `"OK"` for the other actions.
+- Send screenshots that fit the image limits of the model. The toolset refuses a large screenshot. It does not make it smaller.
+- The toolset turns on `zoom` by default. Return an image for the `zoom` action, or turn it off with `configs: { zoom: { enabled: false } }`.
+- The adapter sends the beta header that an earlier version needs. The toolset needs no beta header.
+
+Claude gives coordinates in the pixels of your screenshots.
+
+A version that your model does not take is a type error. `claude-opus-5-fast` takes no provider tools. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+
+### `bashTool`
+
+Gives Claude a bash shell session that stays open. Claude can run commands, install packages, or change files on the host. Pick the type string of your API revision.
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { anthropicText } from "@tanstack/ai-anthropic";
+import { bashTool } from "@tanstack/ai-anthropic/tools";
+
+const stream = chat({
+ adapter: anthropicText("claude-sonnet-4-6"),
+ messages: [{ role: "user", content: "List all TypeScript files in src/" }],
+ threadId: "thread-1",
+ tools: [bashTool({ name: "bash", type: "bash_20250124" })],
+});
+```
+
+**Supported models:** Claude Sonnet 3.5 and later. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+
+### `textEditorTool`
+
+Gives Claude a text editor to view and change files, with the `str_replace`, `create`, `view`, and `undo_edit` commands. Pick the type string of the API revision that you use.
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { anthropicText } from "@tanstack/ai-anthropic";
+import { textEditorTool } from "@tanstack/ai-anthropic/tools";
+
+const stream = chat({
+ adapter: anthropicText("claude-sonnet-4-6"),
+ messages: [{ role: "user", content: "Fix the bug in src/index.ts" }],
+ threadId: "thread-1",
+ tools: [
+ textEditorTool({ type: "text_editor_20250124", name: "str_replace_editor" }),
+ ],
+});
+```
+
+**Supported models:** Claude Sonnet 3.5 and later. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+
+### `memoryTool`
+
+Claude stores and reads facts across turns with the managed memory service of Anthropic. Call it with no arguments to use the default configuration.
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { anthropicText } from "@tanstack/ai-anthropic";
+import { memoryTool } from "@tanstack/ai-anthropic/tools";
+
+const stream = chat({
+ adapter: anthropicText("claude-sonnet-4-6"),
+ messages: [{ role: "user", content: "Remember that I prefer metric units" }],
+ threadId: "thread-1",
+ tools: [memoryTool()],
+});
+```
+
+**Supported models:** Claude Sonnet 4.x and later. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+
+### `customTool`
+
+Makes a tool with an inline schema, without `toolDefinition()`. If you need full control of the schema shape, or want to add `cache_control`, use it. `customTool` returns a plain `Tool`, so every chat model accepts it.
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { anthropicText } from "@tanstack/ai-anthropic";
+import { customTool } from "@tanstack/ai-anthropic/tools";
+import { z } from "zod";
+
+const stream = chat({
+ adapter: anthropicText("claude-sonnet-4-6"),
+ messages: [{ role: "user", content: "Look up user 42" }],
+ threadId: "thread-1",
+ tools: [
+ customTool(
+ "lookup_user",
+ "Look up a user by ID and return their profile",
+ z.object({ userId: z.number() }),
+ ),
+ ],
+});
+```
+
+**Supported models:** all current Claude models. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+
+## API reference
+
+Each factory pair has the same shape. The short factory (`anthropicText`, `anthropicSummarize`) reads the credential from the [environment](./anthropic#2-set-the-api-key). The `create*` factory takes an explicit credential and never reads one from the environment. Both take `model` as the first argument.
+
+### `anthropicText(model, config?)` / `createAnthropicChat(model, apiKey, config?)`
+
+Creates an Anthropic chat adapter.
+
+- `model`: a Claude model id, for example `"claude-sonnet-5-5"` or `"claude-opus-5-5"`.
+- `apiKey`: an API key, a Bearer token, or a Claude OAuth token (`createAnthropicChat` only).
+- `config?.baseURL`: a custom base URL. Optional.
+- `config?.auth`: how to send the credential: `"api-key"`, `"bearer"`, or `"oauth"`. Optional. See [Bearer and OAuth tokens](#bearer-and-oauth-tokens).
+- `config?.allowEmptySignature`: send unsigned thinking back to the same source. Optional, default `false`. See [Replay thinking through a gateway](#replay-thinking-through-a-gateway).
+- `config?.provider`: the source name for a gateway. Optional, default `"anthropic"`.
+
+`adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts).
+
+### `anthropicVertexText(model, config?)`
+
+Creates an Anthropic chat adapter on Vertex. Import it from `@tanstack/ai-anthropic/vertex`.
+
+- `model`: a Claude model id from the Vertex catalog.
+- `config.project`: the GCP project id. Optional if ADC can find it.
+- `config.location`: the Vertex region. You can also set `GOOGLE_CLOUD_LOCATION`.
+
+### `createAnthropicChatWithClient(model, client)`
+
+Creates an Anthropic chat adapter with a client that you give.
+
+- `model`: a Claude model id.
+- `client`: a client with `beta.messages.create`.
+
+### `anthropicSummarize(model, config?)` / `createAnthropicSummarize(model, apiKey, config?)`
+
+Creates an Anthropic summarization adapter.
+
+### `anthropicFiles` / `createAnthropicFiles`
+
+Creates a Files API adapter. See [Files API](../advanced/files-api).
diff --git a/docs/adapters/anthropic.md b/docs/adapters/anthropic.md
index f0bfd88b17..2c944b079d 100644
--- a/docs/adapters/anthropic.md
+++ b/docs/adapters/anthropic.md
@@ -2,7 +2,7 @@
title: Anthropic
id: anthropic-adapter
order: 2
-description: "Use Anthropic Claude models with TanStack AI — Claude Fable 5, Claude Sonnet 5, Claude Opus, and more via the @tanstack/ai-anthropic adapter."
+description: "Use Anthropic Claude models in TanStack AI with @tanstack/ai-anthropic: Claude Opus, Sonnet, Haiku, and Fable for chat, thinking, prompt caching, and tools."
keywords:
- tanstack ai
- anthropic
@@ -14,9 +14,9 @@ keywords:
- llm
---
-The Anthropic adapter provides access to Claude models, including Claude Fable 5, Claude Sonnet 5, Claude Opus 4.8, and more.
+The Anthropic adapter calls Claude for chat. It supports thinking, automatic prompt caching, and tools that Anthropic runs.
-## Installation
+## 1. Install
@@ -31,397 +31,96 @@ octane: @tanstack/ai-anthropic
-## Basic Usage
+## 2. Set the API key
-```typescript
-import { chat } from "@tanstack/ai";
-import { anthropicText } from "@tanstack/ai-anthropic";
+Put the key in the environment of your server:
-const stream = chat({
- adapter: anthropicText("claude-sonnet-4-6"),
- messages: [{ role: "user", content: "Hello!" }],
-});
+```bash
+ANTHROPIC_API_KEY=sk-ant-...
```
-## Basic Usage - Custom API Key
+`anthropicText`, `anthropicSummarize`, and `anthropicFiles` use the first of these variables that is set:
-```typescript
-import { chat } from "@tanstack/ai";
-import { createAnthropicChat } from "@tanstack/ai-anthropic";
-
-const adapter = createAnthropicChat("claude-sonnet-4-6", process.env.ANTHROPIC_API_KEY!, {
- // ... your config options
-});
-
-const stream = chat({
- adapter,
- messages: [{ role: "user", content: "Hello!" }],
-});
-```
-
-## Bearer and OAuth tokens
+1. `ANTHROPIC_AUTH_TOKEN`: sent as a Bearer token. An `sk-ant-oat` token here turns on OAuth mode.
+2. `ANTHROPIC_OAUTH_TOKEN`: sent as a Bearer token, in OAuth mode.
+3. `ANTHROPIC_API_KEY`: sent as `x-api-key`.
-Got a Bearer token instead of an API key? Pass it as the credential and set `auth: "bearer"`. The adapter sends `Authorization: Bearer ` and no `x-api-key` header:
+If none of them is set, these factories throw. To pass the credential in code, use `createAnthropicChat`. The `create*` factories never read a credential from the environment:
```typescript
-import { chat } from "@tanstack/ai";
import { createAnthropicChat } from "@tanstack/ai-anthropic";
-const adapter = createAnthropicChat("claude-sonnet-5-5", process.env.MY_CLAUDE_TOKEN!, {
- auth: "bearer",
-});
-
-const stream = chat({
- adapter,
- messages: [{ role: "user", content: "Hello!" }],
-});
+const adapter = createAnthropicChat("claude-sonnet-5-5", process.env.ANTHROPIC_API_KEY!);
```
-`auth` takes one of three values:
-
-- `"api-key"`: sends the `x-api-key` header. This is the default.
-- `"bearer"`: sends `Authorization: Bearer ` and no `x-api-key` header.
-- `"oauth"`: sends the token like `"bearer"`, plus the Claude Code extras below.
-
-Got a Claude OAuth token? It contains `sk-ant-oat`, and the adapter picks `"oauth"` for you, so you can leave `auth` out. In OAuth mode, every request also gets:
-
-- the Claude Code identity system block, before your own system prompts.
-- the Claude Code CLI headers (`user-agent` and `x-app`).
-- the `claude-code-20250219` and `oauth-2025-04-20` betas.
-
-An explicit `auth` always wins. To send an `sk-ant-oat` token as a plain Bearer token, without the extras, set `auth: "bearer"`.
+Got a Bearer or OAuth token? See [Bearer and OAuth tokens](./anthropic-advanced#bearer-and-oauth-tokens).
-Is the token in your environment? Then `anthropicText` finds it and picks `auth` for you. See [Environment Variables](#environment-variables) for the order:
+## 3. Stream a chat from the server
```typescript
-import { chat } from "@tanstack/ai";
-import { anthropicText } from "@tanstack/ai-anthropic";
-
-// ANTHROPIC_AUTH_TOKEN=my-token in your environment
-const stream = chat({
- adapter: anthropicText("claude-sonnet-5-5"),
- messages: [{ role: "user", content: "Hello!" }],
-});
-```
-
-To call Claude models through a GitHub Copilot plan, see [GitHub Copilot](./openai-compatible#claude-models).
-
-## Configuration
-
-```typescript
-import { createAnthropicChat, type AnthropicTextConfig } from "@tanstack/ai-anthropic";
-
-const config: Omit = {
- baseURL: "https://api.anthropic.com", // Optional, for custom endpoints
-};
-
-const adapter = createAnthropicChat("claude-sonnet-4-6", process.env.ANTHROPIC_API_KEY!, config);
-```
-
-Need a header on each request, or a log of each model call? This adapter supports [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). An adapter with your own client (Claude on Vertex, or `createAnthropicChatWithClient`) ignores it, because the adapter cannot reach the fetch of that client.
-
-## Replay thinking through a gateway
-
-Your gateway speaks the Anthropic protocol, but it returns thinking with no signature. By default the adapter does not send unsigned thinking back, so the next turn loses it. Turn on `allowEmptySignature`:
-
-```typescript
-import { anthropicText } from "@tanstack/ai-anthropic";
-
-const gateway = anthropicText("claude-sonnet-5-5", {
- baseURL: "https://gateway.example.com",
- provider: "my-gateway",
- allowEmptySignature: true,
-});
-
-console.log(gateway.provider); // "my-gateway"
-```
-
-- `allowEmptySignature` is `false` by default. It sends unsigned thinking back only for history from the same source.
-- Redacted thinking still needs its encrypted data.
-- `provider` names the gateway. History from the gateway then counts as a different source from Anthropic itself. See [Keep saved history when you switch](../advanced/runtime-adapter-switching#keep-saved-history-when-you-switch).
-
-## Claude on Vertex
-
-Use `@tanstack/ai-anthropic/vertex` when Claude must run on Vertex AI. That
-is the path for regional endpoints and Google Cloud credentials.
-
-Install the Vertex SDK next to the Anthropic adapter:
-
-
-
-react: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
-vue: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
-solid: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
-svelte: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
-preact: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
-angular: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
-vanilla: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
-octane: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
-
-
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { anthropicVertexText } from "@tanstack/ai-anthropic/vertex";
-
-const stream = chat({
- adapter: anthropicVertexText("claude-sonnet-5", {
- project: "my-project",
- location: "europe-west1",
- }),
- messages: [{ role: "user", content: "Hello!" }],
-});
-```
-
-`anthropicVertexText` accepts only the Claude models in the Vertex catalog.
-It does not accept Anthropic-only ids such as `claude-opus-5-fast`.
-
-`project` and `location` use the same names as `@tanstack/ai-vertex`, so one
-auth object works for Gemini and Claude.
-
-If you omit `project`, Application Default Credentials can still fill it.
-`location` is required. You can pass it on the factory or set
-`GOOGLE_CLOUD_LOCATION`, `GOOGLE_VERTEX_LOCATION`, or `CLOUD_ML_REGION`.
-
-Gemini on Vertex lives in [`@tanstack/ai-vertex`](./vertex).
-
-## Custom Anthropic client
-
-Use `createAnthropicChatWithClient` when you already have an
-Anthropic-compatible client. The adapter only needs `beta.messages.create`.
-Message mapping, streaming, tools, media, usage, and structured output stay
-on the same TanStack path.
-
-
-
-react: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
-vue: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
-solid: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
-svelte: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
-preact: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
-angular: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
-vanilla: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
-octane: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk
-
-
-
-```typescript
-import { AnthropicVertex } from "@anthropic-ai/vertex-sdk";
-import { createAnthropicChatWithClient } from "@tanstack/ai-anthropic";
-
-const client = new AnthropicVertex({
- projectId: "my-project",
- region: "europe-west1",
-});
-
-const adapter = createAnthropicChatWithClient("claude-sonnet-5", client);
-```
-
-The injected client must implement the Anthropic Beta Messages protocol.
-Endpoint-specific model and feature support stays the caller's job.
-
-## Example: Chat Completion
-
-```typescript
-import { chat, toServerSentEventsResponse } from "@tanstack/ai";
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
import { anthropicText } from "@tanstack/ai-anthropic";
export async function POST(request: Request) {
- const { messages } = await request.json();
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
const stream = chat({
- adapter: anthropicText("claude-sonnet-4-6"),
+ adapter: anthropicText("claude-sonnet-5-5"),
messages,
+ threadId,
+ runId,
});
return toServerSentEventsResponse(stream);
}
```
-## Example: With Tools
-
-```typescript
-import { chat, toServerSentEventsResponse, toolDefinition } from "@tanstack/ai";
-import { anthropicText } from "@tanstack/ai-anthropic";
-import { z } from "zod";
-
-const searchDatabaseDef = toolDefinition({
- name: "search_database",
- description: "Search the database",
- inputSchema: z.object({
- query: z.string(),
- }),
-});
+## 4. Render the chat
-const searchDatabase = searchDatabaseDef.server(async ({ query }) => {
- // Search database
- return { results: [] };
-});
+The client is the same for every provider. Point `useChat` at your route:
-export async function POST(request: Request) {
- const { messages } = await request.json();
+```tsx
+import { useState } from "react";
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
- const stream = chat({
- adapter: anthropicText("claude-sonnet-4-6"),
- messages,
- tools: [searchDatabase],
+export function Chat() {
+ const [input, setInput] = useState("");
+ const { messages, sendMessage } = useChat({
+ connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
});
- return toServerSentEventsResponse(stream);
+ return (
+
+ );
}
```
-## Model Options
-
-Anthropic supports various provider-specific options. Sampling parameters live here too — `temperature`, `top_p`, and `max_tokens` — rather than as root-level props on `chat()`:
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { anthropicText } from "@tanstack/ai-anthropic";
-
-const stream = chat({
- adapter: anthropicText("claude-sonnet-4-6"),
- messages: [{ role: "user", content: "Hello!" }],
- modelOptions: {
- max_tokens: 4096,
- temperature: 0.7,
- top_p: 0.9,
- top_k: 40,
- stop_sequences: ["END"],
- },
-});
-```
-
-> If you previously passed `temperature` / `topP` / `maxTokens` at the root of `chat()`, see [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options).
-
-#### `max_tokens` default
-
-Anthropic's Messages API _requires_ `max_tokens` on every request, so the adapter always sends a value. When you don't set `modelOptions.max_tokens`, it defaults to the selected model's full output ceiling (`max_output_tokens` from the model metadata — e.g. 64K for Sonnet, 128K for Opus), falling back to a safe constant for unrecognized models. `max_tokens` is a ceiling, not a reservation — billing is on tokens actually generated — so this default costs nothing extra and avoids the silent mid-response truncation (`stop_reason: "max_tokens"`) that a low default would cause. Set `max_tokens` explicitly only when you want to _cap_ output below the model ceiling. If a response is truncated while using the default cap, the adapter logs a warning (visible with [debug logging](../advanced/debug-logging) enabled).
-
-A streamed response that stops at `max_tokens` ends in a `RUN_ERROR` with `code: 'max_tokens'`. Anthropic bills the tokens of that call, so this `RUN_ERROR` carries the `usage` of the call. The `onUsage` middleware hook fires only for `RUN_FINISHED`. To count these tokens too, read `usage` from the `RUN_ERROR` chunk, in the stream or in an `onChunk` middleware.
-
-One exception: structured output (`chat({ outputSchema })`) on models that use the non-streaming finalization path clamps this default to ~21K tokens. The Anthropic SDK rejects a non-streaming request whose `max_tokens` could exceed its 10-minute timeout, so the full ceiling can't be used there. Streaming chat is unaffected. To raise the structured-output ceiling toward a model's true max, stream the response.
-
-### Reasoning
-
-Want Claude to think harder, or not at all? Set `reasoning` on `chat()`. The adapter turns the level into the right thinking fields for the model.
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { anthropicText } from "@tanstack/ai-anthropic";
-
-const stream = chat({
- adapter: anthropicText("claude-opus-5-5"),
- messages: [{ role: "user", content: "Plan a database migration." }],
- reasoning: "xhigh",
-});
-```
-
-What goes on the wire:
-
-- `off` sends `thinking: { type: "disabled" }` on the models with `off` in the table below. On a model without `off`, such as `claude-fable-5`, the types reject it, and at run time it moves to the lowest level that the model has.
-- Claude 4.6 and later get adaptive thinking and `output_config.effort`. With an `outputSchema`, the adapter adds `output_config.format` next to the effort.
-- Budget models get `thinking: { type: "enabled", budget_tokens }`. The level picks the budget, or you set it with `budgetTokens`. The adapter raises `max_tokens` when it is below the budget.
-- `summary: false` sends `display: "omitted"`, so the thinking text does not stream.
-
-The levels of each model:
-
-| Model | Levels | `budgetTokens` |
-| --- | --- | --- |
-| `claude-opus-5-5`, `claude-sonnet-5-5`, `claude-fable-5-1`, `claude-fable-5`, `claude-opus-5`, `claude-opus-4-8`, `claude-opus-4-7` | `low` to `max` | no |
-| `claude-haiku-5-5`, `claude-sonnet-5` | `off`, `low` to `max` | no |
-| `claude-opus-5-fast` | `off`, `low` to `max` | yes |
-| `claude-opus-4-6`, `claude-sonnet-4-6` | `low`, `medium`, `high`, `max` | yes |
-| `claude-opus-4-5` | `low`, `medium`, `high` | yes |
-| `claude-haiku-4-5`, `claude-sonnet-4-5`, `claude-opus-4-1` | `off` to `high` | yes |
-
-A budget model with `budgetTokens`:
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { anthropicText } from "@tanstack/ai-anthropic";
-
-const stream = chat({
- adapter: anthropicText("claude-haiku-4-5"),
- messages: [{ role: "user", content: "Check this proof." }],
- reasoning: { level: "high", budgetTokens: 8000 },
-});
-```
-
-Claude Sonnet 5, Claude Opus 4.7 and later, and the 5-generation models reject the sampling options (`temperature`, `top_p`, `top_k`). The types take them only on the models that accept them. See [Reasoning](../chat/reasoning) for the levels and the nearest-level rule.
-
-Structured output forces a tool, and Claude cannot think while a tool is forced. So the structured output call sends no thinking.
-
-### Prompt Caching
-
-`chat()` caches Claude prompts by default. It adds `cache_control` markers to the last start tool, the last block of the last user or mid-conversation system message, and the system blocks. At most 4 markers. The last start tool is the tool before the deferred placeholder, or the last tool. Pass `promptCache: 'long'` for the 1-hour cache, or `promptCache: 'none'` to send no markers. For the key and the cost, see [Prompt Caching](../advanced/prompt-caching).
-
-Want the markers in other places? Set `cache_control` in the `metadata` of a message part, a system prompt, or a tool. One marker of your own turns the automatic markers off for that request:
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { anthropicText } from "@tanstack/ai-anthropic";
-
-const stream = chat({
- adapter: anthropicText("claude-opus-5-5"),
- messages: [
- {
- role: "user",
- content: [
- {
- type: "text",
- content: "What is the capital of France?",
- metadata: {
- cache_control: {
- type: "ephemeral",
- },
- },
- },
- ],
- },
- ],
-});
-```
-
-`modelOptions.cache_control` also turns the automatic markers off.
-
-`usage.promptTokens` counts the full input, cached tokens included. The cache reads and writes are also on `usage.promptTokensDetails`. See [Token usage](../chat/stream-events#token-usage).
-
-#### Tools and prompts added during a conversation
-
-On `claude-opus-4-8`, `claude-opus-5`, `claude-opus-5-5`, `claude-fable-5`, and `claude-fable-5-1`, a tool or a system prompt that you add between model calls goes into the conversation. The cached start of the request stays the same.
-
-What the adapter sends on these models:
+For Vue, Solid, Svelte, Angular, and the other frameworks, see [Quick Start](../getting-started/quick-start#3-render-the-chat).
-- `system` keeps the system prompts of the first call.
-- A request with tools gets the `mid-conversation-tool-changes-2026-07-01` beta and one placeholder tool, `__tanstack_deferred_placeholder__`. The model never calls it.
-- An added tool goes at the end of `tools` with `defer_loading: true`. A `system` message names it in a `tool_addition` block.
-- An added system prompt goes into a `system` message, before the next assistant message or at the end.
-- With a provider tool such as `webSearchTool()`, `tools` is the full list and has no placeholder.
-
-The automatic tool marker stays on the last tool of the first call, so the marked start does not move.
-
-The channels are on by default with Anthropic's own API. A custom `baseURL`, a custom `fetch`, `ANTHROPIC_BASE_URL`, or your own client turns them off. Set `midConversationChannels` to choose:
-
-```typescript
-import { anthropicText } from "@tanstack/ai-anthropic";
-
-export const fullLists = anthropicText("claude-opus-5-5", {
- midConversationChannels: false,
-});
-
-export const throughProxy = anthropicText("claude-opus-5-5", {
- baseURL: "https://llm-proxy.example.com",
- midConversationChannels: true,
-});
-```
-
-If that endpoint sends the request and the `anthropic-beta` header on to Anthropic as they are, set `true`. See [Mid-Conversation Changes](../advanced/mid-conversation-changes).
+You now have a streaming Claude chat. [Tools](../tools/tools) and [Structured Outputs](../structured-outputs/overview) work the same as with every adapter.
## Summarization
-Anthropic supports text summarization:
-
```typescript
import { summarize } from "@tanstack/ai";
import { anthropicSummarize } from "@tanstack/ai-anthropic";
@@ -429,6 +128,7 @@ import { anthropicSummarize } from "@tanstack/ai-anthropic";
const result = await summarize({
adapter: anthropicSummarize("claude-sonnet-4-6"),
text: "Your long text to summarize...",
+ threadId: "thread-1",
maxLength: 100,
style: "concise", // "concise" | "bullet-points" | "paragraph"
});
@@ -436,89 +136,17 @@ const result = await summarize({
console.log(result.summary);
```
-## Environment Variables
-
-Set one of these. `anthropicText`, `anthropicSummarize`, and `anthropicFiles` use the first one that is set:
-
-1. `ANTHROPIC_AUTH_TOKEN`: sent as a Bearer token. An `sk-ant-oat` token here turns on OAuth mode.
-2. `ANTHROPIC_OAUTH_TOKEN`: sent as a Bearer token, in OAuth mode.
-3. `ANTHROPIC_API_KEY`: sent as `x-api-key`.
-
-```bash
-ANTHROPIC_API_KEY=sk-ant-...
-```
+## Files
-If none of them is set, these factories throw. An `auth` in the config wins over the kind that the adapter picks. See [Bearer and OAuth tokens](#bearer-and-oauth-tokens) for what OAuth mode adds.
+`anthropicFiles` uploads media to the Anthropic Files API, so a message can point to the file. See [Files API](../advanced/files-api).
-The `create*` factories (`createAnthropicChat`, `createAnthropicSummarize`, `createAnthropicFiles`) never read a credential from the environment. They use only the credential that you pass.
+## No image generation
-## API Reference
+Anthropic does not make images. For images, use [OpenAI](./openai) or [Gemini](./gemini).
-Every factory pair has the same shape. The short factory (`anthropicText`, `anthropicSummarize`) reads the credential from the [environment](#environment-variables). `createAnthropicChat` / `createAnthropicSummarize` take an explicit credential and never read one from the environment. Both take `model` as the first argument. For Claude on Vertex, use `anthropicVertexText` from `@tanstack/ai-anthropic/vertex`. For any other custom transport, `createAnthropicChatWithClient` accepts an Anthropic-compatible Messages client.
+## Provider tools
-### `anthropicText(model, config?)` / `createAnthropicChat(model, apiKey, config?)`
-
-Creates an Anthropic chat adapter.
-
-`adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts).
-
-**Parameters:**
-
-- `model` - Claude model id (e.g. `"claude-sonnet-5"`, `"claude-fable-5"`, `"claude-opus-4-8"`)
-- `apiKey` - API key, Bearer token, or Claude OAuth token (`createAnthropicChat` only)
-- `config?.baseURL` - Custom base URL (optional)
-- `config?.auth` - How to send the credential: `"api-key"`, `"bearer"`, or `"oauth"` (optional, see [Bearer and OAuth tokens](#bearer-and-oauth-tokens))
-- `config?.allowEmptySignature` - Send unsigned thinking back to the same source (optional, default `false`, see [Replay thinking through a gateway](#replay-thinking-through-a-gateway))
-- `config?.provider` - The source name for a gateway (optional, default `"anthropic"`)
-
-### `anthropicVertexText(model, config?)`
-
-Creates an Anthropic chat adapter on Vertex. Import it from
-`@tanstack/ai-anthropic/vertex`.
-
-**Parameters:**
-
-- `model` - Claude model id
-- `config.project` - GCP project id (optional if ADC can resolve it)
-- `config.location` - Vertex region (or set `GOOGLE_CLOUD_LOCATION`)
-
-### `createAnthropicChatWithClient(model, client)`
-
-Creates an Anthropic chat adapter using an injected client.
-
-**Parameters:**
-
-- `model` - Claude model id
-- `client` - Client exposing `beta.messages.create`
-
-### `anthropicSummarize(model, config?)` / `createAnthropicSummarize(model, apiKey, config?)`
-
-Creates an Anthropic summarization adapter.
-
-## Limitations
-
-- **Image Generation**: Anthropic does not support image generation. Use OpenAI or Gemini for image generation.
-
-## Next Steps
-
-- [Getting Started](../getting-started/quick-start) - Learn the basics
-- [Tools Guide](../tools/tools) - Learn about tools
-- [Other Adapters](./openai) - Explore other providers
-
-## Provider Tools
-
-Anthropic exposes several native tools beyond user-defined function calls.
-Import them from `@tanstack/ai-anthropic/tools` and pass them into
-`chat({ tools: [...] })`.
-
-> For the full concept, a comparison matrix, and type-gating details, see
-> [Provider Tools](../tools/provider-tools.md).
-
-### `webSearchTool`
-
-Enables Claude to run Anthropic's native web search with inline citations.
-Scope the search with `allowed_domains` or `blocked_domains` (mutually
-exclusive); set `max_uses` to cap per-turn cost.
+Anthropic runs some tools itself, such as web search and a code sandbox. Import them from `@tanstack/ai-anthropic/tools` and add them to `tools`:
```typescript
import { chat } from "@tanstack/ai";
@@ -526,8 +154,9 @@ import { anthropicText } from "@tanstack/ai-anthropic";
import { webSearchTool } from "@tanstack/ai-anthropic/tools";
const stream = chat({
- adapter: anthropicText("claude-opus-4-7"),
+ adapter: anthropicText("claude-sonnet-5-5"),
messages: [{ role: "user", content: "What's new in AI this week?" }],
+ threadId: "thread-1",
tools: [
webSearchTool({
name: "web_search",
@@ -538,242 +167,117 @@ const stream = chat({
});
```
-**Supported models:** every registered Claude model. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+Tools that Anthropic runs:
-### `webFetchTool`
+- `webSearchTool`: search the web, with citations.
+- `webFetchTool`: read one URL.
+- `codeExecutionTool`: run code in a sandbox, also with hosted skills.
+- `memoryTool`: keep facts across turns.
-Lets Claude fetch the contents of a URL directly, useful when you want the
-model to read a specific page rather than run a search. Takes no required
-arguments — pass an optional config object to override defaults.
+Tools that your app runs when Claude asks:
-```typescript
-import { chat } from "@tanstack/ai";
-import { anthropicText } from "@tanstack/ai-anthropic";
-import { webFetchTool } from "@tanstack/ai-anthropic/tools";
+- `bashTool`: run shell commands.
+- `textEditorTool`: view and change files.
+- `computerUseTool`: control a virtual desktop.
+- `customTool`: a tool with an inline schema.
-const stream = chat({
- adapter: anthropicText("claude-sonnet-4-6"),
- messages: [{ role: "user", content: "Summarise https://example.com" }],
- tools: [webFetchTool()],
-});
-```
+Each tool, with a full sample, is on [Anthropic: Advanced](./anthropic-advanced#provider-tools). For which models take which tool, see [Provider Tools](../tools/provider-tools.md).
-**Supported models:** Claude Sonnet 4.x and above. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+## Models and options
-### `codeExecutionTool`
+### Model options
-Gives Claude a sandboxed code-execution environment so it can run Python
-snippets, analyse data, and return results inline. Choose the version string
-that matches your desired API revision.
+Provider options go in `modelOptions`, with the Anthropic names:
```typescript
import { chat } from "@tanstack/ai";
import { anthropicText } from "@tanstack/ai-anthropic";
-import { codeExecutionTool } from "@tanstack/ai-anthropic/tools";
const stream = chat({
- adapter: anthropicText("claude-sonnet-4-6"),
- messages: [{ role: "user", content: "Plot a histogram of [1,2,2,3,3,3]" }],
- tools: [
- codeExecutionTool({ name: "code_execution", type: "code_execution_20250825" }),
- ],
+ adapter: anthropicText("claude-sonnet-5-5"),
+ messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
+ modelOptions: {
+ max_tokens: 4096,
+ stop_sequences: ["END"],
+ },
});
```
-**Supported models:** Claude Sonnet 4.x and above. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
-
-#### Attaching hosted skills
+- The sampling options `temperature`, `top_p`, and `top_k` also go in `modelOptions`. Claude Sonnet 5, Claude Opus 4.7 and later, and the 5-generation models reject them. The types take them only on the models that accept them, for example `claude-sonnet-4-6`.
+- When you do not set `max_tokens`, the adapter sends the full output limit of the model. See [The `max_tokens` default](./anthropic-advanced#max_tokens-default).
+- Sampling options do not go at the root of `chat()`. See [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options).
-Pass a `skills` array as the second argument to load provider-managed skill
-bundles into the sandbox. The adapter auto-lifts them into the API's
-`container.skills` param and adds the required beta headers for you.
-
-```typescript
-import { chat, toServerSentEventsResponse } from "@tanstack/ai";
-import { anthropicText } from "@tanstack/ai-anthropic";
-import { codeExecutionTool } from "@tanstack/ai-anthropic/tools";
-
-export async function POST(request: Request) {
- const { messages } = await request.json();
-
- const stream = chat({
- adapter: anthropicText("claude-sonnet-4-5"),
- messages,
- tools: [
- codeExecutionTool(
- { type: "code_execution_20250825", name: "code_execution" },
- {
- skills: [{ type: "anthropic", skill_id: "pptx", version: "latest" }],
- },
- ),
- ],
- });
-
- return toServerSentEventsResponse(stream);
-}
-```
-
-For the full reference — skill shape, constraints, scope, and the OpenAI
-equivalent — see [Provider Skills](../tools/provider-skills.md).
-
-### `computerUseTool`
+### Reasoning
-Claude can look at a screen and use the mouse and the keyboard. Your app runs each action and sends back a screenshot. `createComputerUse` from `@tanstack/ai-computer-use` does this on a Playwright browser or an E2B desktop. Each thread gets its own machine:
+Want Claude to think harder, or not at all? Set `reasoning` on `chat()`. The adapter turns the level into the right thinking fields for the model.
```typescript
import { chat } from "@tanstack/ai";
import { anthropicText } from "@tanstack/ai-anthropic";
-import { createComputerUse } from "@tanstack/ai-computer-use";
-import { playwrightComputer } from "@tanstack/ai-computer-playwright";
-
-declare const threadId: string;
-
-const computerUse = createComputerUse({
- driver: playwrightComputer({ startUrl: "https://tanstack.com" }),
-});
const stream = chat({
adapter: anthropicText("claude-opus-5-5"),
- messages: [{ role: "user", content: "Find the TanStack AI docs" }],
- threadId,
- tools: computerUse.tools,
- middleware: [computerUse.middleware],
- interrupts: [computerUse.interrupt],
-});
-```
-
-The adapter sends the `computer` tool to Claude as the newest native computer tool that your model takes.
-
-- For the setup, the threads, and how to close the machines, see [Computer use](../computer-use/computer-use.md).
-- For your own machine, see [Custom driver](../computer-use/custom-driver.md).
-
-#### The raw tool
-
-`computerUseTool` from `@tanstack/ai-anthropic/tools` is the native tool with no `execute`. Use it when your app runs the actions itself. Pick the version that your model takes:
-
-| Version | Models |
-| --- | --- |
-| `computer_toolset_20260801` | Claude Opus 5.5, Sonnet 5.5, Haiku 5.5, Fable 5.1, Fable 5, Opus 5, Sonnet 5, Opus 4.8 |
-| `computer_20251124` | Claude Fable 5.1, Fable 5, Opus 5, Sonnet 5, Opus 4.8, Opus 4.7, Opus 4.6, Sonnet 4.6, Opus 4.5 |
-| `computer_20250124` | Claude Sonnet 4.5, Haiku 4.5, Opus 4.1 |
-
-```typescript
-import { computerUseTool } from "@tanstack/ai-anthropic/tools";
-
-const computer = computerUseTool({ type: "computer_toolset_20260801" });
-```
-
-The earlier versions also take a name and the screen size:
-
-```typescript
-import { computerUseTool } from "@tanstack/ai-anthropic/tools";
-
-const computer = computerUseTool({
- type: "computer_20251124",
- name: "computer",
- display_width_px: 1280,
- display_height_px: 800,
+ messages: [{ role: "user", content: "Plan a database migration." }],
+ threadId: "thread-1",
+ reasoning: "xhigh",
});
```
-Add an `execute` that runs the action and returns a result:
-
-- Every version calls `execute` with the action in `input.action`. With the toolset, Claude calls each action as its own tool. The adapter gives you each call as one `computer` call.
-- Return a screenshot as an image part for the `screenshot` action, and `"OK"` for the other actions.
-- Send screenshots that fit the image limits of the model. The toolset refuses a large screenshot. It does not make it smaller.
-- The toolset turns on `zoom` by default. Return an image for the `zoom` action, or turn it off with `configs: { zoom: { enabled: false } }`.
-- The adapter sends the beta header that an earlier version needs. The toolset needs no beta header.
-
-Claude gives coordinates in the pixels of your screenshots.
-
-A version that your model does not take is a type error. `claude-opus-5-fast` takes no provider tools. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
-
-### `bashTool`
-
-Provides Claude with a persistent bash shell session, letting it run arbitrary
-commands, install packages, or manipulate files on the host. Choose the type
-string that matches your API revision.
+What goes to Anthropic:
-```typescript
-import { chat } from "@tanstack/ai";
-import { anthropicText } from "@tanstack/ai-anthropic";
-import { bashTool } from "@tanstack/ai-anthropic/tools";
-
-const stream = chat({
- adapter: anthropicText("claude-sonnet-4-6"),
- messages: [{ role: "user", content: "List all TypeScript files in src/" }],
- tools: [bashTool({ name: "bash", type: "bash_20250124" })],
-});
-```
+- `off` sends `thinking: { type: "disabled" }` on the models with `off` in the table below. On a model without `off`, such as `claude-fable-5`, the types reject it. At run time, it moves to the lowest level that the model has.
+- Claude 4.6 and later get adaptive thinking and `output_config.effort`. With an `outputSchema`, the adapter adds `output_config.format` next to the effort.
+- Budget models get `thinking: { type: "enabled", budget_tokens }`. The level picks the budget, or you set it with `budgetTokens`. If `max_tokens` is below the budget, the adapter raises it.
+- `summary: false` sends `display: "omitted"`, so the thinking text does not stream.
-**Supported models:** Claude Sonnet 3.5 and above. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+The levels of each model:
-### `textEditorTool`
+| Model | Levels | `budgetTokens` |
+| --- | --- | --- |
+| `claude-opus-5-5`, `claude-sonnet-5-5`, `claude-fable-5-1`, `claude-fable-5`, `claude-opus-5`, `claude-opus-4-8`, `claude-opus-4-7` | `low` to `max` | no |
+| `claude-haiku-5-5`, `claude-sonnet-5` | `off`, `low` to `max` | no |
+| `claude-opus-5-fast` | `off`, `low` to `max` | yes |
+| `claude-opus-4-6`, `claude-sonnet-4-6` | `low`, `medium`, `high`, `max` | yes |
+| `claude-opus-4-5` | `low`, `medium`, `high` | yes |
+| `claude-haiku-4-5`, `claude-sonnet-4-5`, `claude-opus-4-1` | `off` to `high` | yes |
-Gives Claude a structured text-editor interface for viewing and modifying files
-using `str_replace`, `create`, `view`, and `undo_edit` commands. Choose the
-type string for the API revision you target.
+A budget model with `budgetTokens`:
```typescript
import { chat } from "@tanstack/ai";
import { anthropicText } from "@tanstack/ai-anthropic";
-import { textEditorTool } from "@tanstack/ai-anthropic/tools";
const stream = chat({
- adapter: anthropicText("claude-sonnet-4-6"),
- messages: [{ role: "user", content: "Fix the bug in src/index.ts" }],
- tools: [
- textEditorTool({ type: "text_editor_20250124", name: "str_replace_editor" }),
- ],
+ adapter: anthropicText("claude-haiku-4-5"),
+ messages: [{ role: "user", content: "Check this proof." }],
+ threadId: "thread-1",
+ reasoning: { level: "high", budgetTokens: 8000 },
});
```
-**Supported models:** Claude Sonnet 3.5 and above. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
-
-### `memoryTool`
-
-Enables Claude to store and retrieve information across conversation turns
-using Anthropic's managed memory service. Call with no arguments to use
-default configuration.
+Structured output forces a tool, and Claude cannot think while a tool is forced. So the structured output call sends no thinking. See [Reasoning](../chat/reasoning) for the levels and the nearest-level rule.
-```typescript
-import { chat } from "@tanstack/ai";
-import { anthropicText } from "@tanstack/ai-anthropic";
-import { memoryTool } from "@tanstack/ai-anthropic/tools";
+### Models
-const stream = chat({
- adapter: anthropicText("claude-sonnet-4-6"),
- messages: [{ role: "user", content: "Remember that I prefer metric units" }],
- tools: [memoryTool()],
-});
-```
+Newest first. The package exports the full list as `ANTHROPIC_MODELS`.
-**Supported models:** Claude Sonnet 4.x and above. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+| Family | Models |
+| --- | --- |
+| Claude 5.5 | `claude-opus-5-5`, `claude-sonnet-5-5`, `claude-haiku-5-5` |
+| Claude 5 | `claude-fable-5-1`, `claude-fable-5`, `claude-opus-5`, `claude-opus-5-fast`, `claude-sonnet-5` |
+| Claude 4 | `claude-opus-4-8`, `claude-opus-4-7`, `claude-opus-4-6`, `claude-sonnet-4-6`, `claude-opus-4-5`, `claude-sonnet-4-5`, `claude-haiku-4-5`, `claude-opus-4-1` |
-### `customTool`
+`adapter.inputModalities` lists the input kinds of the selected model. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts).
-Creates a tool with an inline JSON Schema input definition instead of going
-through `toolDefinition()`. Useful when you need fine-grained control over the
-schema shape or want to add `cache_control`. Unlike branded provider tools,
-`customTool` returns a plain `Tool` and is accepted by any chat model.
+## Advanced
-```typescript
-import { chat } from "@tanstack/ai";
-import { anthropicText } from "@tanstack/ai-anthropic";
-import { customTool } from "@tanstack/ai-anthropic/tools";
-import { z } from "zod";
+These topics are on [Anthropic: Advanced](./anthropic-advanced):
-const stream = chat({
- adapter: anthropicText("claude-sonnet-4-6"),
- messages: [{ role: "user", content: "Look up user 42" }],
- tools: [
- customTool(
- "lookup_user",
- "Look up a user by ID and return their profile",
- z.object({ userId: z.number() }),
- ),
- ],
-});
-```
+- Bearer and OAuth tokens, gateways, and your own Anthropic client.
+- Claude on Vertex AI.
+- The `max_tokens` default and prompt caching.
+- Every provider tool, with a full sample.
+- The API reference.
-**Supported models:** all current Claude models. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+To call Claude through a GitHub Copilot plan, see [GitHub Copilot](./openai-compatible-advanced#claude-models).
diff --git a/docs/adapters/bedrock.md b/docs/adapters/bedrock.md
index 6f1ab9e57a..3edacf2446 100644
--- a/docs/adapters/bedrock.md
+++ b/docs/adapters/bedrock.md
@@ -2,7 +2,7 @@
title: Amazon Bedrock
id: bedrock-adapter
order: 7
-description: "Use Amazon Bedrock with TanStack AI — the Converse API is the default, reaching Claude, Nova, Llama, Mistral, DeepSeek, and more. Opt into OpenAI-compatible Chat Completions or Responses for open-weight and gpt-oss models. Supports streaming, tools, reasoning, and API-key or SigV4 auth."
+description: "Use Amazon Bedrock in TanStack AI with @tanstack/ai-bedrock: Claude, Nova, Llama, Mistral, DeepSeek, and gpt-oss through the Converse API or the OpenAI-compatible APIs, with API-key or SigV4 auth."
keywords:
- tanstack ai
- amazon bedrock
@@ -19,18 +19,9 @@ keywords:
- adapter
---
-The Bedrock adapter connects TanStack AI to [Amazon Bedrock](https://aws.amazon.com/bedrock/) with three API paths:
+The Bedrock adapter calls Claude, Nova, Llama, Mistral, DeepSeek, and gpt-oss in your AWS account. It supports streaming, tools, reasoning, and embeddings.
-- **Converse** (default) — Bedrock's model-agnostic API built on `@aws-sdk/client-bedrock-runtime`. Reaches the broad chat catalog including Anthropic Claude, Amazon Nova, Meta Llama, Mistral, DeepSeek, Cohere, AI21, and OpenAI gpt-oss models.
-- **Chat Completions** (`api: 'chat'`) — Bedrock's OpenAI-compatible Chat Completions endpoint. Reaches open-weight models only (gpt-oss, DeepSeek V3.x, Gemma, Qwen, Mistral open models, GLM, etc.). Does NOT reach Claude, Nova, or Llama.
-- **Responses** (`api: 'responses'`) — Bedrock's OpenAI-compatible Responses API, mantle-only. Currently gpt-oss and Gemma 4.
-
-All paths support streaming and client-side tool calling. Reasoning output is
-surfaced when the model emits it (e.g. DeepSeek R1, gpt-oss); on the Converse
-path, request-side enablement of extended thinking (e.g. a Claude thinking
-budget) is not yet wired, so only models that reason by default surface it.
-
-## Installation
+## 1. Install
@@ -45,206 +36,352 @@ octane: @tanstack/ai-bedrock
-No additional packages are required. SigV4 authentication is handled by `@aws-sdk/client-bedrock-runtime`, which is a direct dependency.
+You need no other package. `@aws-sdk/client-bedrock-runtime` is a direct dependency, and it does the SigV4 signing.
-## Quick Start (Converse — default)
+## 2. Set the credentials
-The default `bedrockText` call uses the Converse API and reaches the broad model catalog:
+Bedrock takes an API key or your AWS credentials. Pick one.
-```typescript ignore
-// ignore: iterating a chat() stream and reading chunk.type/chunk.delta needs the
-// AG-UI base event fields, which come from @ag-ui/core. It's a transitive dep of
-// @tanstack/ai, so kiira (resolving @tanstack/ai from source under the dist->src
-// heuristic) can't follow it and those base fields drop off StreamChunk. The code
-// is correct (the same pattern is used throughout ai-client); see
-// getting-started/quick-start-server for the type-checked consumption shape.
-import { bedrockText } from '@tanstack/ai-bedrock'
-import { chat } from '@tanstack/ai'
+**API key.** Create a key in the AWS Console. See the [Bedrock API keys guide](https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html). Then set one of these variables:
-const adapter = bedrockText('us.anthropic.claude-haiku-4-5-20251001-v1:0', {
- region: 'us-east-1',
-})
+```bash
+BEDROCK_API_KEY=your-bedrock-api-key
+# or the legacy name:
+AWS_BEARER_TOKEN_BEDROCK=your-bedrock-api-key
+```
-for await (const chunk of chat({
- adapter,
- messages: [{ role: 'user', content: 'What is the capital of France?' }],
-})) {
- if (chunk.type === 'TEXT_MESSAGE_CONTENT') process.stdout.write(chunk.delta ?? '')
+**SigV4 (AWS credential chain).** Use this for IAM roles, instance profiles, or `~/.aws/credentials`. With no API key in the environment, the adapter uses SigV4:
+
+```bash
+AWS_ACCESS_KEY_ID=...
+AWS_SECRET_ACCESS_KEY=...
+AWS_SESSION_TOKEN=... # optional, for temporary credentials
+```
+
+With `auth: 'auto'` (the default), the adapter uses the first of these that it finds:
+
+1. An explicit `apiKey` passed to the factory.
+2. The `BEDROCK_API_KEY` environment variable.
+3. The `AWS_BEARER_TOKEN_BEDROCK` environment variable.
+4. SigV4 through the standard AWS credential chain.
+
+Also enable access to the model in the [Amazon Bedrock console](https://console.aws.amazon.com/bedrock/home#/modelaccess). The models that you can use depend on your account and your region.
+
+## 3. Stream a chat from the server
+
+```typescript
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
+import { bedrockText } from "@tanstack/ai-bedrock";
+
+export async function POST(request: Request) {
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
+
+ const stream = chat({
+ adapter: bedrockText("us.anthropic.claude-sonnet-4-5-20250929-v1:0", {
+ region: "us-east-1",
+ }),
+ messages,
+ threadId,
+ runId,
+ });
+
+ return toServerSentEventsResponse(stream);
}
```
-Equivalent to passing `{ api: 'converse' }` explicitly. Returns a `bedrock-converse` adapter.
+`bedrockText` uses the Converse API by default. Converse reaches the full chat catalog of Bedrock. For the other two APIs, see [Choose the API](#choose-the-api).
+
+## 4. Render the chat
+
+The client is the same for every provider. Point `useChat` at your route:
+
+```tsx
+import { useState } from "react";
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
+
+export function Chat() {
+ const [input, setInput] = useState("");
+ const { messages, sendMessage } = useChat({
+ connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
+ });
+
+ return (
+
+ );
+}
+```
-## Authentication
+For Vue, Solid, Svelte, Angular, and the other frameworks, see [Quick Start](../getting-started/quick-start#3-render-the-chat).
-Bedrock supports two authentication modes.
+You now have a streaming chat on Bedrock. Client-side [tools](../tools/tools) work on all three APIs. Structured output and multimodal input (text, images, and documents) depend on the model.
-### API Key
+## Embeddings
-Bedrock issues API keys from the AWS Console. See the [Bedrock API keys guide](https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html) for instructions.
+`bedrockEmbedding` calls the Titan and Cohere embedding models through `InvokeModel`:
-Set one of the following environment variables and the adapter picks it up automatically:
+```typescript
+import { embed } from "@tanstack/ai";
+import { bedrockEmbedding } from "@tanstack/ai-bedrock";
-```bash
-BEDROCK_API_KEY=your-bedrock-api-key
-# or the legacy name:
-AWS_BEARER_TOKEN_BEDROCK=your-bedrock-api-key
+const result = await embed({
+ adapter: bedrockEmbedding("amazon.titan-embed-text-v2:0"),
+ input: ["a red guitar", "a blue drum kit"],
+ dimensions: 512, // 256 | 512 | 1024
+});
+
+console.log(result.embeddings[0]?.vector);
```
-### SigV4 (AWS credential chain)
+Titan Multimodal embeds text and images, alone or joined into one vector. To join parts, nest them in an array (the same `Array` shape as the `content` of a chat message). The outer array is the list of items, so this embeds one joined item:
+
+```typescript
+import { embed } from "@tanstack/ai";
+import { bedrockEmbedding } from "@tanstack/ai-bedrock";
-For workloads using IAM roles, instance profiles, or `~/.aws/credentials`, set `auth: 'sigv4'` (or leave it as `'auto'` with no API key in the environment). SigV4 works out of the box via `@aws-sdk/client-bedrock-runtime` — no additional packages required.
+const productPhoto = "iVBORw0KGgo..."; // base64 image data
-```bash
-AWS_ACCESS_KEY_ID=...
-AWS_SECRET_ACCESS_KEY=...
-AWS_SESSION_TOKEN=... # optional, for temporary credentials
+const result = await embed({
+ adapter: bedrockEmbedding("amazon.titan-embed-image-v1"),
+ input: [
+ [
+ { type: "text", content: "a red guitar" },
+ {
+ type: "image",
+ source: {
+ type: "data",
+ value: productPhoto,
+ mimeType: "image/png",
+ },
+ },
+ ],
+ ],
+ dimensions: 1024, // 256 | 384 | 1024
+});
+```
+
+Cohere Embed v3 on Bedrock is text only. It sends items in batches and requires `inputType`:
+
+```typescript
+import { embed } from "@tanstack/ai";
+import { bedrockEmbedding } from "@tanstack/ai-bedrock";
+
+const result = await embed({
+ adapter: bedrockEmbedding("cohere.embed-english-v3"),
+ input: ["a red guitar", "a blue drum kit"],
+ modelOptions: { inputType: "search_document" },
+});
```
-### Auth resolution order (`auth: 'auto'`, the default)
+- Titan models have no batch API. A batch of N items runs as N `InvokeModel` calls, with a small limit on calls at the same time.
+- Titan Multimodal does not fetch remote image URLs. Pass base64 data (or a `data:` URI).
-1. Explicit `apiKey` passed to the factory
-2. `BEDROCK_API_KEY` environment variable
-3. `AWS_BEARER_TOKEN_BEDROCK` environment variable
-4. SigV4 via the standard AWS credential chain
+See the [Embeddings guide](../embeddings.md).
-## Configuration
+## Models and options
-`BedrockClientConfig` accepts the following options:
+### Choose the API
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `api` | `'converse' \| 'chat' \| 'responses'` | `'converse'` | Bedrock API to use |
-| `region` | `string` | `'us-east-1'` | AWS region string (e.g. `'us-west-2'`) |
-| `auth` | `'apikey' \| 'sigv4' \| 'auto'` | `'auto'` | Authentication mode |
-| `apiKey` | `string` | — | Explicit API key (overrides env vars) |
-| `baseURL` | `string` | — | Override the computed base URL entirely |
-| `defaultHeaders` | `Record` | none | Headers sent with every request |
-| `endpoint` | `'runtime' \| 'mantle'` | `'runtime'` | Bedrock endpoint to target (Chat Completions path only) |
+Bedrock has three chat APIs. Set `api` on the factory:
+
+| `api` | Adapter name | Models | SDK |
+|---|---|---|---|
+| `'converse'` (default) | `bedrock-converse` | The full catalog: Claude, Nova, Llama, Mistral, DeepSeek, Cohere, AI21, gpt-oss, and more | `@aws-sdk/client-bedrock-runtime` |
+| `'chat'` | `bedrock` | Open-weight models only: gpt-oss, DeepSeek V3.x, Gemma, Qwen, Mistral open models, GLM, and similar. No Claude, Nova, or Llama | `openai` (OpenAI-compatible Chat Completions) |
+| `'responses'` | `bedrock-responses` | gpt-oss and Gemma 4. Mantle only | `openai` (OpenAI-compatible Responses) |
-The `endpoint` option only applies when `api: 'chat'`. The `runtime` endpoint (`bedrock-runtime`) hosts the broad open-weight catalog; `mantle` is an alternative. On mantle, the adapter reads the path from the Bedrock API compatibility config (`/openai/v1` for Gemma 4, `/v1` for other models). An explicit `baseURL` still wins. The Responses API always targets mantle.
+Use Converse unless you need the OpenAI wire format. For which models work with which API, see the [AWS API compatibility matrix](https://docs.aws.amazon.com/bedrock/latest/userguide/models-api-compatibility.html). Samples for `'chat'` and `'responses'` are in [Advanced](#chat-completions-api).
-## Behind a proxy
+### Reasoning
-Route every request through a gateway, such as Cloudflare AI Gateway or a corporate proxy, with `baseURL` and `defaultHeaders`. These two option names are the same on every TanStack AI adapter, so one gateway config works for all of them.
+Set `reasoning` on `chat()`. What goes to Bedrock depends on the API:
+
+- Converse (the default): only Claude gets thinking. A budget model gets `thinking` with a budget in `additionalModelRequestFields`, and `maxTokens` grows past the budget. `off` sends nothing.
+- Chat Completions (`api: 'chat'`): `reasoning_effort`.
+- Responses (`api: 'responses'`): `reasoning.effort`.
```typescript
-import { createBedrockText } from "@tanstack/ai-bedrock";
+import { chat } from "@tanstack/ai";
+import { bedrockText } from "@tanstack/ai-bedrock";
-const adapter = createBedrockText("us.amazon.nova-pro-v1:0", process.env.BEDROCK_API_KEY!, {
- baseURL: "https://gateway.example.com/aws-bedrock",
- defaultHeaders: { "cf-aig-authorization": `Bearer ${process.env.GATEWAY_TOKEN}` },
+const stream = chat({
+ adapter: bedrockText("us.anthropic.claude-haiku-4-5-20251001-v1:0", {
+ region: "us-east-1",
+ }),
+ messages: [{ role: "user", content: "Check this proof." }],
+ threadId: "thread-1",
+ reasoning: { level: "high", budgetTokens: 8000 },
});
```
-All three Bedrock APIs accept these options. On the Converse API the headers are added before SigV4 signing, so a signed request still includes them.
+The levels of each model:
-Need more than a fixed header, such as a log of each model call? The Chat Completions API (`api: 'chat'`) and the Responses API (`api: 'responses'`) support [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). The Converse API ignores it, because the AWS SDK does not send its requests through `fetch`.
+- `us.anthropic.claude-haiku-4-5-*`, `us.anthropic.claude-sonnet-4-5-*`: `off`, `high`, with `budgetTokens`.
+- `openai.gpt-oss-120b-1:0`, `openai.gpt-oss-20b-1:0`: `low`, `medium`, `high`.
+- The `google.gemma-4` models: `off`, `high`.
-## Converse API (default)
+The other models take no `reasoning`. `us.deepseek.r1-v1:0` always thinks, and has no levels. When a model thinks by default, such as DeepSeek R1 or gpt-oss, the adapter streams its thinking.
-`bedrockText(model)` or `bedrockText(model, { api: 'converse' })` returns a `bedrock-converse` adapter backed by `@aws-sdk/client-bedrock-runtime`. This is Bedrock's model-agnostic conversational API and is the recommended path for most use cases.
+On Converse, structured output forces a tool, and Claude cannot think while a tool is forced. So the structured output call sends no thinking.
-**Model scope:** Anthropic Claude, Amazon Nova, Meta Llama, Mistral, DeepSeek, Cohere, AI21, OpenAI gpt-oss, and other models accessible in your account. See [Model availability](#model-availability) below.
+### Models
-```typescript
-import { bedrockText } from '@tanstack/ai-bedrock'
-import { chat } from '@tanstack/ai'
+The package ships a catalog of confirmed model ids (`src/model-catalog.generated.ts`). The maintainer script `scripts/fetch-bedrock-models.ts` refreshes it with `ListFoundationModels`.
-// Claude via Converse
-const claudeAdapter = bedrockText('us.anthropic.claude-haiku-4-5-20251001-v1:0', {
- region: 'us-east-1',
-})
+| Group | Model ids |
+| --- | --- |
+| Anthropic Claude | `us.anthropic.claude-sonnet-4-5-20250929-v1:0`, `us.anthropic.claude-haiku-4-5-20251001-v1:0`, `us.anthropic.claude-3-7-sonnet-20250219-v1:0`, `us.anthropic.claude-3-5-sonnet-20241022-v2:0`, `us.anthropic.claude-3-5-haiku-20241022-v1:0` |
+| Amazon Nova | `us.amazon.nova-pro-v1:0`, `us.amazon.nova-lite-v1:0`, `us.amazon.nova-micro-v1:0` |
+| Meta Llama | `us.meta.llama4-maverick-17b-instruct-v1:0`, `us.meta.llama3-3-70b-instruct-v1:0` |
+| Other chat | `openai.gpt-oss-120b-1:0`, `openai.gpt-oss-20b-1:0`, `us.mistral.pixtral-large-2502-v1:0`, `us.deepseek.r1-v1:0`, `google.gemma-4-31b`, `google.gemma-4-26b-a4b`, `google.gemma-4-e2b` |
+| Embeddings | `amazon.titan-embed-text-v2:0`, `amazon.titan-embed-image-v1`, `cohere.embed-english-v3`, `cohere.embed-multilingual-v3` |
-// Amazon Nova via Converse
-const novaAdapter = bedrockText('us.amazon.nova-pro-v1:0', {
- region: 'us-east-1',
-})
+`adapter.inputModalities` lists the input kinds of a model in the catalog. If this package does not know the model id, `bedrockText` and `createBedrockText` throw. `createBedrockChat`, `createBedrockResponsesText`, and `createBedrockConverse` return `undefined` for that id. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts).
-// Meta Llama via Converse
-const llamaAdapter = bedrockText('us.meta.llama4-maverick-17b-instruct-v1:0', {
- region: 'us-east-1',
-})
-```
+## Advanced
+
+### Configuration
-### Explicit API key (Converse)
+`BedrockClientConfig` takes these options:
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `api` | `'converse' \| 'chat' \| 'responses'` | `'converse'` | The Bedrock API to use |
+| `region` | `string` | `'us-east-1'` | AWS region (for example `'us-west-2'`) |
+| `auth` | `'apikey' \| 'sigv4' \| 'auto'` | `'auto'` | Authentication mode |
+| `apiKey` | `string` | none | Explicit API key (wins over the environment variables) |
+| `baseURL` | `string` | none | Replaces the computed base URL |
+| `defaultHeaders` | `Record` | none | Headers sent with every request |
+| `endpoint` | `'runtime' \| 'mantle'` | `'runtime'` | The Bedrock endpoint (Chat Completions only) |
+
+About `endpoint`:
+
+- It applies only when `api: 'chat'`.
+- `runtime` (`bedrock-runtime`) hosts the broad open-weight catalog. `mantle` is the other choice.
+- On mantle, the adapter reads the path from the Bedrock API compatibility config: `/openai/v1` for Gemma 4, `/v1` for other models. An explicit `baseURL` still wins.
+- The Responses API always uses mantle.
+
+With an explicit API key:
```typescript
-import { createBedrockText } from '@tanstack/ai-bedrock'
+import { createBedrockText } from "@tanstack/ai-bedrock";
const adapter = createBedrockText(
- 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
- 'your-bedrock-api-key',
- { region: 'us-west-2' },
-)
+ "us.anthropic.claude-haiku-4-5-20251001-v1:0",
+ "your-bedrock-api-key",
+ { region: "us-west-2" },
+);
+```
+
+### Behind a proxy
+
+Send every request through a gateway, such as Cloudflare AI Gateway or a corporate proxy, with `baseURL` and `defaultHeaders`. These two option names are the same on every TanStack AI adapter, so one gateway config works for all of them.
+
+```typescript
+import { createBedrockText } from "@tanstack/ai-bedrock";
+
+const adapter = createBedrockText("us.amazon.nova-pro-v1:0", process.env.BEDROCK_API_KEY!, {
+ baseURL: "https://gateway.example.com/aws-bedrock",
+ defaultHeaders: { "cf-aig-authorization": `Bearer ${process.env.GATEWAY_TOKEN}` },
+});
```
+- All three Bedrock APIs accept these options. On the Converse API, the adapter adds the headers before SigV4 signing, so a signed request still includes them.
+- Need more than a fixed header, such as a log of each model call? The Chat Completions API (`api: 'chat'`) and the Responses API (`api: 'responses'`) support [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). The Converse API ignores it, because the AWS SDK does not send its requests through `fetch`.
+
### Prompt caching
`chat()` adds cache points for Claude models by default: one after the system prompt, and one at the end of the last user message. Pass `promptCache: 'long'` for the 1-hour cache, or `promptCache: 'none'` to turn it off. Other models get no automatic cache point. For the cost, see [Prompt Caching](../advanced/prompt-caching).
-To pick the places yourself, add a `cachePoint`. One cache point of your own turns the automatic ones off for that request. Later requests can read matching tokens at the reduced cache rate. Bedrock bills cache misses at the standard input rate.
+To pick the places yourself, add a `cachePoint`. One cache point of your own turns the automatic ones off for that request. Later requests can read matching tokens at the lower cache rate. Bedrock bills cache misses at the standard input rate.
-Explicit prompt caching is model-dependent. Use `cachePoint` only with a model that AWS lists as supporting it. The minimum checkpoint size and the TTL options also vary by model.
+Explicit prompt caching depends on the model. Use `cachePoint` only with a model that AWS lists as supporting it. The minimum checkpoint size and the TTL options also change by model.
-Set `metadata.cachePoint` on the item that ends the stable part of your prompt. The adapter places a `cachePoint` block right after it:
+Set `metadata.cachePoint` on the item that ends the stable part of your prompt. The adapter puts a `cachePoint` block right after it:
```typescript
-import { bedrockText } from '@tanstack/ai-bedrock'
-import { chat } from '@tanstack/ai'
+import { bedrockText } from "@tanstack/ai-bedrock";
+import { chat } from "@tanstack/ai";
const stream = chat({
- adapter: bedrockText('us.anthropic.claude-sonnet-4-5-20250929-v1:0', {
- region: 'us-east-1',
+ adapter: bedrockText("us.anthropic.claude-sonnet-4-5-20250929-v1:0", {
+ region: "us-east-1",
}),
systemPrompts: [
{
- content: 'Long, stable instructions...',
- metadata: { cachePoint: { type: 'default' } },
+ content: "Long, stable instructions...",
+ metadata: { cachePoint: { type: "default" } },
},
],
messages: [
// An older turn. Its cachePoint was removed when the new turn was added.
- { role: 'user', content: 'Summarize the report.' },
- { role: 'assistant', content: 'The report says...' },
+ { role: "user", content: "Summarize the report." },
+ { role: "assistant", content: "The report says..." },
{
- role: 'user',
+ role: "user",
content: [
{
- type: 'text',
- content: 'What changed since yesterday?',
+ type: "text",
+ content: "What changed since yesterday?",
// Only the newest message has a checkpoint.
// It caches the conversation up to here for the next round.
- metadata: { cachePoint: { type: 'default' } },
+ metadata: { cachePoint: { type: "default" } },
},
],
},
],
-})
+ threadId: "thread-1",
+});
```
Keep one message checkpoint, on the newest message. Before you send the next turn, remove `cachePoint` from the older message and add it to the new message. The metadata stays in the message history. If you do not remove it, the checkpoints add up, and the request can go over the Bedrock limit of four.
-Tools take the same metadata. Pass `metadata: { cachePoint: { type: 'default' } }` to `toolDefinition()` for the last tool in the list to cache the tool definitions.
+Tools take the same metadata. To cache the tool definitions, pass `metadata: { cachePoint: { type: 'default' } }` to `toolDefinition()` for the last tool in the list.
-- Bedrock accepts up to four checkpoints per request.
-- Add `ttl: '1h'` to keep an entry for one hour. The default is 5 minutes, and a read refreshes the timer. Bedrock processes tool checkpoints first, then system prompt checkpoints, and then message checkpoints. If a request mixes both TTLs, place every `1h` checkpoint before the first `5m` checkpoint in that combined order.
-- A checkpoint below the model's minimum size is ignored. The request still succeeds, and nothing is cached.
+- Bedrock accepts up to four checkpoints in one request.
+- Add `ttl: '1h'` to keep an entry for one hour. The default is 5 minutes, and a read starts the timer again.
+- Bedrock reads tool checkpoints first, then system prompt checkpoints, then message checkpoints. If a request mixes both TTLs, put every `1h` checkpoint before the first `5m` checkpoint in that order.
+- Bedrock ignores a checkpoint below the minimum size of the model. The request still succeeds, and nothing goes into the cache.
### Token usage
-`onUsage` and `RUN_FINISHED.usage` report Bedrock's counts as `promptTokens`, `completionTokens`, and `totalTokens`. `promptTokens` is the full input, cached tokens included. When a request hits or writes a prompt cache, the cache parts are also on `promptTokensDetails.cachedTokens` and `promptTokensDetails.cacheWriteTokens`. To read these counts, see [Token usage](../chat/stream-events#token-usage).
+`onUsage` and `RUN_FINISHED.usage` report the counts of Bedrock as `promptTokens`, `completionTokens`, and `totalTokens`.
-## Chat Completions API (`api: 'chat'`)
+- `promptTokens` is the full input, cached tokens included.
+- When a request reads or writes a prompt cache, the cache parts are also on `promptTokensDetails.cachedTokens` and `promptTokensDetails.cacheWriteTokens`.
-Set `api: 'chat'` to use Bedrock's OpenAI-compatible Chat Completions endpoint. Returns a `bedrock` adapter.
+To read these counts, see [Token usage](../chat/stream-events#token-usage).
-**Model scope:** Open-weight models only — gpt-oss, DeepSeek V3.x, Gemma, Qwen, Mistral open models, GLM, and similar. Claude, Nova, and Llama are NOT available on this endpoint. See the [AWS API compatibility matrix](https://docs.aws.amazon.com/bedrock/latest/userguide/models-api-compatibility.html) for the current list.
+### Chat Completions API
+
+Set `api: 'chat'` to use the OpenAI-compatible Chat Completions endpoint of Bedrock. It returns a `bedrock` adapter. Only open-weight models are on this endpoint.
```typescript ignore
-// ignore: see the Converse quick-start above — iterating a chat() stream and
-// reading chunk.type/chunk.delta needs @ag-ui/core base fields kiira can't
-// resolve transitively through @tanstack/ai source.
+// ignore: iterating a chat() stream and reading chunk.type/chunk.delta needs the
+// AG-UI base event fields, which come from @ag-ui/core. It's a transitive dep of
+// @tanstack/ai, so kiira (resolving @tanstack/ai from source under the dist->src
+// heuristic) can't follow it and those base fields drop off StreamChunk. The code
+// is correct (the same pattern is used throughout ai-client); see
+// getting-started/quick-start-server for the type-checked consumption shape.
import { bedrockText } from '@tanstack/ai-bedrock'
import { chat } from '@tanstack/ai'
@@ -256,21 +393,21 @@ const adapter = bedrockText('openai.gpt-oss-20b-1:0', {
for await (const chunk of chat({
adapter,
messages: [{ role: 'user', content: 'What is the capital of France?' }],
+ threadId: 'thread-1',
})) {
if (chunk.type === 'TEXT_MESSAGE_CONTENT') process.stdout.write(chunk.delta ?? '')
}
```
-## Responses API (`api: 'responses'`)
-
-Set `api: 'responses'` to use Bedrock's OpenAI-compatible Responses API. Returns a `bedrock-responses` adapter. This API is mantle-only.
+### Responses API
-**Model scope:** Currently gpt-oss and Gemma 4. The Responses API is stateful — pass `previous_response_id` and `store` through `modelOptions` to continue a conversation server-side.
+Set `api: 'responses'` to use the OpenAI-compatible Responses API of Bedrock. It returns a `bedrock-responses` adapter. This API is mantle only.
-**Reasoning:** Reasoning deltas stream as thinking content. Mantle-served models may still emit the pre-July-2025 event name `response.reasoning.delta` (removed from the OpenAI spec in favor of `response.reasoning_text.delta`); the adapter recognizes both and maps them identically.
+- The Responses API is stateful. To continue a conversation on the server, pass `previous_response_id` and `store` in `modelOptions`.
+- Reasoning deltas stream as thinking content. Models on mantle can still send the old event name `response.reasoning.delta`, which the OpenAI spec replaced with `response.reasoning_text.delta`. The adapter reads both names the same way.
```typescript ignore
-// ignore: see the Converse quick-start above — iterating a chat() stream and
+// ignore: see the Chat Completions sample above. Iterating a chat() stream and
// reading chunk.type/chunk.delta needs @ag-ui/core base fields kiira can't
// resolve transitively through @tanstack/ai source.
import { bedrockText } from '@tanstack/ai-bedrock'
@@ -284,154 +421,24 @@ const adapter = bedrockText('openai.gpt-oss-120b-1:0', {
for await (const chunk of chat({
adapter,
messages: [{ role: 'user', content: 'Summarize the Bedrock pricing page.' }],
+ threadId: 'thread-1',
})) {
if (chunk.type === 'TEXT_MESSAGE_CONTENT') process.stdout.write(chunk.delta ?? '')
}
```
-## Reasoning
-
-Set `reasoning` on `chat()`. What goes on the wire depends on the API:
-
-- Converse (the default): only Claude gets thinking. A budget model gets `thinking` with a budget in `additionalModelRequestFields`, and `maxTokens` grows past the budget. `off` sends nothing.
-- Chat Completions (`api: 'chat'`): `reasoning_effort`.
-- Responses (`api: 'responses'`): `reasoning.effort`.
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { bedrockText } from "@tanstack/ai-bedrock";
-
-const stream = chat({
- adapter: bedrockText("us.anthropic.claude-haiku-4-5-20251001-v1:0", {
- region: "us-east-1",
- }),
- messages: [{ role: "user", content: "Check this proof." }],
- reasoning: { level: "high", budgetTokens: 8000 },
-});
-```
-
-The levels of each model:
-
-- `us.anthropic.claude-haiku-4-5-*`, `us.anthropic.claude-sonnet-4-5-*`: `off`, `high`, with `budgetTokens`.
-- `openai.gpt-oss-120b-1:0`, `openai.gpt-oss-20b-1:0`: `low`, `medium`, `high`.
-- The `google.gemma-4` models: `off`, `high`.
-
-The other models take no `reasoning`. `us.deepseek.r1-v1:0` always thinks, and has no levels.
-
-On Converse, structured output forces a tool, and Claude cannot think while a tool is forced. So the structured output call sends no thinking.
-
-## Embeddings
-
-Generate embedding vectors with Titan or Cohere embedding models via `InvokeModel`:
-
-```typescript
-import { embed } from "@tanstack/ai";
-import { bedrockEmbedding } from "@tanstack/ai-bedrock";
-
-const result = await embed({
- adapter: bedrockEmbedding("amazon.titan-embed-text-v2:0"),
- input: ["a red guitar", "a blue drum kit"],
- dimensions: 512, // 256 | 512 | 1024
-});
-
-console.log(result.embeddings[0]?.vector);
-```
-
-Titan Multimodal embeds text and images — alone or fused into a single vector. Fuse parts by nesting them in an array (the same `Array` shape a chat message's `content` uses); the outer array is the item list, so this embeds one fused item:
-
-```typescript
-import { embed } from "@tanstack/ai";
-import { bedrockEmbedding } from "@tanstack/ai-bedrock";
-
-const productPhoto = "iVBORw0KGgo..."; // base64 image data
-
-const result = await embed({
- adapter: bedrockEmbedding("amazon.titan-embed-image-v1"),
- input: [
- [
- { type: "text", content: "a red guitar" },
- {
- type: "image",
- source: {
- type: "data",
- value: productPhoto,
- mimeType: "image/png",
- },
- },
- ],
- ],
- dimensions: 1024, // 256 | 384 | 1024
-});
-```
-
-Cohere Embed v3 on Bedrock is also supported (text-only, batched, requires `inputType`):
-
-```typescript
-import { embed } from "@tanstack/ai";
-import { bedrockEmbedding } from "@tanstack/ai-bedrock";
-
-const result = await embed({
- adapter: bedrockEmbedding("cohere.embed-english-v3"),
- input: ["a red guitar", "a blue drum kit"],
- modelOptions: { inputType: "search_document" },
-});
-```
-
-> Titan models have no batch API — a batch of N items runs as N `InvokeModel`
-> calls under a small concurrency cap. Titan Multimodal does not fetch remote
-> image URLs; pass base64 data (or a `data:` URI).
-
-See the [Embeddings guide](../embeddings.md) for the full API.
-
-## Model Availability
-
-The adapter ships with a hand-seeded snapshot catalog (`src/model-catalog.generated.ts`) of confirmed model IDs. This catalog can be refreshed by the maintainer script `scripts/fetch-bedrock-models.ts`, which calls `ListFoundationModels` with AWS credentials.
-
-**Actual model availability depends on your AWS account's model access configuration and the region you are targeting.** Enable model access in the [Amazon Bedrock console](https://console.aws.amazon.com/bedrock/home#/modelaccess) before use.
-
-For the full list of models and which API endpoints they support, see the [AWS API compatibility matrix](https://docs.aws.amazon.com/bedrock/latest/userguide/models-api-compatibility.html).
-
-## Supported Capabilities
-
-- Streaming chat completions
-- Client-side tool calling
-- Reasoning output (extended thinking surfaced when the model emits it; request-side enablement on Converse is not yet wired)
-- Multimodal input (text, images, documents — model-dependent)
-- JSON schema / structured output
-
-## API Reference
-
-### `bedrockText(model, config?)`
-
-Creates a Bedrock adapter using environment-variable auth.
-
-`adapter.inputModalities` lists the input kinds of a model in the catalog. If this package does not know the model id, `bedrockText` and `createBedrockText` throw. `createBedrockChat`, `createBedrockResponsesText`, and `createBedrockConverse` return `undefined` for that id. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts).
-
-- `model` — Model ID (e.g. `'us.anthropic.claude-haiku-4-5-20251001-v1:0'`)
-- `config.api` — `'converse'` (default), `'chat'`, or `'responses'`
-- `config.region` — AWS region string (default `'us-east-1'`)
-- `config.auth` — `'auto'` (default), `'apikey'`, or `'sigv4'`
-- `config.apiKey` — Explicit API key (overrides env vars)
-- `config.baseURL` — Override base URL
-- `config.endpoint` — `'runtime'` (default) or `'mantle'` (Chat Completions path only)
-
-Returns a chat adapter for use with `chat()` or `generate()`.
-
-| `api` value | Adapter name | Underlying SDK |
-|---|---|---|
-| `'converse'` (default) | `bedrock-converse` | `@aws-sdk/client-bedrock-runtime` |
-| `'chat'` | `bedrock` | `openai` (OpenAI-compatible) |
-| `'responses'` | `bedrock-responses` | `openai` (OpenAI-compatible) |
+### API reference
-### `createBedrockText(model, apiKey, config?)`
+| Factory | Creates |
+| --- | --- |
+| `bedrockText(model, config?)` | Chat adapter with auth from the environment. Returns the adapter of the `api` that you pick |
+| `createBedrockText(model, apiKey, config?)` | Chat adapter with an explicit API key. Skips the environment lookup |
+| `bedrockEmbedding(model, config?)` / `createBedrockEmbedding(model, apiKey, config?)` | Embedding adapter for Titan and Cohere models |
-Creates a Bedrock adapter with an explicit API key, bypassing the environment-variable lookup.
+`config` takes `api`, `region`, `auth`, `apiKey`, `baseURL`, `defaultHeaders`, and `endpoint`. See [Configuration](#configuration).
-## Next Steps
+More from AWS:
-- [Amazon Bedrock API keys](https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html) — Create and manage API keys
-- [Amazon Bedrock model access](https://docs.aws.amazon.com/bedrock/latest/userguide/model-access.html) — Enable models in your account
-- [AWS API compatibility matrix](https://docs.aws.amazon.com/bedrock/latest/userguide/models-api-compatibility.html) — Which models work with which APIs
-- [Converse API reference](https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_Converse.html) — Native Converse API docs
-- [Streaming Guide](../chat/streaming) — Learn about streaming responses
-- [Tools Guide](../tools/tools) — Learn about tool calling
+- [Amazon Bedrock API keys](https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html): create and manage API keys.
+- [Amazon Bedrock model access](https://docs.aws.amazon.com/bedrock/latest/userguide/model-access.html): enable models in your account.
+- [Converse API reference](https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_Converse.html): the native Converse API docs.
diff --git a/docs/adapters/byteplus-advanced.md b/docs/adapters/byteplus-advanced.md
new file mode 100644
index 0000000000..3eee7bc5f7
--- /dev/null
+++ b/docs/adapters/byteplus-advanced.md
@@ -0,0 +1,421 @@
+---
+title: "BytePlus: Advanced"
+id: byteplus-advanced
+order: 8
+description: "Advanced BytePlus setup in TanStack AI: regions, reasoning and encrypted_content, structured output, Seedance options per model, Seedream reference images, Seed Speech details, and the API reference."
+keywords:
+ - tanstack ai
+ - byteplus
+ - modelark
+ - seedance
+ - seedream
+ - seed speech
+ - encrypted_content
+ - adapter
+---
+
+This page has the details of the BytePlus adapter. To get a first chat working, start at [BytePlus](./byteplus).
+
+## Regions
+
+Each Ark key works in one region only. A key from one region does not authenticate in another region. The default base URL is the Asia-Pacific south-east endpoint, `https://ark.ap-southeast.bytepluses.com/api/v3`. To use another region, set `baseURL`:
+
+```typescript
+import { createBytePlusText } from "@tanstack/ai-byteplus";
+
+const adapter = createBytePlusText("dola-seed-2-1-turbo-260628", process.env.ARK_API_KEY!, {
+ baseURL: "https://ark.eu-west.bytepluses.com/api/v3",
+});
+```
+
+The BytePlus docs say that the EU endpoint has chat and images only. Seedance video is in Asia-Pacific only.
+
+### BYOK
+
+The two products use two keys, so [BYOK](../advanced/byok) has two providers. Import both from `@tanstack/ai-byteplus/byok`:
+
+- `byteplusByok`: the Ark key, for chat, video, and images.
+- `byteplusVoiceByok`: the Seed Speech key, for text-to-speech and transcription.
+
+## Reasoning and `encrypted_content`
+
+Seed models think by default. The thinking text streams as `reasoning_content` deltas. The adapter sends it as reasoning, so `useChat` shows it apart from the answer.
+
+What `reasoning` sends to Ark:
+
+- `off` sends `thinking: { type: "disabled" }` and no effort.
+- The other levels send `thinking: { type: "enabled" }` and `reasoning_effort`.
+- `deepseek-v3-2-251201` is the one model that does not think by default.
+
+Four models also send an `encrypted_content` value with their reasoning: `dola-seed-2-1-turbo-260628`, `seed-2-0-lite-260428`, `seed-2-0-mini-260428`, and `seed-2-0-pro-260328`. This value is a signature of the reasoning. BytePlus asks you to send it back, with no change, on the assistant message of the next turn.
+
+The adapter does this for you. It reads the value from the stream and stores it as the `signature` of the thinking part. On the next request, it sends the value back. Anthropic thinking signatures work the same way.
+
+- If you store and replay the history yourself, keep the `signature` of the thinking parts. Without it, you lose the reasoning cache hit. The request still works.
+- A structured-output turn does not keep the value. Again, you lose the cache hit, and the request still works.
+
+## Structured output
+
+Ten of the 18 chat models accept `response_format: { type: "json_schema" }`:
+
+- `dola-seed-2-1-turbo-260628`
+- `seed-2-0-pro-260328`, `seed-2-0-lite-260228`, `seed-2-0-mini-260215`
+- `seed-1-8-251228`, `seed-1-6-250915`, `seed-1-6-250615`
+- `seed-1-6-flash-250715`, `seed-1-6-flash-250615`
+- `glm-5-2-260617`
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { byteplusText } from "@tanstack/ai-byteplus";
+import { z } from "zod";
+
+const RecipeSchema = z.object({
+ name: z.string().meta({ description: "Name of the dish" }),
+ minutes: z.number().meta({ description: "Total cooking time in minutes" }),
+ ingredients: z.array(z.string()),
+});
+
+const recipe = await chat({
+ adapter: byteplusText("dola-seed-2-1-turbo-260628"),
+ messages: [{ role: "user", content: "Give me a recipe for carbonara" }],
+ threadId: "thread-1",
+ outputSchema: RecipeSchema,
+});
+
+console.log(recipe.name, recipe.minutes);
+```
+
+On the other eight models, the adapter stops with an error:
+
+- `chat({ outputSchema })` throws.
+- The streaming form sends a `RUN_ERROR` that names the models that work.
+
+There is no JSON-mode fallback. Ark also rejects `response_format: { type: "json_object" }` on these eight models.
+
+Two results of live tests that the BytePlus tables do not show:
+
+- `seed-2-0-lite-260428` rejects a JSON schema. For typed output, use `seed-2-0-lite-260228` or `dola-seed-2-1-turbo-260628`.
+- `glm-4-7-251222` accepts a schema and then ignores it. It answers in prose with a `200`. The adapter does not allow a schema on this model, so you do not get output that you cannot parse.
+
+To build a model picker, use the exported `BYTEPLUS_STRUCTURED_OUTPUT_CHAT_MODELS`.
+
+## Seedance video
+
+### Run a Seedance job from the client
+
+Let the server poll the job. Set `stream: true` and send the events to the browser:
+
+```typescript
+import {
+ generateVideo,
+ generationParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
+import { byteplusVideo } from "@tanstack/ai-byteplus";
+
+export async function POST(request: Request) {
+ const { input, threadId } = await generationParamsFromRequest("video", request);
+ if (typeof input.prompt !== "string") {
+ return new Response("Send a text prompt.", { status: 400 });
+ }
+
+ const stream = generateVideo({
+ adapter: byteplusVideo("dreamina-seedance-2-5-260628"),
+ prompt: input.prompt,
+ threadId,
+ size: "16:9_720p",
+ duration: 5,
+ stream: true,
+ pollingInterval: 5000,
+ });
+
+ return toServerSentEventsResponse(stream);
+}
+```
+
+On the client, `useGenerateVideo` tracks the job:
+
+```tsx
+import { fetchServerSentEvents, useGenerateVideo } from "@tanstack/ai-react";
+
+export function SeedanceGenerator() {
+ const { generate, result, videoStatus, isLoading, error } = useGenerateVideo({
+ connection: fetchServerSentEvents("/api/generate/video"),
+ threadId: "video-studio",
+ });
+
+ return (
+
+
+
+ {isLoading &&
Status: {videoStatus?.status ?? "starting"}
}
+ {error &&
Error: {error.message}
}
+ {result && }
+
+ );
+}
+```
+
+The job record stays for seven days. The video URL expires 24 hours after the job completes.
+
+### Seedance options per model
+
+Seedance options are different for each model. Ark rejects an option that does not apply with a `400`, for example: "the specified parameter `draft` is not supported for model seedance-1-0-pro in t2v, must be empty".
+
+| Option | Models that accept it |
+| --- | --- |
+| `service_tier` (`"default"` or `"flex"`) | Seedance 1.x only |
+| `camera_fixed` | Seedance 1.x only |
+| `frames` (`25 + 4n`, from `29` to `289`) | `seedance-1-0-pro-250528`, `seedance-1-0-pro-fast-251015` |
+| `draft` (a cheap preview) | `seedance-1-5-pro-251215` only |
+| `priority` (`0` to `9`) | Seedance 2.5 and the `dreamina-seedance-2-0-*` models |
+| `duration: -1` (the model picks the length) | Seedance 2.5, Seedance 2.0, `seedance-1-5-pro-251215` |
+| `output_format` (`"mp4"` or `"mov"`) | Seedance 2.5 (default `mp4`) |
+| `seed`, `watermark`, `generate_audio`, `return_last_frame`, `callback_url` | All models |
+
+- `watermark` is `false` by default for video. For Seedream images, it is `true`.
+- All models accept `generate_audio`. Only Seedance 2.5, Seedance 2.0, and `seedance-1-5-pro-251215` add an audio track.
+
+Resolutions also change by model:
+
+- No Seedance model has a 2K tier.
+- Only `dreamina-seedance-2-0-260128` has `4k`.
+- Seedance 2.5 accepts `480p`, `720p`, and `1080p`.
+- `seedance-1-0-pro-fast-251015` accepts `1080p`.
+
+The adapter finds a combination that a model does not support before it sends the request, and throws a clear error.
+
+Reference media uses the shared [role hints](../media/video-generation#role-hints): `start_frame`, `end_frame`, and `reference`.
+
+- Seedance 2.5 and Seedance 2.0 take reference images, video, and audio.
+- Seedance 2.5 also accepts audio-only reference input. It takes up to 30 images, 10 videos, and 10 audio clips. Seedance 2.0 takes 9, 3, and 3.
+- The 1.x models take start and end frames only. `seedance-1-0-pro-fast-251015` takes a start frame only.
+
+### Seedance 2.5
+
+`dreamina-seedance-2-5-260628` is the newest Seedance model. Use it for long clips (up to 30 seconds), audio-only reference input, and `output_format: "mov"`.
+
+```typescript
+import { generateVideo } from "@tanstack/ai";
+import { byteplusVideo } from "@tanstack/ai-byteplus";
+
+const { jobId } = await generateVideo({
+ adapter: byteplusVideo("dreamina-seedance-2-5-260628"),
+ prompt: "a guitar being played in a store",
+ threadId: "thread-1",
+ size: "16:9_1080p",
+ duration: 10,
+ modelOptions: {
+ generate_audio: true,
+ priority: 5,
+ output_format: "mp4",
+ },
+});
+
+console.log(jobId);
+```
+
+| Capability | Seedance 2.5 |
+| --- | --- |
+| Duration | 4 to 30 seconds, or `-1` (the model picks; required for video edit tasks) |
+| Resolution | `480p`, `720p`, `1080p` (default `720p`). No 4k. |
+| Reference media | 1 to 30 images, 0 to 10 videos, 0 to 10 audio clips. Audio only is allowed. |
+| First and last frame | Yes |
+| `priority`, `generate_audio` | Yes |
+| `service_tier`, `camera_fixed`, `frames`, `draft` | No |
+
+The account can need to activate the model, or buy a resource pack, in the Ark Console. Until then, a call fails with `404 ModelNotOpen`.
+
+`byteplusVideo()` accepts any string, so you can use an id that BytePlus releases before this package lists it. For an unknown id, the adapter skips its local checks and lets Ark validate the request. Known ids keep their checks.
+
+### Seedance through BytePlus or fal
+
+You can also reach Seedance through [`@tanstack/ai-fal`](./fal). This adapter talks to BytePlus directly, so you get:
+
+- BytePlus billing and rate limits.
+- The model ids of BytePlus.
+- All the Seedance fields above (`camera_fixed`, `draft`, `priority`, reference media roles). fal has a smaller, shared set.
+
+Use the account that you already have. You do not need both packages for Seedance.
+
+## Seedream reference images
+
+To edit an image, or to guide the result with images, put image parts in the `prompt` array. Seedream takes up to 14 reference images, and `dola-seedream-5-0-pro-260628` takes up to 10:
+
+```typescript
+import { generateImage } from "@tanstack/ai";
+import { byteplusImage } from "@tanstack/ai-byteplus";
+
+const result = await generateImage({
+ adapter: byteplusImage("seedream-5-0-260128"),
+ prompt: [
+ { type: "text", content: "Put this guitar on a concert stage" },
+ {
+ type: "image",
+ source: { type: "url", value: "https://example.com/guitar.png" },
+ },
+ ],
+ threadId: "thread-1",
+});
+
+console.log(result.images.length);
+```
+
+Two Seedream behaviors to know:
+
+- The adapter never sets `watermark` for you, so the BytePlus default (`true`) applies.
+- Seedream has no `n` option. When you ask for more than one image, the adapter uses the group-image mode (`sequential_image_generation: "auto"` with `max_images`). The model then decides how many images the prompt needs. A request for four can return two. The adapter logs a warning when this occurs.
+
+## Seed Speech
+
+### Voice ids
+
+Seed Speech has no top-level `speaker` field. The voice goes inside a `references` entry. The adapter turns `voice` into `references: [{ speaker: "en_female_stokie_uranus_bigtts" }]` for you.
+
+`modelOptions.references` replaces that entry. It does not merge with it. So if you send `references` for voice cloning, the adapter drops `voice`. To keep a stock voice with your clips, add a `speaker` entry yourself.
+
+The end of a voice id tells you its generation:
+
+- `_uranus_bigtts`: TTS 2.0 voices, the newest generation.
+- `_mars_bigtts` and `_moon_bigtts`: TTS 1.0 voices.
+- `*_emo_v2_*`: TTS 1.0 voices that also accept emotion tags.
+
+The full list is in the [BytePlus voice list](https://docs.byteplus.com/en/docs/byteplusvoice/voicelist). It changes more often than this package, so the adapter accepts any string. `speaker` also takes a voice that you cloned with Voice Replication.
+
+### Speech options
+
+Use `modelOptions` for these:
+
+- `ogg_opus` output and an exact `sample_rate`.
+- `references`: voice cloning, with up to three 30-second audio clips. Refer to them from the text as `@Audio1` to `@Audio3`.
+- `watermark`, `speech_rate`, and the raw `enable_subtitle` timings.
+
+```typescript
+import { generateSpeech } from "@tanstack/ai";
+import { byteplusSpeech, type BytePlusTTSResult } from "@tanstack/ai-byteplus";
+
+const result: BytePlusTTSResult = await generateSpeech({
+ adapter: byteplusSpeech("seed-audio-1.0"),
+ text: "welcome to the guitar store",
+ threadId: "thread-1",
+ modelOptions: {
+ format: "ogg_opus",
+ sample_rate: 48000,
+ speech_rate: 20,
+ enable_subtitle: true,
+ },
+});
+
+for (const sentence of result.subtitle?.sentences ?? []) {
+ console.log(sentence.text, sentence.start_time);
+}
+```
+
+### Timings
+
+The shared `timestamps: true` option is easier than `modelOptions.enable_subtitle`. It sets the same flag. Then it maps the timings to `result.alignment` (per word) and `result.segments` (per sentence), in seconds:
+
+```typescript
+import { generateSpeech } from "@tanstack/ai";
+import { byteplusSpeech } from "@tanstack/ai-byteplus";
+
+const result = await generateSpeech({
+ adapter: byteplusSpeech("seed-audio-1.0"),
+ text: "welcome to the guitar store",
+ threadId: "thread-1",
+ timestamps: true,
+});
+
+console.log(result.alignment?.unit); // "word"
+console.log(result.alignment?.endSeconds.at(-1)); // where speech stops
+```
+
+### Dialogue
+
+Seed Audio 1.0 makes a dialogue with several voices in one request. Pass `turns`, and the adapter builds the `references` array and the role prompt for you. A request can have up to three different voices:
+
+```typescript
+import { generateSpeech } from "@tanstack/ai";
+import { byteplusSpeech } from "@tanstack/ai-byteplus";
+
+// Replace SECOND_VOICE with another voice id from the BytePlus voice list.
+const SECOND_VOICE = "your-second-voice-id";
+
+const result = await generateSpeech({
+ adapter: byteplusSpeech("seed-audio-1.0"),
+ turns: [
+ { text: "Do you sell picks?", voice: "en_female_stokie_uranus_bigtts" },
+ { text: "By the till.", voice: SECOND_VOICE },
+ ],
+ timestamps: true,
+ threadId: "thread-1",
+});
+
+for (const segment of result.segments ?? []) {
+ console.log(segment.startSeconds, segment.text);
+}
+```
+
+### Watermarks
+
+For speech, `watermark` is an object. It has two markers, and both are off by default:
+
+- `aigc_watermark`: adds a sound marker at the end of the clip.
+- `aigc_metadata`: writes the source of the audio into the audio header. It writes nothing unless `enable` is `true`.
+
+```typescript
+import { generateSpeech } from "@tanstack/ai";
+import { byteplusSpeech } from "@tanstack/ai-byteplus";
+
+const result = await generateSpeech({
+ adapter: byteplusSpeech("seed-audio-1.0"),
+ text: "welcome to the guitar store",
+ threadId: "thread-1",
+ modelOptions: {
+ watermark: {
+ aigc_watermark: true,
+ aigc_metadata: { enable: true, content_producer: "guitar-store" },
+ },
+ },
+});
+
+console.log(result.format);
+```
+
+`watermark: true` is short for `{ aigc_watermark: true }`. Images and video use a boolean `watermark`. Only speech takes the object.
+
+### Limits
+
+- One request makes 120 seconds of audio at most. The limit applies to `originalDuration`, the length before `speech_rate`. BytePlus also bills on this length.
+- The raw `subtitle` timings are in milliseconds. `duration` and `originalDuration` are in seconds. `alignment` and `segments` are always in seconds.
+- The `url` on the result expires about two hours after generation. Keep the base64 `audio`, not the link.
+
+## Transcription options
+
+Set these in `modelOptions` of `byteplusTranscription`:
+
+- `enable_itn`: writes spoken numbers and dates as digits.
+- `enable_punc`: adds punctuation.
+- `enable_ddc`: removes filler words.
+- `enable_speaker_info`: adds speaker labels, which come back as `segment.speaker`.
+
+## API reference
+
+Each factory pair has the same shape. The short factory reads the key from the environment. The `create*` factory takes an explicit API key. Both take `model` as the first argument.
+
+| Factories | Creates | Key |
+| --- | --- | --- |
+| `byteplusText(model, config?)` / `createBytePlusText(model, apiKey, config?)` | Chat adapter | `ARK_API_KEY` or `BYTEPLUS_API_KEY` |
+| `byteplusVideo(model, config?)` / `createBytePlusVideo(model, apiKey, config?)` | Seedance video adapter (experimental) | `ARK_API_KEY` |
+| `byteplusImage(model, config?)` / `createBytePlusImage(model, apiKey, config?)` | Seedream image adapter | `ARK_API_KEY` |
+| `byteplusSpeech(model, config?)` / `createBytePlusSpeech(model, apiKey, config?)` | Seed Speech text-to-speech adapter | `BYTEPLUS_VOICE_API_KEY` |
+| `byteplusTranscription(model, config?)` / `createBytePlusTranscription(model, apiKey, config?)` | Seed Speech transcription adapter | `BYTEPLUS_VOICE_API_KEY` |
+
+- `config.baseURL` sets the region endpoint of the Ark adapters.
+- `adapter.inputModalities` lists the input kinds of the selected chat model. It is `undefined` for a model that this package does not know.
+- BytePlus has no provider tools. Define your tools with `toolDefinition()`. Ark uses the OpenAI tool-call format, so the usual [tools flow](../tools/tools.md) works.
diff --git a/docs/adapters/byteplus.md b/docs/adapters/byteplus.md
index 7d00c036db..fbbf712ae8 100644
--- a/docs/adapters/byteplus.md
+++ b/docs/adapters/byteplus.md
@@ -2,7 +2,7 @@
title: BytePlus
id: byteplus-adapter
order: 8
-description: "Use BytePlus ModelArk with TanStack AI — Seed chat models with reasoning, Seedance video generation, Seedream image generation, and Seed Speech text-to-speech and transcription via @tanstack/ai-byteplus."
+description: "Use BytePlus ModelArk in TanStack AI with @tanstack/ai-byteplus: Seed chat models with reasoning, Seedance video, Seedream images, and Seed Speech text-to-speech and transcription."
keywords:
- tanstack ai
- byteplus
@@ -20,14 +20,9 @@ keywords:
- adapter
---
-The BytePlus adapter connects TanStack AI to [BytePlus](https://www.byteplus.com/), ByteDance's international model platform, across four generation kinds:
+`@tanstack/ai-byteplus` calls Seed chat models with reasoning, plus Seedance video, Seedream images, and Seed Speech.
-- **Chat** — the Seed model family (plus GLM, DeepSeek and gpt-oss) on ModelArk's OpenAI-compatible `/chat/completions` endpoint, with reasoning on by default.
-- **Video** — Seedance, an asynchronous task API.
-- **Image** — Seedream.
-- **Speech** — Seed Speech text-to-speech and transcription, which live on a **different host with a different API key** (see [Two products, two keys](#two-products-two-keys)).
-
-## Installation
+## 1. Install
@@ -42,591 +37,281 @@ octane: @tanstack/ai-byteplus
-## Two products, two keys
-
-BytePlus splits these models across two products, and they do not share credentials:
-
-| Adapters | Product | Env var | Auth header |
-| --- | --- | --- | --- |
-| `byteplusText`, `byteplusVideo`, `byteplusImage` | ModelArk (Ark) | `ARK_API_KEY` (falls back to `BYTEPLUS_API_KEY`) | `Authorization: Bearer` |
-| `byteplusSpeech`, `byteplusTranscription` | Seed Speech | `BYTEPLUS_VOICE_API_KEY` | `X-Api-Key` |
+## 2. Set the API keys
-BYOK uses two slugs because these are two keys: `byteplusByok` (Ark) and `byteplusVoiceByok` (Seed Speech), both from `@tanstack/ai-byteplus/byok`.
+BytePlus sells these models as two products, and each product has its own key. Put both keys in the environment of your server:
```bash
-# ModelArk: chat, Seedance video, Seedream image
+# ModelArk: chat, Seedance video, Seedream images
ARK_API_KEY=...
-# Seed Speech: TTS and transcription — a separate product key
+# Seed Speech: text-to-speech and transcription
BYTEPLUS_VOICE_API_KEY=...
```
-Passing an Ark key to the speech adapters fails with `45000010 Invalid X-Api-Key`.
+- `byteplusText`, `byteplusVideo`, and `byteplusImage` read `ARK_API_KEY`. If it is not set, they read `BYTEPLUS_API_KEY`.
+- `byteplusSpeech` and `byteplusTranscription` read `BYTEPLUS_VOICE_API_KEY`. An Ark key fails here with `45000010 Invalid X-Api-Key`.
-Ark keys are also **region-isolated**: the default base URL is the Asia-Pacific south-east endpoint (`https://ark.ap-southeast.bytepluses.com/api/v3`), and a key issued for one region will not authenticate against another. Point the adapter elsewhere with `baseURL`:
+To pass the key in code, use the `create*` factory of the same adapter:
```typescript
-import { createBytePlusText } from '@tanstack/ai-byteplus'
-import { arkApiKey } from './config'
+import { createBytePlusText } from "@tanstack/ai-byteplus";
-const adapter = createBytePlusText('dola-seed-2-1-turbo-260628', arkApiKey, {
- baseURL: 'https://ark.eu-west.bytepluses.com/api/v3',
-})
+const adapter = createBytePlusText("dola-seed-2-1-turbo-260628", process.env.ARK_API_KEY!);
```
-Per the BytePlus docs the EU endpoint serves chat and image only — Seedance video is Asia-Pacific only.
-
-## Chat
-
-The adapter carries the model, so there is no separate `model` option.
+Ark keys work in one region only. The default endpoint is Asia-Pacific south-east. For a key from another region, see [Regions](./byteplus-advanced#regions).
-**Server** — an endpoint that streams the reply back over SSE:
+## 3. Stream a chat from the server
```typescript
-import { chat, toServerSentEventsResponse } from '@tanstack/ai'
-import { byteplusText } from '@tanstack/ai-byteplus'
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
+import { byteplusText } from "@tanstack/ai-byteplus";
export async function POST(request: Request) {
- const { messages } = await request.json()
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
const stream = chat({
- adapter: byteplusText('dola-seed-2-1-turbo-260628'),
+ adapter: byteplusText("dola-seed-2-1-turbo-260628"),
messages,
- })
+ threadId,
+ runId,
+ });
- return toServerSentEventsResponse(stream)
+ return toServerSentEventsResponse(stream);
}
```
-**Client** — the same `useChat` hook as every other provider:
+## 4. Render the chat
+
+The client is the same for every provider. Point `useChat` at your route:
```tsx
-import { useState } from 'react'
-import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
+import { useState } from "react";
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
export function Chat() {
- const [input, setInput] = useState('')
-
- const { messages, sendMessage, isLoading } = useChat({
- connection: fetchServerSentEvents('/api/chat'),
- })
+ const [input, setInput] = useState("");
+ const { messages, sendMessage } = useChat({
+ connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
+ });
return (
-
+
-
- )
+ setInput(event.target.value)} />
+
+ );
}
```
-### Model options
-
-Ark's chat endpoint is OpenAI-compatible, so sampling parameters keep their OpenAI snake_case names and live in `modelOptions`. `repetition_penalty` and `service_tier` are the Ark-only additions. The thinking level goes in `reasoning`:
-
-```typescript
-import { chat, toServerSentEventsResponse } from '@tanstack/ai'
-import { byteplusText } from '@tanstack/ai-byteplus'
-
-export async function POST(request: Request) {
- const { messages } = await request.json()
-
- const stream = chat({
- adapter: byteplusText('dola-seed-2-1-turbo-260628'),
- messages,
- modelOptions: {
- temperature: 0.7,
- top_p: 0.9,
- max_tokens: 2048,
- },
- reasoning: 'medium',
- })
-
- return toServerSentEventsResponse(stream)
-}
-```
+For Vue, Solid, Svelte, Angular, and the other frameworks, see [Quick Start](../getting-started/quick-start#3-render-the-chat).
-`max_tokens` and `max_completion_tokens` are mutually exclusive. The types cannot express this, and Ark returns a `400` when you send both.
-
-`service_tier: 'flex'` routes the request to the cheaper offline batch queue with no latency guarantee.
-
-Need a header on each request, or a log of each model call? The chat adapter supports [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call).
-
-## Reasoning and `encrypted_content`
-
-Seed models reason by default. Reasoning arrives as its own stream of `reasoning_content` deltas and is surfaced as reasoning content rather than answer text, so `useChat` renders it separately from the reply. Turn it off per request, on a model that can stop thinking:
-
-```typescript
-import { chat, toServerSentEventsResponse } from '@tanstack/ai'
-import { byteplusText } from '@tanstack/ai-byteplus'
-
-export async function POST(request: Request) {
- const { messages } = await request.json()
-
- const stream = chat({
- adapter: byteplusText('glm-5-2-260617'),
- messages,
- reasoning: 'off',
- })
-
- return toServerSentEventsResponse(stream)
-}
-```
-
-`off` sends `thinking: { type: 'disabled' }` and no effort. The other levels send `thinking: { type: 'enabled' }` and `reasoning_effort`. `deepseek-v3-2-251201` is the one model that does not think by default.
-
-The levels of each model:
-
-- `dola-seed-2-1-turbo-260628` and the `seed-*` models: `minimal` to `high`. They cannot stop thinking.
-- `glm-5-2-260617`: `off` to `high`. `glm-4-7-251222`, `deepseek-v3-2-251201`: `off`, `high`.
-- `deepseek-v4-pro-260425`, `deepseek-v4-flash-260425`: `off`, `high`, `max`.
-- `gpt-oss-120b-250805`: `low`, `medium`, `high`.
-
-The four "thinking summary" models — `dola-seed-2-1-turbo-260628`, `seed-2-0-lite-260428`, `seed-2-0-mini-260428` and `seed-2-0-pro-260328` — also emit an opaque `encrypted_content` blob alongside the reasoning trace. It is a signature over that trace, and BytePlus's docs ask for it back verbatim on the assistant message in the next turn.
-
-**The adapter round-trips it for you**, over the same seam Anthropic's thinking signatures use: the blob is captured off the stream and attached to the reasoning step as its `signature`, which lands on the thinking part of the assistant message and is echoed back on the next request. Two consequences worth knowing:
-
-- If you persist and replay conversation history yourself, keep the thinking parts' `signature` — dropping it costs you the reasoning-cache hit. It is not fatal: a live probe confirmed Ark accepts a turn whose assistant message omits `encrypted_content`.
-- A structured-output turn doesn't capture the blob, for the same reason — again a lost cache hit, not a failed request.
-
-## Structured output
-
-Ten of the eighteen chat models accept `response_format: { type: 'json_schema' }`:
-
-`dola-seed-2-1-turbo-260628`, `seed-2-0-pro-260328`, `seed-2-0-lite-260228`, `seed-2-0-mini-260215`, `seed-1-8-251228`, `seed-1-6-250915`, `seed-1-6-250615`, `seed-1-6-flash-250715`, `seed-1-6-flash-250615`, `glm-5-2-260617`.
-
-```typescript
-import { chat } from '@tanstack/ai'
-import { byteplusText } from '@tanstack/ai-byteplus'
-import { z } from 'zod'
-
-const RecipeSchema = z.object({
- name: z.string().meta({ description: 'Name of the dish' }),
- minutes: z.number().meta({ description: 'Total cooking time in minutes' }),
- ingredients: z.array(z.string()),
-})
-
-const recipe = await chat({
- adapter: byteplusText('dola-seed-2-1-turbo-260628'),
- messages: [{ role: 'user', content: 'Give me a recipe for carbonara' }],
- outputSchema: RecipeSchema,
-})
-
-console.log(recipe.name, recipe.minutes)
-```
-
-On the other eight models the adapter **fails loudly** rather than degrading: `chat({ outputSchema })` throws, and the streaming form emits a `RUN_ERROR` naming the models that do work. There is no JSON-mode fallback to fall back *to* — Ark rejects `response_format: { type: 'json_object' }` on every one of them too.
-
-Two traps worth knowing, both found by probing the live API rather than reading the tables:
-
-- **The published capability tables are wrong in both directions.** `seed-2-0-lite-260428` — the obvious default model — is one of the eight that reject a JSON schema. Reach for `seed-2-0-lite-260228` or `dola-seed-2-1-turbo-260628` when you need typed output.
-- **`glm-4-7-251222` accepts a schema and then ignores it**, answering in prose with a `200`. It is deliberately excluded from the supported list, so the adapter rejects it up front instead of handing you unparseable output.
-
-`BYTEPLUS_STRUCTURED_OUTPUT_CHAT_MODELS` is exported if you want to gate a model picker on it.
+You now have a streaming chat on BytePlus. Seed models think before they answer, and `useChat` shows that thinking as separate thinking parts. [Tools](../tools/tools) work the same as with every adapter. BytePlus has no provider tools of its own.
## Video generation (Seedance)
> Video generation is an [experimental feature](../media/video-generation).
-Seedance is an asynchronous task API: `generateVideo()` opens the job and returns a `jobId`, and the video URL arrives with the terminal status.
+Seedance runs as a job. `generateVideo()` starts the job and returns a `jobId`. Poll the job until it has a video URL:
```typescript
-import { generateVideo, getVideoJobStatus } from '@tanstack/ai'
-import { byteplusVideo } from '@tanstack/ai-byteplus'
+import { generateVideo, getVideoJobStatus } from "@tanstack/ai";
+import { byteplusVideo } from "@tanstack/ai-byteplus";
-const adapter = byteplusVideo('dreamina-seedance-2-0-260128')
+const adapter = byteplusVideo("dreamina-seedance-2-5-260628");
const { jobId } = await generateVideo({
adapter,
- prompt: 'a guitar being played in a store',
- size: '16:9_720p',
+ prompt: "a guitar being played in a store",
+ threadId: "thread-1",
+ size: "16:9_720p",
duration: 5,
-})
-
-let status = await getVideoJobStatus({ adapter, jobId })
-while (status.status === 'pending' || status.status === 'processing') {
- await new Promise((resolve) => setTimeout(resolve, 5000))
- status = await getVideoJobStatus({ adapter, jobId })
-}
-
-console.log(status.status === 'completed' ? status.url : status.error)
-```
-
-**Generated video URLs expire 24 hours after the task completes** (the task record itself is kept for seven days), so download anything you intend to keep — see [Keeping generated files](../persistence/keep-generated-files).
-
-`size` is a `ratio` or `ratio_resolution` template (`'16:9'`, `'16:9_720p'`). Ratios are `16:9`, `9:16`, `4:3`, `3:4`, `1:1`, `21:9` and `adaptive`.
-
-### Driving the whole lifecycle from the client
-
-**Server** — hand the polling to the core with `stream: true` and pipe the chunks out:
-
-```typescript
-import { generateVideo, toServerSentEventsResponse } from '@tanstack/ai'
-import { byteplusVideo } from '@tanstack/ai-byteplus'
+});
-export async function POST(request: Request) {
- const { prompt } = await request.json()
-
- const stream = generateVideo({
- adapter: byteplusVideo('dreamina-seedance-2-0-260128'),
- prompt,
- size: '16:9_720p',
- duration: 5,
- stream: true,
- pollingInterval: 5000,
- })
-
- return toServerSentEventsResponse(stream)
+let status = await getVideoJobStatus({ adapter, jobId });
+while (status.status === "pending" || status.status === "processing") {
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+ status = await getVideoJobStatus({ adapter, jobId });
}
-```
-
-**Client** — `useGenerateVideo` tracks the job for you:
-
-```tsx
-import { fetchServerSentEvents, useGenerateVideo } from '@tanstack/ai-react'
-export function SeedanceGenerator() {
- const { generate, result, videoStatus, isLoading, error } = useGenerateVideo({
- connection: fetchServerSentEvents('/api/generate/video'),
- })
-
- return (
-
-
-
- {isLoading &&
Status: {videoStatus?.status ?? 'starting…'}
}
- {error &&
Error: {error.message}
}
- {result && }
-
- )
-}
+console.log(status.status === "completed" ? status.url : status.error);
```
-### Per-model options
-
-Seedance options are model-specific, and **Ark rejects an inapplicable field with a `400` rather than ignoring it** — "the specified parameter `draft` is not supported for model seedance-1-0-pro in t2v, must be empty". The applicable set:
+- The video URL expires 24 hours after the job completes. If you want to keep the video, download it. See [Keeping generated files](../persistence/keep-generated-files).
+- `size` is a ratio (`"16:9"`) or a ratio with a resolution (`"16:9_720p"`). The ratios are `16:9`, `9:16`, `4:3`, `3:4`, `1:1`, `21:9`, and `adaptive`.
+- The Seedance 2 models can need activation in the Ark Console first. Until then, a call fails with `404 ModelNotOpen`.
-| Option | Models that accept it |
-| --- | --- |
-| `service_tier` (`'default'` \| `'flex'`) | Seedance 1.x only — Seedance 2.5 and the 2.0 family reject it |
-| `camera_fixed` | Seedance 1.x only |
-| `frames` (fractional-second output, `25 + 4n` in `[29, 289]`) | `seedance-1-0-pro-250528`, `seedance-1-0-pro-fast-251015` |
-| `draft` (cheap low-fidelity preview) | `seedance-1-5-pro-251215` only |
-| `priority` (`0`–`9`) | Seedance 2.5 and the `dreamina-seedance-2-0-*` family |
-| `duration: -1` (model picks the length) | Seedance 2.5, 2.0 and `seedance-1-5-pro-251215` |
-| `output_format` (`'mp4'` \| `'mov'`) | Seedance 2.5 (default `mp4`) |
-| `seed`, `watermark`, `generate_audio`, `return_last_frame`, `callback_url` | every model |
-
-`watermark` defaults to `false` for video (the opposite of Seedream images). `generate_audio` is accepted everywhere but only Seedance 2.5, 2.0 and 1.5-pro actually produce an audio track.
-
-Resolutions are per model too: there is **no 2K tier on any Seedance model**, `4k` exists only on `dreamina-seedance-2-0-260128`, Seedance 2.5 accepts **480p/720p/1080p**, and `seedance-1-0-pro-fast-251015` does accept `1080p` despite older prose listing it as 480p/720p only. An unsupported combination is caught locally with a clear error before the request goes out.
-
-Reference media follows the shared [role hints](../media/video-generation#role-hints) — `start_frame`, `end_frame` and `reference`. Seedance 2.5 and the 2.0 family take full multimodal references (reference images, video and audio); Seedance 2.5 additionally accepts **audio-only** reference input and up to 30 reference images / 10 videos / 10 audio clips (2.0 is 9 / 3 / 3). The 1.x models take start/end frames only, and `seedance-1-0-pro-fast-251015` takes a start frame only.
-
-### Seedance 2.5
-
-Seedance 2.5 (`dreamina-seedance-2-5-260628`) is a first-class model in this package. It is the current multimodal flagship for longer clips (up to **30 seconds**), audio-only reference input, and `output_format: 'mov'`. Like the 2.0 series it may still require model activation / a resource pack in the Ark Console before the account can call it (`404 ModelNotOpen` until then).
-
-```typescript
-import { generateVideo } from '@tanstack/ai'
-import { byteplusVideo } from '@tanstack/ai-byteplus'
-
-const { jobId } = await generateVideo({
- adapter: byteplusVideo('dreamina-seedance-2-5-260628'),
- prompt: 'a guitar being played in a store',
- size: '16:9_1080p',
- duration: 10,
- modelOptions: {
- generate_audio: true,
- priority: 5,
- output_format: 'mp4',
- },
-})
-```
-
-| Capability | Seedance 2.5 |
-| --- | --- |
-| Duration | 4–30s, or `-1` (model chooses; required for video-editing tasks) |
-| Resolution | `480p`, `720p`, `1080p` (default `720p`) — no 4k |
-| Reference media | images 1–30, videos 0–10, audio 0–10; **audio-only allowed** |
-| First + last frame | yes |
-| `priority` / `generate_audio` | yes |
-| `service_tier` / `camera_fixed` / `frames` / `draft` | no |
-
-`byteplusVideo()` still accepts any string for ids BytePlus ships between package releases. An unknown id relaxes local guards so Ark validates the request; known ids (including 2.5) keep their documented narrowing.
-
-### Seedance here vs. Seedance via fal
-
-Seedance is also reachable through [`@tanstack/ai-fal`](./fal), which proxies it alongside hundreds of other hosted models. This adapter talks to BytePlus directly, which means BytePlus billing and rate limits, model ids in BytePlus's own naming, and the first-class Seedance request fields above (`camera_fixed`, `draft`, `priority`, reference-media roles) rather than fal's normalized subset. Pick whichever matches the account you already have; there is no reason to install both just for Seedance.
+To run the job from a React component, and for the options of each model, see [BytePlus: Advanced](./byteplus-advanced#run-a-seedance-job-from-the-client).
## Image generation (Seedream)
```typescript
-import { generateImage } from '@tanstack/ai'
-import { byteplusImage } from '@tanstack/ai-byteplus'
+import { generateImage } from "@tanstack/ai";
+import { byteplusImage } from "@tanstack/ai-byteplus";
const result = await generateImage({
- adapter: byteplusImage('dola-seedream-5-0-pro-260628'),
- prompt: 'a guitar being played in a store',
- size: '2K',
+ adapter: byteplusImage("dola-seedream-5-0-pro-260628"),
+ prompt: "a guitar being played in a store",
+ threadId: "thread-1",
+ size: "2K",
modelOptions: { watermark: false },
-})
-
-console.log(result.images[0]?.url)
-```
+});
-`size` takes either a token (`1K`, `2K`, `4K`) or explicit pixels (`2048x2048`) — never a mix of the two. Pass image parts in the `prompt` array to edit or condition on existing images (up to 14 references, 10 on `dola-seedream-5-0-pro-260628`):
-
-```typescript
-import { generateImage } from '@tanstack/ai'
-import { byteplusImage } from '@tanstack/ai-byteplus'
-
-const result = await generateImage({
- adapter: byteplusImage('seedream-5-0-260128'),
- prompt: [
- { type: 'text', content: 'Put this guitar on a concert stage' },
- {
- type: 'image',
- source: { type: 'url', value: 'https://example.com/guitar.png' },
- },
- ],
-})
-
-console.log(result.images.length)
+console.log(result.images[0]?.url);
```
-Two behaviours surprise people:
-
-- **`watermark` defaults to `true`.** BytePlus stamps "AI generated" into the bottom-right corner unless you pass `watermark: false`. The adapter never sets the field implicitly, so the provider default applies.
-- **`numberOfImages` is an upper bound, not a count.** Seedream has no `n` parameter; asking for more than one image maps onto its group-image mode (`sequential_image_generation: 'auto'` with `max_images`), where the model decides how many images the prompt actually warrants. A request for four can come back with two, and the adapter logs a warning when it does.
+- `watermark` is `true` by default. BytePlus then writes "AI generated" in the bottom-right corner. Set `watermark: false` to stop this.
+- `size` is a token (`1K`, `2K`, `4K`) or pixels (`2048x2048`). Do not mix the two.
+- `numberOfImages` is a maximum, not an exact count. The model decides how many images to return.
+- Result URLs expire after 24 hours. To get the bytes in the response, set `response_format: "b64_json"` in `modelOptions`.
-Result URLs expire after 24 hours. Pass `response_format: 'b64_json'` in `modelOptions` to get the bytes inline instead.
+To edit an existing image, see [Seedream reference images](./byteplus-advanced#seedream-reference-images).
## Text-to-speech (Seed Speech)
-Seed Speech uses `BYTEPLUS_VOICE_API_KEY`, not the Ark key.
+This adapter uses `BYTEPLUS_VOICE_API_KEY`:
```typescript
-import { generateSpeech } from '@tanstack/ai'
-import { byteplusSpeech } from '@tanstack/ai-byteplus'
+import { generateSpeech } from "@tanstack/ai";
+import { byteplusSpeech } from "@tanstack/ai-byteplus";
const result = await generateSpeech({
- adapter: byteplusSpeech('seed-audio-1.0'),
- text: 'welcome to the guitar store',
- voice: 'en_female_stokie_uranus_bigtts',
- format: 'mp3',
-})
-
-console.log(result.contentType, result.audio.length)
-```
-
-Seed Speech has **no top-level `speaker` field** — the voice travels inside a
-`references` entry, so the adapter turns `voice` into
-`references: [{ speaker: 'en_female_stokie_uranus_bigtts' }]` for you. The
-consequence: `modelOptions.references` **replaces** that entry rather than
-merging with it, so a request that passes `references` for voice cloning
-silently drops `voice`. Include a `speaker` member yourself if you still want a
-stock voice alongside your clips.
-
-The suffix on a voice id tells you which generation you are asking for:
-
-- `_uranus_bigtts` — TTS 2.0 voices, the current generation.
-- `_mars_bigtts` / `_moon_bigtts` — TTS 1.0 voices.
-- `*_emo_v2_*` — TTS 1.0 voices that additionally accept emotion tags.
-
-The full roster lives in the [BytePlus voice list](https://docs.byteplus.com/en/docs/byteplusvoice/voicelist) and changes far more often than this package ships, so any string is accepted.
-
-`speaker` also takes a voice you cloned through Voice Replication, not only a stock id. That field is the join between replication and synthesis.
-
-Output formats are `wav`, `mp3`, `pcm` and `ogg_opus`. Reach for `modelOptions` for `ogg_opus`, for an explicit `sample_rate`, for `references` (voice cloning, up to three 30-second audio clips, addressed from the text as `@Audio1` to `@Audio3`), for `watermark`, or for word-level timings:
-
-```typescript
-import { generateSpeech } from '@tanstack/ai'
-import { byteplusSpeech, type BytePlusTTSResult } from '@tanstack/ai-byteplus'
-
-const result: BytePlusTTSResult = await generateSpeech({
- adapter: byteplusSpeech('seed-audio-1.0'),
- text: 'welcome to the guitar store',
- modelOptions: {
- format: 'ogg_opus',
- sample_rate: 48000,
- speech_rate: 20,
- enable_subtitle: true,
- },
-})
-
-for (const sentence of result.subtitle?.sentences ?? []) {
- console.log(sentence.text, sentence.start_time)
-}
+ adapter: byteplusSpeech("seed-audio-1.0"),
+ text: "welcome to the guitar store",
+ threadId: "thread-1",
+ voice: "en_female_stokie_uranus_bigtts",
+ format: "mp3",
+});
+
+console.log(result.contentType, result.audio.length);
```
-Prefer the cross-provider `timestamps: true` over `modelOptions.enable_subtitle`
-unless you want the raw BytePlus block. It sets the same flag and maps the
-response onto `result.alignment` (per word) and `result.segments` (per
-sentence), already converted to seconds:
+- Formats: `wav`, `mp3`, `pcm`, and `ogg_opus`.
+- One request makes 120 seconds of audio at most.
+- `timestamps: true` gives you word timings, and `turns` makes a dialogue with up to three voices.
-```typescript
-import { generateSpeech } from '@tanstack/ai'
-import { byteplusSpeech } from '@tanstack/ai-byteplus'
-
-const result = await generateSpeech({
- adapter: byteplusSpeech('seed-audio-1.0'),
- text: 'welcome to the guitar store',
- timestamps: true,
-})
+The voice ids, voice cloning, timings, dialogue, and watermarks are in [Seed Speech](./byteplus-advanced#seed-speech). The client half is the standard [`useGenerateSpeech` hook](../media/text-to-speech#quick-setup).
-console.log(result.alignment?.unit) // 'word'
-console.log(result.alignment?.endSeconds.at(-1)) // where speech stops
-```
+## Transcription (Seed Speech ASR)
-Seed Audio 1.0 also synthesizes multi-role dialogue in one pass. Pass `turns`
-and the adapter builds the `references` array and the role-structured prompt
-for you, up to **three distinct voices** per request:
+This adapter also uses `BYTEPLUS_VOICE_API_KEY`. The call is synchronous, so you get the transcript back with no polling:
```typescript
-import { generateSpeech } from '@tanstack/ai'
-import { byteplusSpeech } from '@tanstack/ai-byteplus'
+import { generateTranscription } from "@tanstack/ai";
+import { byteplusTranscription } from "@tanstack/ai-byteplus";
-// Replace SECOND_VOICE with another voice id from the BytePlus voice list.
-const SECOND_VOICE = 'your-second-voice-id'
-
-const result = await generateSpeech({
- adapter: byteplusSpeech('seed-audio-1.0'),
- turns: [
- { text: 'Do you sell picks?', voice: 'en_female_stokie_uranus_bigtts' },
- { text: 'By the till.', voice: SECOND_VOICE },
- ],
- timestamps: true,
-})
+const result = await generateTranscription({
+ adapter: byteplusTranscription("seed-asr"),
+ audio: "https://example.com/recording.mp3",
+ modelOptions: { enable_punc: true, enable_speaker_info: true },
+ threadId: "thread-1",
+});
+console.log(result.text);
for (const segment of result.segments ?? []) {
- console.log(segment.startSeconds, segment.text)
+ console.log(segment.speaker, segment.text);
}
```
-### Watermarking speech
+`audio` takes a `File`, base64, a data URL, or a public URL. The limit is two hours and 100 MB. For the other options, see [Transcription options](./byteplus-advanced#transcription-options).
-Seed Audio takes an object here, not a flag. Two markers, both off by default:
+## Models and options
-- `aigc_watermark`: appends an audible rhythm marker to the end of the clip.
-- `aigc_metadata`: writes provenance into the audio header. It writes nothing unless `enable` is `true`.
+### Model options
+
+Ark speaks the OpenAI chat format, so the sampling options keep their OpenAI names. They go in `modelOptions`:
```typescript
-import { generateSpeech } from '@tanstack/ai'
-import { byteplusSpeech } from '@tanstack/ai-byteplus'
+import { chat } from "@tanstack/ai";
+import { byteplusText } from "@tanstack/ai-byteplus";
-const result = await generateSpeech({
- adapter: byteplusSpeech('seed-audio-1.0'),
- text: 'welcome to the guitar store',
+const stream = chat({
+ adapter: byteplusText("dola-seed-2-1-turbo-260628"),
+ messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
modelOptions: {
- watermark: {
- aigc_watermark: true,
- aigc_metadata: { enable: true, content_producer: 'guitar-store' },
- },
+ temperature: 0.7,
+ top_p: 0.9,
+ max_tokens: 2048,
},
-})
+});
```
-`watermark: true` is shorthand for `{ aigc_watermark: true }`, so the audible marker is what a boolean gives you. Images and video keep their own boolean `watermark`. Only speech takes the object.
+- Send `max_tokens` or `max_completion_tokens`, not both. Ark returns a `400` when you send both. The types cannot catch this.
+- `repetition_penalty` and `service_tier` are Ark options. `service_tier: "flex"` sends the request to a cheaper batch queue with no latency guarantee.
-Three things to plan around:
-
-- **Synthesis is capped at 120 seconds of output.** The cap applies to `originalDuration` (the length before `speech_rate` is applied), which is also what BytePlus bills on.
-- **Subtitle timings are milliseconds** on the raw `subtitle` block, while `duration` and `originalDuration` are seconds. `alignment` and `segments` are always seconds.
-- The `url` on the result expires roughly two hours after generation — persist the base64 `audio`, not the link.
-
-The client half is the standard [`useGenerateSpeech` hook](../media/text-to-speech#streaming-mode-server-route--client-hook); nothing about it is BytePlus-specific.
+Need a header on each request, or a log of each model call? The chat adapter supports [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call).
-## Transcription (Seed Speech ASR)
+### Reasoning
-Also on the voice key. The endpoint is synchronous — audio in, transcript out, no polling:
+Seed models think by default. Set how hard they think with `reasoning` on `chat()`:
```typescript
-import { generateTranscription } from '@tanstack/ai'
-import { byteplusTranscription } from '@tanstack/ai-byteplus'
-import { audioFile } from './audio'
-
-const result = await generateTranscription({
- adapter: byteplusTranscription('seed-asr'),
- audio: audioFile,
- modelOptions: { enable_punc: true, enable_speaker_info: true },
-})
-
-console.log(result.text)
-for (const segment of result.segments ?? []) {
- console.log(segment.speaker, segment.text)
-}
+import { chat } from "@tanstack/ai";
+import { byteplusText } from "@tanstack/ai-byteplus";
+
+const stream = chat({
+ adapter: byteplusText("glm-5-2-260617"),
+ messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
+ reasoning: "off",
+});
```
-Audio can be a `File`, base64, a data URL or a public URL, up to two hours and 100 MB. `enable_itn` renders spoken numbers and dates as digits, `enable_punc` inserts punctuation, `enable_ddc` strips fillers, and `enable_speaker_info` attaches speaker labels that surface as `segment.speaker`.
-
-## Supported models
-
-- **Chat** — `dola-seed-2-1-turbo-260628`, `seed-2-0-lite-260428`, `seed-2-0-mini-260428`, `seed-2-0-pro-260328`, `seed-2-0-lite-260228`, `seed-2-0-mini-260215`, `seed-2-0-code-preview-260328`, `seed-1-8-251228`, `seed-1-6-250915`, `seed-1-6-250615`, `seed-1-6-flash-250715`, `seed-1-6-flash-250615`, `glm-5-2-260617`, `glm-4-7-251222`, `deepseek-v4-pro-260425`, `deepseek-v4-flash-260425`, `deepseek-v3-2-251201`, `gpt-oss-120b-250805`.
-- **Video** — `dreamina-seedance-2-5-260628`, `dreamina-seedance-2-0-260128`, `dreamina-seedance-2-0-fast-260128`, `dreamina-seedance-2-0-mini-260615`, `seedance-1-5-pro-251215`, `seedance-1-0-pro-250528`, `seedance-1-0-pro-fast-251015`.
-- **Image** — `dola-seedream-5-0-pro-260628`, `seedream-5-0-260128`, `seedream-5-0-lite-260128`, `seedream-4-5-251128`, `seedream-4-0-250828`.
-- **Speech** — `seed-audio-1.0` (TTS) and `seed-asr` (transcription).
-
-BytePlus retires model ids aggressively and its published lists include ids that no longer resolve, so this package ships only dated ids that answered a live request. `BYTEPLUS_CHAT_MODELS`, `BYTEPLUS_VIDEO_MODELS`, `BYTEPLUS_IMAGE_MODELS`, `BYTEPLUS_TTS_MODELS` and `BYTEPLUS_TRANSCRIPTION_MODELS` are the authoritative lists and are exported for building model pickers.
-
-## API Reference
-
-Every factory has an environment-variable form and an explicit-key form.
-
-### `byteplusText(model, config?)` / `createBytePlusText(model, apiKey, config?)`
-
-Chat adapter for the Seed, GLM, DeepSeek and gpt-oss models. Reads `ARK_API_KEY` (or `BYTEPLUS_API_KEY`). `config.baseURL` overrides the region endpoint.
-
-`adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts).
+The levels of each model:
-### `byteplusVideo(model, config?)` / `createBytePlusVideo(model, apiKey, config?)`
+- `dola-seed-2-1-turbo-260628` and the `seed-*` models: `minimal` to `high`. They cannot stop thinking.
+- `glm-5-2-260617`: `off` to `high`.
+- `glm-4-7-251222`, `deepseek-v3-2-251201`: `off`, `high`.
+- `deepseek-v4-pro-260425`, `deepseek-v4-flash-260425`: `off`, `high`, `max`.
+- `gpt-oss-120b-250805`: `low`, `medium`, `high`.
-Seedance video adapter (experimental). Reads `ARK_API_KEY`.
+What goes over the wire, and how the adapter keeps the `encrypted_content` of the reasoning, is in [Reasoning and `encrypted_content`](./byteplus-advanced#reasoning-and-encrypted_content).
-### `byteplusImage(model, config?)` / `createBytePlusImage(model, apiKey, config?)`
+### Structured output
-Seedream image adapter. Reads `ARK_API_KEY`.
+Ten of the 18 chat models support `chat({ outputSchema })`. Use `dola-seed-2-1-turbo-260628` or `seed-2-0-lite-260228`. On the other models, the call fails with an error that names the models that work. See [Structured output](./byteplus-advanced#structured-output).
-### `byteplusSpeech(model, config?)` / `createBytePlusSpeech(model, apiKey, config?)`
+### Models
-Seed Speech TTS adapter. Reads **`BYTEPLUS_VOICE_API_KEY`**.
+Newest first. The package exports the full lists: `BYTEPLUS_CHAT_MODELS`, `BYTEPLUS_VIDEO_MODELS`, `BYTEPLUS_IMAGE_MODELS`, `BYTEPLUS_TTS_MODELS`, and `BYTEPLUS_TRANSCRIPTION_MODELS`.
-### `byteplusTranscription(model, config?)` / `createBytePlusTranscription(model, apiKey, config?)`
+| Activity | Factory | Models |
+| --- | --- | --- |
+| Chat | `byteplusText` | `dola-seed-2-1-turbo-260628`, `seed-2-0-lite-260428`, `seed-2-0-mini-260428`, `seed-2-0-pro-260328`, `seed-2-0-lite-260228`, `seed-2-0-mini-260215`, `seed-2-0-code-preview-260328`, `seed-1-8-251228`, `seed-1-6-250915`, `seed-1-6-250615`, `seed-1-6-flash-250715`, `seed-1-6-flash-250615`, `glm-5-2-260617`, `glm-4-7-251222`, `deepseek-v4-pro-260425`, `deepseek-v4-flash-260425`, `deepseek-v3-2-251201`, `gpt-oss-120b-250805` |
+| Video | `byteplusVideo` | `dreamina-seedance-2-5-260628`, `dreamina-seedance-2-0-260128`, `dreamina-seedance-2-0-fast-260128`, `dreamina-seedance-2-0-mini-260615`, `seedance-1-5-pro-251215`, `seedance-1-0-pro-250528`, `seedance-1-0-pro-fast-251015` |
+| Images | `byteplusImage` | `dola-seedream-5-0-pro-260628`, `seedream-5-0-260128`, `seedream-5-0-lite-260128`, `seedream-4-5-251128`, `seedream-4-0-250828` |
+| Speech | `byteplusSpeech` | `seed-audio-1.0` |
+| Transcription | `byteplusTranscription` | `seed-asr` |
-Seed Speech transcription adapter. Reads **`BYTEPLUS_VOICE_API_KEY`**.
+BytePlus retires model ids often, and its published lists contain ids that do not work. This package lists only dated ids that answered a live request.
-## Provider Tools
+`adapter.inputModalities` lists the input kinds of the selected model. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts).
-BytePlus does not expose provider-specific tool factories. Define your own with `toolDefinition()` from `@tanstack/ai` — Ark's tool calling is the standard OpenAI shape and works with the usual [tools flow](../tools/tools.md).
+## Advanced
-## Next Steps
+These topics are on [BytePlus: Advanced](./byteplus-advanced):
-- [Video Generation](../media/video-generation) — the full jobs/polling flow and the `useGenerateVideo` hook
-- [Image Generation](../media/image-generation) — image-conditioned generation and role hints
-- [Structured Outputs](../structured-outputs/one-shot) — schemas, validation and streaming
-- [Other Adapters](./openai) — explore other providers
+- Regions, and the two BYOK providers.
+- Reasoning, `encrypted_content`, and structured output.
+- Seedance from the client, the options of each model, and Seedance 2.5.
+- Seedream reference images, Seed Speech details, and transcription options.
+- The API reference.
diff --git a/docs/adapters/claude-code.md b/docs/adapters/claude-code.md
index ce41f96c36..35d0002beb 100644
--- a/docs/adapters/claude-code.md
+++ b/docs/adapters/claude-code.md
@@ -2,7 +2,7 @@
title: Claude Code
id: claude-code-adapter
order: 11
-description: "Use Claude Code as a chat backend in TanStack AI — agent harness with local tool execution, stateful coding sessions, and tool bridging via @tanstack/ai-claude-code."
+description: "Run Anthropic's Claude Code agent inside a sandbox from TanStack AI. It reads, edits, and runs code in a real repo and streams each step to your UI."
keywords:
- tanstack ai
- claude code
@@ -14,131 +14,185 @@ keywords:
- adapter
---
-The Claude Code adapter runs [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (via the `@anthropic-ai/claude-agent-sdk`) as a chat backend. Unlike HTTP provider adapters, this is a **harness adapter**: Claude Code runs its own agent loop and executes its own tools — bash, file reads and edits, glob/grep search, web search — locally on your server. Each `chat()` call runs one full harness turn; the harness's tool activity streams back as already-resolved tool-call events your UI can render.
+The Claude Code adapter runs the `claude` CLI in a sandbox and streams each step to `chat()`. The agent reads files, edits them, and runs commands in a repo.
-> **Server-only.** The harness spawns the Claude Code runtime as a subprocess, so this adapter only works in a Node.js server environment — never in the browser. Treat it like giving Claude a shell on the machine it runs on, and configure permissions accordingly.
+You need:
-## Installation
+- A sandbox provider. The samples use Docker, so Docker must run on your machine.
+- An Anthropic API key, or a `claude login` on the machine.
+- The `claude` CLI inside the sandbox. A setup step in the sandbox installs it.
+
+Do you only want a Claude chat, with no repo and no shell? Use the [Anthropic adapter](./anthropic).
+
+## 1. Install
-react: @tanstack/ai-claude-code
-vue: @tanstack/ai-claude-code
-solid: @tanstack/ai-claude-code
-svelte: @tanstack/ai-claude-code
-preact: @tanstack/ai-claude-code
-angular: @tanstack/ai-claude-code
-vanilla: @tanstack/ai-claude-code
-octane: @tanstack/ai-claude-code
+react: @tanstack/ai-claude-code @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
+vue: @tanstack/ai-claude-code @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
+solid: @tanstack/ai-claude-code @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
+svelte: @tanstack/ai-claude-code @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
+preact: @tanstack/ai-claude-code @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
+angular: @tanstack/ai-claude-code @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
+vanilla: @tanstack/ai-claude-code @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
+octane: @tanstack/ai-claude-code @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
-A runnable demo lives at [`examples/sandbox-cloudflare`](https://github.com/TanStack/ai/tree/main/examples/sandbox-cloudflare) — pick Claude Code, Codex, or Grok Build in the UI, with session resume, the harness tool timeline, and tool bridging, wired into a TanStack Start app on Workers. For the same wiring on plain Node with durable, refresh-surviving runs (this adapter on Docker), see [`examples/sandbox-web`](https://github.com/TanStack/ai/tree/main/examples/sandbox-web).
+## 2. Set the API key
-## Authentication
+Put the key in the environment of your server:
-Your laptop can already have `claude login`. A CI runner only has
-`ANTHROPIC_API_KEY`. The default `authMode` is `'api-key'`. Set `'host'`
-when you want `claude login`. See [Harness Auth](../sandbox/auth).
-
-```ts
-import { claudeCodeText } from "@tanstack/ai-claude-code"
-
-claudeCodeText("claude-opus-4-8")
-claudeCodeText("claude-opus-4-8", { authMode: "host" })
+```bash
+ANTHROPIC_API_KEY=sk-ant-...
```
-- `'api-key'` (default): inject `ANTHROPIC_API_KEY` (or pass `apiKey`).
-- `'host'`: use `claude login`. Do not inject `ANTHROPIC_API_KEY`.
+The sandbox in step 3 gives this key to the `claude` process as a workspace secret.
-## Basic Usage
+Did the machine already run `claude login`? Then you can use that login and no key. See [Harness Auth](../sandbox/auth).
-```typescript
-import { chat } from "@tanstack/ai";
-import { claudeCodeText } from "@tanstack/ai-claude-code";
+## 3. Run the agent from the server
-const stream = chat({
- adapter: claudeCodeText("claude-opus-4-8", {
- cwd: "/path/to/project",
- permissionMode: "acceptEdits",
+Define the sandbox once. It clones your repo into a Docker container and installs the `claude` CLI:
+
+```ts
+// sandbox.ts
+import {
+ createSecrets,
+ defineSandbox,
+ defineWorkspace,
+ githubRepo,
+} from "@tanstack/ai-sandbox";
+import { dockerSandbox } from "@tanstack/ai-sandbox-docker";
+
+export const sandbox = defineSandbox({
+ id: "claude-code-agent",
+ provider: dockerSandbox({ image: "node:22" }),
+ workspace: defineWorkspace({
+ source: githubRepo({ repo: "owner/app" }),
+ setup: [
+ "npm install -g @anthropic-ai/claude-code --include=optional && claude --version",
+ ],
+ secrets: createSecrets({
+ ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY ?? "",
+ }),
}),
- messages: [{ role: "user", content: "Fix the failing test in utils.test.ts" }],
});
```
-`RUN_FINISHED.usage.promptTokens` counts the full input, cached tokens included. See [Token usage](../chat/stream-events#token-usage).
+Then pass the adapter and the sandbox to `chat()` in your route:
-## Configuration
+```ts
+// api/agent.ts
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
+import { claudeCodeText } from "@tanstack/ai-claude-code";
+import { withSandbox } from "@tanstack/ai-sandbox";
+import { sandbox } from "./sandbox";
-| Option | Description |
-| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `cwd` | Working directory for the harness session. Defaults to `process.cwd()`. |
-| `permissionMode` | Claude Code permission mode (`'default'`, `'acceptEdits'`, `'bypassPermissions'`, `'plan'`, `'dontAsk'`, `'auto'`). See the permissions note below. |
-| `allowedTools` | Built-in tools the harness may use without prompting (e.g. `['Read', 'Grep', 'Bash(npm test:*)']`). |
-| `disallowedTools` | Built-in tools removed from the harness entirely. |
-| `maxTurns` | Maximum harness-internal turns per run. |
-| `systemPromptMode` | `'append'` (default) keeps Claude Code's preset system prompt and appends your `systemPrompts`; `'replace'` sends yours as the entire prompt. |
-| `mcpServers` | Extra MCP servers passed through to the harness untouched. |
-| `authMode` | `'api-key'` (default) injects `ANTHROPIC_API_KEY`. `'host'` uses `claude login`. Also valid on `modelOptions`. See [Harness Auth](../sandbox/auth). |
-| `apiKey` | Anthropic API key for the harness subprocess. |
-| `env` | Extra environment variables for the harness subprocess. |
-| `pathToClaudeCodeExecutable` | Use a specific Claude Code executable instead of the SDK's bundled one. |
-| `streamPartials` | Emit true token-level deltas for text and tool-call arguments (default `true`). When `false`, both emit whole from the complete message. |
-| `canUseTool` | Custom permission handler; replaces the adapter's default handler. |
-| `settingSources` | Claude Code settings tiers to load. Default `['project']`: the workspace's `CLAUDE.md`, `.claude/skills`, and `.mcp.json` apply, and the host's `~/.claude` (plugins, hooks, skills) is not loaded. Pass `['user', 'project', 'local']` for CLI-equivalent behavior, or `[]` to load no settings. |
+export async function POST(request: Request) {
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
-**Permissions on headless servers.** Without an explicit `permissionMode` or `canUseTool`, the adapter installs a safe default handler: bridged TanStack tools always run, and any built-in tool call that would normally prompt a human is denied with guidance instead of hanging the request. To let the harness edit files or run commands, set `permissionMode: 'acceptEdits'` / `'bypassPermissions'`, or enumerate `allowedTools`.
+ const stream = chat({
+ adapter: claudeCodeText("claude-opus-4-8"),
+ messages,
+ threadId,
+ runId,
+ middleware: [withSandbox(sandbox)],
+ });
-## Reasoning
+ return toServerSentEventsResponse(stream);
+}
+```
-Set `reasoning` on `chat()`. The adapter turns it into a Claude Code setting:
+Replace `owner/app` with your repo.
-```typescript
-import { chat } from "@tanstack/ai";
-import { claudeCodeText } from "@tanstack/ai-claude-code";
+## 4. Render the agent's work
-const stream = chat({
- adapter: claudeCodeText("claude-opus-4-8"),
- messages: [{ role: "user", content: "Refactor the auth module" }],
- reasoning: "xhigh",
-});
+The client is the same as for any chat. Render the text, and show each tool that the agent ran:
+
+```tsx
+import { useState } from "react";
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
+
+export function CodingAgent() {
+ const [input, setInput] = useState("");
+ const { messages, sendMessage } = useChat({
+ connection: fetchServerSentEvents("/api/agent"),
+ threadId: "support-chat",
+ });
+
+ return (
+
+ );
+}
```
-- `claude-opus-4-8`, `claude-opus-4-7`, `opus`: `--effort`, `low` to `max`.
-- `claude-opus-4-6`, `claude-sonnet-4-6`, `sonnet`: `--effort`, `low`, `medium`, `high`, `max`.
-- `claude-haiku-4-5`, `haiku`: `MAX_THINKING_TOKENS`, `off` to `high`. The level picks the budget, or you set it with `budgetTokens`. `off` sends `0`.
+Ask it "Fix the failing test in utils.test.ts". You see the agent's text, plus a line for each tool it used: `Read`, `Edit`, `Bash`, and more. The agent edited the files inside the container.
-## Stateful Sessions
+## How it works
-Claude Code sessions are stateful — the harness keeps the full working context (files read, commands run, conclusions reached) between turns. The adapter surfaces the session id of every run as a custom stream event named `claude-code.session-id`; thread it back via `modelOptions.sessionId` to resume the session. When resuming, only the latest user message is sent — the harness already holds the prior context.
+- `claudeCodeText` declares that it needs a sandbox. `withSandbox(sandbox)` gives it one. Without a sandbox, `chat()` fails at the call site.
+- Each `chat()` call runs one full agent turn. Claude Code runs its own loop and its own tools.
+- The tools of the agent stream back as `tool-call` parts with the results attached. Your code does not run them.
+- The agent runs with `permissionMode: 'bypassPermissions'` by default. The sandbox is the safety boundary. To limit what the agent can do, add a sandbox [Policy](../sandbox/policy).
+- When the run ends, the adapter sends a `file.changed` custom event with the `git diff` of the workspace.
-The `claude-code.session-id` event payload also includes `skills`, an array of skills loaded during SDK initialization (or an empty array when none are reported).
+To learn more about sandboxes, see [Sandboxes](../sandbox/overview).
-Server endpoint:
+## Resume a session
-```typescript
+Claude Code keeps its working context between turns: the files it read, the commands it ran, and what it found. The adapter sends the session id of each run as a custom event named `claude-code.session-id`. Send it back in `modelOptions.sessionId` to continue the session.
+
+On the server, read the id from the request:
+
+```ts
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from "@tanstack/ai";
import { claudeCodeText } from "@tanstack/ai-claude-code";
+import { withSandbox } from "@tanstack/ai-sandbox";
+import { sandbox } from "./sandbox";
export async function POST(request: Request) {
const params = await chatParamsFromRequest(request);
- // Extra fields the client puts in the connection `body` arrive here.
+ // The client sends the id in `forwardedProps`.
const sessionId =
typeof params.forwardedProps.sessionId === "string"
? params.forwardedProps.sessionId
: undefined;
const stream = chat({
- adapter: claudeCodeText("claude-opus-4-8", {
- cwd: "/path/to/project",
- permissionMode: "acceptEdits",
- }),
+ adapter: claudeCodeText("claude-opus-4-8"),
messages: params.messages,
+ threadId: params.threadId,
+ runId: params.runId,
+ middleware: [withSandbox(sandbox)],
modelOptions: { sessionId },
});
@@ -146,20 +200,19 @@ export async function POST(request: Request) {
}
```
-Client (React) — capture the session id from the custom event and send it back on subsequent requests:
+On the client, keep the id from the custom event and pass it to `forwardedProps`. `useChat` sends the latest value with each request:
-```typescript
+```tsx
import { useState } from "react";
-import { useChat } from "@tanstack/ai-react";
-import { fetchServerSentEvents } from "@tanstack/ai-client";
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
-function CodingAssistant() {
+export function useCodingAgent() {
const [sessionId, setSessionId] = useState(undefined);
- const { messages, sendMessage } = useChat({
- connection: fetchServerSentEvents("/api/chat", () => ({
- body: { sessionId },
- })),
+ return useChat({
+ connection: fetchServerSentEvents("/api/agent"),
+ forwardedProps: { sessionId },
+ threadId: "support-chat",
onCustomEvent: (name, value) => {
if (
name === "claude-code.session-id" &&
@@ -172,26 +225,23 @@ function CodingAssistant() {
}
},
});
-
- // ... render messages; harness tool activity (Bash, Edit, Read, ...)
- // arrives as regular tool-call parts with their results attached.
}
```
-Sessions are stored on the machine that ran them (`~/.claude/projects/`), so resuming only works on the same server instance. Pass `modelOptions: { forkSession: true }` alongside `sessionId` to branch a session instead of continuing it.
+- On a resumed session, the adapter sends only the latest user message. The agent already has the rest.
+- The event value also has `skills`: the skills that Claude Code loaded when it started. It is an empty array when Claude Code reports none.
+- Claude Code saves the session inside the sandbox. Resume works only when the next call runs in the same sandbox. See [Lifecycle & Snapshots](../sandbox/lifecycle).
-## Tools
+## Give the agent your own tools
-Two kinds of tools flow through this adapter:
+The agent has its own tools for files and the shell. It cannot reach your database or your secrets. Pass your server tools to `chat()`, and the adapter bridges them into the sandbox:
-1. **Built-in harness tools** (`Bash`, `Read`, `Write`, `Edit`, `Glob`, `Grep`, `WebSearch`, ...) are executed by Claude Code itself. Their activity streams back as tool-call events with results already attached, so `useChat` UIs render them with no extra wiring — but your code never executes them.
-
-2. **Your TanStack tools** are bridged *into* the harness as an in-process MCP server. Define them as usual with `toolDefinition().server()`; the model sees them as `mcp__tanstack__` and the adapter strips the prefix on the way back out, so events match the names you registered.
-
-```typescript group=tools
+```ts group=tools
import { z } from "zod";
import { chat, toolDefinition } from "@tanstack/ai";
import { claudeCodeText } from "@tanstack/ai-claude-code";
+import { withSandbox } from "@tanstack/ai-sandbox";
+import { sandbox } from "./sandbox";
const lookupTicket = toolDefinition({
name: "lookup_ticket",
@@ -204,22 +254,30 @@ const lookupTicket = toolDefinition({
const stream = chat({
adapter: claudeCodeText("claude-opus-4-8"),
messages: [{ role: "user", content: "What's the status of ticket T-123?" }],
+ threadId: "thread-1",
tools: [lookupTicket],
+ middleware: [withSandbox(sandbox)],
});
```
-**Client-side and approval-gated tools are not supported.** The harness executes tools inside a live subprocess, which cannot pause across HTTP requests to wait for a browser round-trip or a human approval. Passing a tool without a server `execute()` implementation — or one marked `needsApproval` — fails fast with a descriptive error. Run those tools outside the harness with a regular provider adapter.
+- Each tool runs on your server, with its closures and secrets. The result goes back into the sandbox.
+- The agent sees the tool as `mcp__tanstack__lookup_ticket`. The adapter removes the prefix, so the events use the name you gave.
+- Only server tools work here. A client tool has no server `execute()`, so the agent cannot run it.
+
+For how the bridge reaches your server from each provider, see [Sandbox Tools](../sandbox/tools).
## Tool choice
-You want Claude Code to answer in text only, or to use just one of your tools. Pass `toolChoice` to `chat()`:
+You want Claude Code to answer in text only, or to use only one of your tools. Pass `toolChoice` to `chat()`:
-```typescript group=tools
+```ts group=tools
const onlyLookup = chat({
adapter: claudeCodeText("claude-opus-4-8"),
messages: [{ role: "user", content: "What's the status of ticket T-123?" }],
+ threadId: "thread-1",
tools: [lookupTicket],
toolChoice: { type: "tool", name: "lookup_ticket" },
+ middleware: [withSandbox(sandbox)],
});
```
@@ -230,78 +288,166 @@ const onlyLookup = chat({
| `{ type: 'tool', name }` | Turns off the built-in tools and bridges only the named tool. Claude Code can call only this tool, but it does not have to. |
| `'required'` | No change. Claude Code cannot force a tool call, so the adapter logs a warning the first time. |
-For `'none'` and a named tool, the adapter passes `--tools ""` and `--strict-mcp-config`. Then other MCP servers, for example from the workspace or a settings file, do not load.
-
For the values on other adapters, see [Choose when the model calls a tool](../tools/tools#choose-when-the-model-calls-a-tool).
-## Structured Output
-
-Pass `outputSchema` on `chat()`. Claude Code runs one harness turn, uses its native tools, and returns a typed object. The schema JSON is passed to `--json-schema` as inline JSON (the CLI rejects a file path). Tool activity and prose stream as usual. The object arrives as `structured-output.complete`, including when Claude delivers it through its built-in `StructuredOutput` tool.
+## Structured output
-By default, the adapter loads only project settings (`--setting-sources project`). Workspace projections (instructions, skills, MCP config) are project-scoped, so they apply. The host's `~/.claude` is not loaded, so a local-process run does not pick up your personal skills and hooks. A cloned repo's `.claude/settings.json` and hooks apply to the run. Set `settingSources` to change this. The adapter does not pass `--bare`, because that flag ignores a host `claude login`.
-
-On local-process, Claude uses your host `claude login`. On Docker, pass `ANTHROPIC_API_KEY` in the process env. A container has no host login.
+You want a typed report after the agent reads the repo. Pass `outputSchema` on the same `chat()` call:
```ts
-import { chat } from "@tanstack/ai"
-import { claudeCodeText } from "@tanstack/ai-claude-code"
-import { defineSandbox, withSandbox } from "@tanstack/ai-sandbox"
-import { dockerSandbox } from "@tanstack/ai-sandbox-docker"
-import { z } from "zod"
+import { chat } from "@tanstack/ai";
+import { claudeCodeText } from "@tanstack/ai-claude-code";
+import { withSandbox } from "@tanstack/ai-sandbox";
+import { z } from "zod";
+import { sandbox } from "./sandbox";
const Report = z.object({
summary: z.string(),
filesChanged: z.array(z.string()),
-})
-
-const sandbox = defineSandbox({
- id: "repo-report",
- provider: dockerSandbox({ image: "node:22" }),
-})
+});
const report = await chat({
adapter: claudeCodeText("claude-opus-4-8"),
messages: [{ role: "user", content: "Review this repo." }],
+ threadId: "thread-1",
outputSchema: Report,
middleware: [withSandbox(sandbox)],
-})
+});
-report.summary
+console.log(report.summary);
```
On the client, pass the same schema to `useChat` and read `final`. `partial` stays empty until the end.
```tsx
-import { fetchServerSentEvents, useChat } from "@tanstack/ai-react"
-import { z } from "zod"
+import { fetchServerSentEvents, useChat } from "@tanstack/ai-react";
+import { z } from "zod";
const Report = z.object({
summary: z.string(),
filesChanged: z.array(z.string()),
-})
+});
-function ReportView() {
+export function ReportView() {
const { final, isLoading } = useChat({
connection: fetchServerSentEvents("/api/repo-report"),
+ threadId: "support-chat",
outputSchema: Report,
- })
+ });
- if (isLoading) return
The agent is inspecting the repo.
- if (!final) return null
- return
{final.summary}
+ if (isLoading) return
The agent is inspecting the repo.
;
+ if (!final) return null;
+ return
{final.summary}
;
}
```
-If you only need to extract JSON from a prompt and do not need a sandbox, use `@tanstack/ai-anthropic`. That path is faster.
+- Claude Code runs one turn with its own tools. Tool activity and text stream as usual.
+- The adapter passes the schema to `--json-schema` as inline JSON. The CLI does not take a file path.
+- The object arrives as `structured-output.complete`. This also works when Claude sends it through its built-in `StructuredOutput` tool.
+
+Do you only need JSON from a prompt, with no repo? Use `@tanstack/ai-anthropic`. That is faster. For the full walkthrough, see [Harness Agents](../structured-outputs/harnesses).
+
+## Models and options
+
+### Models
+
+| Model | Reasoning levels | What `reasoning` sets |
+| --- | --- | --- |
+| `claude-opus-4-8`, `claude-opus-4-7`, `opus` | `low`, `medium`, `high`, `xhigh`, `max` | `--effort` |
+| `claude-opus-4-6`, `claude-sonnet-4-6`, `sonnet` | `low`, `medium`, `high`, `max` | `--effort` |
+| `claude-haiku-4-5`, `haiku` | `off`, `minimal`, `low`, `medium`, `high` | `MAX_THINKING_TOKENS` |
+
+The package exports this list as `CLAUDE_CODE_MODELS`. The adapter also takes any other Claude model id as a string.
+
+### Reasoning
+
+Set `reasoning` on `chat()`:
+
+```ts
+import { chat } from "@tanstack/ai";
+import { claudeCodeText } from "@tanstack/ai-claude-code";
+import { withSandbox } from "@tanstack/ai-sandbox";
+import { sandbox } from "./sandbox";
+
+const stream = chat({
+ adapter: claudeCodeText("claude-opus-4-8"),
+ messages: [{ role: "user", content: "Refactor the auth module" }],
+ threadId: "thread-1",
+ reasoning: "xhigh",
+ middleware: [withSandbox(sandbox)],
+});
+```
+
+For `claude-haiku-4-5`, the level picks a token budget. You can also set the budget with `budgetTokens`. `off` sends `0`. See [Reasoning](../chat/reasoning).
+
+### Adapter options
+
+Pass these as the second argument of `claudeCodeText`:
+
+| Option | What it does |
+| --- | --- |
+| `cwd` | The working directory inside the sandbox. Default `/workspace`. |
+| `permissionMode` | `'default'`, `'acceptEdits'`, `'bypassPermissions'`, or `'plan'`. Default `'bypassPermissions'`. |
+| `allowedTools` | Built-in tools that the agent can use (`--allowedTools`), for example `['Read', 'Grep', 'Bash(npm test:*)']`. |
+| `disallowedTools` | Built-in tools that the agent cannot use (`--disallowedTools`). |
+| `addDirs` | More directories that the agent can access (`--add-dir`). |
+| `maxTurns` | The maximum number of agent turns in one run. |
+| `settingSources` | Which Claude Code settings to load. Default `['project']`. See [Settings sources](#settings-sources). |
+| `systemPromptMode` | `'append'` (default) adds your `systemPrompts` to the Claude Code prompt. `'replace'` sends only yours. |
+| `authMode` | `'api-key'` (default) gives `ANTHROPIC_API_KEY` to the process. `'host'` uses `claude login`. See [Harness Auth](../sandbox/auth). |
+| `claudeExecutable` | The path or name of the `claude` executable in the sandbox. Default `claude`. |
+| `streamPartials` | Stream text and tool arguments token by token. Default `true`. With `false`, each arrives whole. |
+| `env` | More environment variables for the `claude` process. |
+| `emitDiff` | Send the `file.changed` event with the `git diff` after the run. Default `true`. |
+
+### Per-call options
+
+Pass these in `modelOptions` on `chat()`. They override the adapter options for one call:
+
+- `sessionId`: resume a session. See [Resume a session](#resume-a-session).
+- `cwd`, `maxTurns`, `permissionMode`, `allowedTools`, `disallowedTools`.
+- `authMode`: `'host'` or `'api-key'`.
+
+## Advanced
+
+### Permissions and policy
+
+A headless run has no person to answer a permission prompt. So the default `permissionMode` is `'bypassPermissions'`, and the sandbox is the boundary.
+
+- To allow fewer actions, set `permissionMode`, `allowedTools`, or `disallowedTools`.
+- To allow, ask, or deny each command and capability, add a sandbox [Policy](../sandbox/policy). The adapter maps the policy to Claude Code flags and answers the permission prompts of Claude Code from it.
+- If the policy says `ask`, the adapter sends an approval-requested event and denies the action. The client approves, then runs the turn again.
+
+### Settings sources
+
+By default, the adapter loads only the project settings (`--setting-sources project`):
+
+- The workspace `CLAUDE.md`, `.claude/skills`, and `.mcp.json` apply to the run.
+- A cloned repo's `.claude/settings.json` and hooks also apply.
+- The host `~/.claude` (your personal plugins, hooks, and skills) does not load. This matters on the local-process provider.
+
+For the same behavior as the CLI, pass `settingSources: ['user', 'project', 'local']`. To load no settings, pass `[]`.
+
+The adapter does not pass `--bare`, because that flag ignores a host `claude login`.
+
+### How tool choice changes the CLI flags
+
+For `'none'` and a named tool, the adapter passes `--tools ""` and `--strict-mcp-config`. Then other MCP servers, for example from the workspace or a settings file, do not load.
+
+### Token usage
+
+`RUN_FINISHED.usage.promptTokens` counts the full input, cached tokens included. See [Token usage](../chat/stream-events#token-usage).
-Full walkthrough, including the client: [Harness Agents](../structured-outputs/harnesses).
+### Limitations
-## Limitations
+- **A sandbox is required.** Every call needs `withSandbox(...)`.
+- **The agent owns the loop.** TanStack agent-loop strategies and per-iteration middleware do not apply inside a turn. Use `maxTurns` to limit the turn.
+- **No sampling options.** Options such as `temperature` do not exist here.
+- **Sessions stay in the sandbox.** Resume needs the same sandbox.
+- **Cold starts.** Each call starts a CLI turn, so the first token arrives later than with an HTTP adapter.
+- **No `wrapFetch`.** This adapter ignores [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call), because the `claude` process sends the model requests.
-- **Server-only (Node).** The harness spawns a subprocess; Windows support is untested.
-- **The harness owns the agent loop.** TanStack's agent-loop strategies and per-iteration middleware don't apply inside a harness turn; `maxTurns` is the equivalent control.
-- **No sampling controls.** `temperature`-style options don't exist here.
-- **Sessions are machine-local.** Resume requires hitting the same server instance.
-- **Cold starts.** Each call spawns a harness turn; expect higher first-token latency than HTTP adapters.
+### Example apps
-This adapter ignores [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call), because the harness process sends the model requests.
+- [`examples/sandbox-cloudflare`](https://github.com/TanStack/ai/tree/main/examples/sandbox-cloudflare): a TanStack Start app on Workers. You pick Claude Code, Codex, or Grok Build in the UI. It shows session resume, the tool timeline, and tool bridging.
+- [`examples/sandbox-web`](https://github.com/TanStack/ai/tree/main/examples/sandbox-web): this adapter on Docker, with durable runs that continue after a page refresh.
diff --git a/docs/adapters/cloudflare.md b/docs/adapters/cloudflare.md
index 9abf504050..7f0fc20247 100644
--- a/docs/adapters/cloudflare.md
+++ b/docs/adapters/cloudflare.md
@@ -1,7 +1,7 @@
---
title: Cloudflare
id: cloudflare-adapter
-description: "Run Workers AI chat, embeddings, images, speech, transcription, and evaluate from a Cloudflare Worker or any server, and route other providers through AI Gateway, with TanStack AI."
+description: "Run Workers AI chat, embeddings, images, speech, transcription, and evaluate from a Cloudflare Worker or any server, and send other providers through AI Gateway, with TanStack AI."
keywords:
- tanstack ai
- cloudflare
@@ -14,9 +14,13 @@ keywords:
- jev
---
-You have a Cloudflare Worker and want an AI route without managing API keys. Or you have a server elsewhere and want to call Workers AI models. Or you already use OpenAI or Anthropic and want AI Gateway caching and logs in front of them. This adapter does all three.
+The Cloudflare adapter calls Workers AI, and it can put AI Gateway in front of another provider.
-## Installation
+- You have a Cloudflare Worker and want an AI route with no API keys to manage.
+- You have a server somewhere else and want to call Workers AI models.
+- You use OpenAI or Anthropic and want AI Gateway caching and logs in front of them.
+
+## 1. Install
@@ -31,11 +35,9 @@ octane: @tanstack/ai-cloudflare
-## Chat from a Worker
-
-Pass `env.AI` as the binding. No token, no account id.
+## 2. Connect to Cloudflare
-1. Add the binding to `wrangler.jsonc`:
+Inside a Worker, use the `env.AI` binding. You need no token and no account id. Add the binding to `wrangler.jsonc`:
```jsonc
{
@@ -43,10 +45,25 @@ Pass `env.AI` as the binding. No token, no account id.
}
```
-2. Stream a chat response from your Worker:
+On any other server, the adapter calls the Cloudflare REST API. Put your account id and an API token with Workers AI access in the environment of your server:
+
+```bash
+CLOUDFLARE_ACCOUNT_ID=your-account-id
+CLOUDFLARE_API_TOKEN=your-api-token
+```
+
+Get the account id from `wrangler whoami`. Create the token in the Cloudflare dashboard, under Workers AI, then Use REST API.
+
+## 3. Stream a chat from the server
+
+From a Worker, pass `env.AI` as the binding:
```typescript
-import { chat, toServerSentEventsResponse } from "@tanstack/ai";
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
import { createCloudflareText } from "@tanstack/ai-cloudflare";
import type { Ai } from "@cloudflare/workers-types";
@@ -56,13 +73,15 @@ interface Env {
export default {
async fetch(request: Request, env: Env) {
- const { messages } = await request.json();
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
const stream = chat({
adapter: createCloudflareText("@cf/zai-org/glm-5.3-flash", {
binding: env.AI,
}),
messages,
+ threadId,
+ runId,
});
return toServerSentEventsResponse(stream);
@@ -70,36 +89,86 @@ export default {
};
```
-That is a working AI route. Run `wrangler dev` and post `{ "messages": [...] }` to it.
+Run `wrangler dev` to start the route.
-## Chat from anywhere else
+From any other server, `cloudflareText` reads `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN`:
-Outside a Worker, the adapter talks to the Cloudflare REST API. You need your account id and an API token with Workers AI access.
+```typescript
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
+import { cloudflareText } from "@tanstack/ai-cloudflare";
+
+export async function POST(request: Request) {
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
+
+ const stream = chat({
+ adapter: cloudflareText("@cf/zai-org/glm-5.3-flash"),
+ messages,
+ threadId,
+ runId,
+ });
+
+ return toServerSentEventsResponse(stream);
+}
+```
+
+To pass the values in code, use `createCloudflareText`:
```typescript
-import { chat } from "@tanstack/ai";
import { createCloudflareText } from "@tanstack/ai-cloudflare";
const adapter = createCloudflareText("@cf/zai-org/glm-5.3-flash", {
accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
apiKey: process.env.CLOUDFLARE_API_TOKEN!,
});
-
-const stream = chat({
- adapter,
- messages: [{ role: "user", content: "Hello!" }],
-});
```
-`cloudflareText(model)` does the same and reads `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` from the environment for you.
+## 4. Render the chat
+
+The client is the same for every provider. Point `useChat` at your route:
+
+```tsx
+import { useState } from "react";
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
-The text and summarize REST config also accepts the OpenAI SDK client options (`baseURL`, `defaultHeaders`, `fetch`, `timeout`, `maxRetries`). The other adapters call the native `/ai/run` endpoint and take `fetch` only.
+export function Chat() {
+ const [input, setInput] = useState("");
+ const { messages, sendMessage } = useChat({
+ connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
+ });
+
+ return (
+
+ );
+}
+```
+
+For Vue, Solid, Svelte, Angular, and the other frameworks, see [Quick Start](../getting-started/quick-start#3-render-the-chat).
-Need a header on each request, or a log of each model call? The chat adapter supports [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call) over REST. In binding mode, the wrapper still runs. The binding reads only the request body, so it drops the URL and the headers that the wrapper sets.
+You now have a streaming chat on Workers AI. [Tools](../tools/tools) and [Structured Outputs](../structured-outputs/overview) work the same as with every adapter.
## Route through AI Gateway
-AI Gateway gives you caching, logs, rate limits, and one bill across providers. Add `gateway` to any config to send requests through it. Use `"default"` for the gateway Cloudflare creates for your account.
+AI Gateway gives you caching, logs, rate limits, and one bill for all providers. To send requests through it, add `gateway` to any config. Use `"default"` for the gateway that Cloudflare makes for your account:
```typescript
import { createCloudflareText } from "@tanstack/ai-cloudflare";
@@ -117,7 +186,7 @@ export function makeAdapter(env: Env) {
}
```
-With a gateway you can also run third-party models by their catalog id, billed through Cloudflare:
+With a gateway, you can also run third-party models by their catalog id. Cloudflare bills them:
```typescript
import { createCloudflareText } from "@tanstack/ai-cloudflare";
@@ -131,7 +200,7 @@ const adapter = createCloudflareText("openai/gpt-5.5", {
### Keep your existing provider adapter
-Already using `@tanstack/ai-openai` with your own OpenAI key? `cloudflareGateway()` returns the `baseURL` and headers that point that adapter at your gateway. The rest of your code stays the same.
+Do you use `@tanstack/ai-openai` with your own OpenAI key? `cloudflareGateway()` returns the `baseURL` and the headers that point that adapter at your gateway. The rest of your code stays the same:
```typescript
import { createOpenaiChat } from "@tanstack/ai-openai";
@@ -143,22 +212,27 @@ const gateway = cloudflareGateway("openai", {
cacheTtl: 300,
});
-const adapter = createOpenaiChat("gpt-5.5", process.env.OPENAI_API_KEY!, {
+const adapter = createOpenaiChat("gpt-6.1-sol", process.env.OPENAI_API_KEY!, {
baseURL: gateway.baseURL,
defaultHeaders: gateway.headers,
});
```
-The first argument is the provider slug from your gateway dashboard (`openai`, `anthropic`, `groq`, and so on). If the gateway has authentication turned on, pass `cfApiKey` too. That token needs the `AI Gateway Run` permission.
+- The first argument is the provider slug from your gateway dashboard, for example `openai`, `anthropic`, or `groq`.
+- If the gateway has authentication on, also pass `cfApiKey`. That token needs the `AI Gateway Run` permission.
-The `ts-react-chat` example does this. Set `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_API_TOKEN`, and `CLOUDFLARE_AI_GATEWAY_ID` in its `.env`, and the Cloudflare, OpenAI, Anthropic, and Groq models in its picker all go through your gateway.
+The `ts-react-chat` example does this. Set `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_API_TOKEN`, and `CLOUDFLARE_AI_GATEWAY_ID` in its `.env`. Then the Cloudflare, OpenAI, Anthropic, and Groq models in its picker all go through your gateway.
### Claude and GPT from a Worker
-Inside a Worker, the AI Gateway `anthropic/...` and `openai/...` models use their provider's own API, not Chat Completions. Keep the provider adapter, and give it `cloudflareBindingFetch` as its `fetch`. The binding signs the requests, so the Worker needs no provider key:
+Inside a Worker, the AI Gateway `anthropic/...` and `openai/...` models use the API of their own provider, not Chat Completions. Keep the provider adapter, and give it `cloudflareBindingFetch` as its `fetch`. The binding signs the requests, so the Worker needs no provider key:
```typescript
-import { chat, toServerSentEventsResponse } from "@tanstack/ai";
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
import { createAnthropicChat } from "@tanstack/ai-anthropic";
import { cloudflareBindingFetch } from "@tanstack/ai-cloudflare";
import type { Ai } from "@cloudflare/workers-types";
@@ -169,7 +243,7 @@ interface Env {
export default {
async fetch(request: Request, env: Env) {
- const { messages } = await request.json();
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
// The SDK needs a key value. The binding does not use it.
const adapter = createAnthropicChat("claude-opus-5-5", "cloudflare-binding", {
@@ -180,7 +254,7 @@ export default {
}),
});
- const stream = chat({ adapter, messages, reasoning: "high" });
+ const stream = chat({ adapter, messages, threadId, runId, reasoning: "high" });
return toServerSentEventsResponse(stream);
},
};
@@ -188,33 +262,22 @@ export default {
- `vendor: "anthropic"` sends Anthropic Messages requests to `anthropic/`. Use it with `createAnthropicChat`.
- `vendor: "openai"` sends OpenAI Responses requests to `openai/`. Use it with `createOpenaiChat`.
-- The adapter keeps all of its options: `chat({ reasoning })`, tools, `cache_control` for prompt caching, and Anthropic betas, which go out as the `anthropic-beta` header.
+- The adapter keeps all of its options: `chat({ reasoning })`, tools, `cache_control` for prompt caching, and Anthropic betas (sent as the `anthropic-beta` header).
- For `@cf/...` models and other gateway vendors, keep `createCloudflareText` with `binding: env.AI`.
-An adapter with a gateway `baseURL` (`cloudflareGateway()`) or with `cloudflareBindingFetch` sends the full tools and system prompts on every call, also on a model with a [mid-conversation channel](../advanced/mid-conversation-changes). AI Gateway then gets the same request as on any other model. To send tools and prompts added during a conversation through the channel, set `midConversationChannels: true`:
+## Bring your own key
-```typescript
-import { createAnthropicChat } from "@tanstack/ai-anthropic";
-import { cloudflareBindingFetch } from "@tanstack/ai-cloudflare";
-import type { Ai } from "@cloudflare/workers-types";
+Two different things have this name. Both work.
-export function gatewayClaude(env: { AI: Ai }) {
- return createAnthropicChat("claude-opus-5-5", "cloudflare-binding", {
- fetch: cloudflareBindingFetch({
- binding: env.AI,
- vendor: "anthropic",
- gateway: { id: "default" },
- }),
- midConversationChannels: true,
- });
-}
-```
+### Cloudflare credentials of a user, from the browser
-## Bring your own key
+A user brings two values: the API token, and the account that it belongs to.
-Two different things go by this name. Both work.
+- `cloudflareByok` and `cloudflareAccountByok` are two entries in the TanStack BYOK store. Each one has its own env fallback.
+- `cloudflareByok` declares the account id as a companion. Register both, and a send for `cloudflare` carries both headers.
+- Import them from `@tanstack/ai-cloudflare/byok`, not from the main entry.
-**A user's Cloudflare credentials from the browser.** A user brings two values: the API token and the account it belongs to. `cloudflareByok` and `cloudflareAccountByok` are two entries in the TanStack BYOK store, each with its own env fallback. `cloudflareByok` declares the account id as a companion, so register both and a send for `cloudflare` carries both headers. Import them from `@tanstack/ai-cloudflare/byok`, not from the main entry.
+On the client:
```typescript
import { defineByok, defaultByokStorage } from "@tanstack/ai-client/byok";
@@ -232,7 +295,11 @@ export const byok = defineByok({
On the relay, read both values in one call:
```typescript
-import { chat, toServerSentEventsResponse } from "@tanstack/ai";
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
import { createCloudflareText } from "@tanstack/ai-cloudflare";
import {
cloudflareAccountByok,
@@ -248,13 +315,15 @@ export async function POST(request: Request) {
if (!apiKey) return byokMissing(cloudflareByok);
if (!accountId) return byokMissing(cloudflareAccountByok);
- const { messages } = await request.json();
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
const stream = chat({
adapter: createCloudflareText("@cf/zai-org/glm-5.3-flash", {
accountId,
apiKey,
}),
messages,
+ threadId,
+ runId,
});
return toServerSentEventsResponse(stream);
}
@@ -262,84 +331,13 @@ export async function POST(request: Request) {
See [Bring Your Own Key](../advanced/byok) for the client store and the save dialog.
-**Your provider keys stored in AI Gateway.** Add an OpenAI or Anthropic key under Provider Keys in the gateway dashboard, alias `default`. From then on, a `provider/model` id with `gateway: { id }` uses that key instead of Unified Billing. No code changes.
-
-## Models
-
-Any id from the [Workers AI catalog](https://developers.cloudflare.com/workers-ai/models/) works, and so does any `provider/model` id from the [AI Gateway catalog](https://developers.cloudflare.com/ai/models/) when a gateway is set. Ids in the bundled catalog get editor autocomplete.
-
-## Tools and structured output
-
-Tools and `outputSchema` work the same as with every other adapter:
-
-```typescript
-import { chat, toServerSentEventsResponse, toolDefinition } from "@tanstack/ai";
-import { createCloudflareText } from "@tanstack/ai-cloudflare";
-import { z } from "zod";
-import type { Ai } from "@cloudflare/workers-types";
-
-interface Env {
- AI: Ai;
-}
-
-const getWeather = toolDefinition({
- name: "get_weather",
- description: "Get the current weather",
- inputSchema: z.object({ location: z.string() }),
-}).server(async ({ location }) => {
- return { location, temperature: 21, conditions: "sunny" };
-});
-
-export default {
- async fetch(request: Request, env: Env) {
- const { messages } = await request.json();
-
- const stream = chat({
- adapter: createCloudflareText("@cf/zai-org/glm-5.3-flash", {
- binding: env.AI,
- }),
- messages,
- tools: [getWeather],
- });
-
- return toServerSentEventsResponse(stream);
- },
-};
-```
-
-## Model options
-
-Sampling controls go in `modelOptions`, and the thinking level goes in `reasoning`. Reasoning models stream their thinking as `reasoning_content`, which shows up as `REASONING_*` events.
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { cloudflareText } from "@tanstack/ai-cloudflare";
-
-const stream = chat({
- adapter: cloudflareText("@cf/zai-org/glm-5.3-flash"),
- messages: [{ role: "user", content: "Summarize this in one sentence." }],
- modelOptions: {
- temperature: 0.3,
- max_tokens: 512,
- },
- reasoning: "low",
-});
-```
-
-### Reasoning
+### Your provider keys stored in AI Gateway
-`reasoning` goes out as `reasoning_effort`. `off` sends `null`, which turns thinking off. Some models and their levels:
-
-- `@cf/zai-org/glm-5.3`, `@cf/zai-org/glm-5.3-flash`: `low`, `high`, `max`.
-- `@cf/deepseek-ai/deepseek-v4-pro-0813`, `@cf/deepseek-ai/deepseek-v4-flash-0731`: `off`, `low`, `high`, `max`.
-- `@cf/openai/gpt-oss-120b`, `@cf/openai/gpt-oss-20b`: `low`, `medium`, `high`.
-
-The types take the levels of each model. A model with no reasoning data takes no `reasoning`.
+Add an OpenAI or Anthropic key under Provider Keys in the gateway dashboard, with the alias `default`. Then a `provider/model` id with `gateway: { id }` uses that key, not Unified Billing. You change no code.
## Evaluate
-Use `cloudflareDecider('typesafe/jev')` with `decide()`.
-Pass `gateway: { id: 'default' }` on the adapter config to send the run through AI Gateway.
+Sometimes you need an answer that your code can branch on, not chat text. Use `cloudflareDecider("typesafe/jev")` with `decide()`. To send the run through AI Gateway, add `gateway: { id: "default" }` to the adapter config.
From a Worker, pass the binding:
@@ -387,8 +385,7 @@ export async function evaluateTicket(env: Env) {
}
```
-From any other server, `cloudflareDecider` reads `CLOUDFLARE_ACCOUNT_ID` and
-`CLOUDFLARE_API_TOKEN`:
+From any other server, `cloudflareDecider` reads `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN`:
```typescript
import { decide, boolean } from "@tanstack/ai";
@@ -407,14 +404,19 @@ const result = await decide({
console.log(result.refund.value);
```
-See the [Evaluate guide](../evaluate/evaluate) for question helpers, the result
-shape, abort, and middleware.
+See the [Evaluate guide](../evaluate/evaluate) for the question helpers, the result shape, abort, and middleware.
## Summarize, embed, and media
-Every activity takes the same config as chat: `{ binding }` inside a Worker, `{ accountId, apiKey }` elsewhere, plus an optional `gateway`. The examples below use the env-reading factories.
+Every activity takes the same config as chat:
+
+- Inside a Worker: `{ binding }`.
+- On any other server: `{ accountId, apiKey }`.
+- Both take an optional `gateway`.
-Summarize:
+The samples below use the factories that read the environment.
+
+### Summarize
```typescript
import { summarize } from "@tanstack/ai";
@@ -423,11 +425,14 @@ import { cloudflareSummarize } from "@tanstack/ai-cloudflare";
const result = await summarize({
adapter: cloudflareSummarize("@cf/zai-org/glm-5.3-flash"),
text: "Long article text...",
+ threadId: "thread-1",
stream: false,
});
+
+console.log(result.summary);
```
-Embeddings:
+### Embeddings
```typescript
import { embed } from "@tanstack/ai";
@@ -437,10 +442,11 @@ const result = await embed({
adapter: cloudflareEmbedding("@cf/baai/bge-m3"),
input: ["a story about a llama", "a story about a cloud"],
});
-// result.embeddings[0].vector
+
+console.log(result.embeddings[0]?.vector);
```
-Images:
+### Images
```typescript
import { generateImage } from "@tanstack/ai";
@@ -449,12 +455,14 @@ import { cloudflareImage } from "@tanstack/ai-cloudflare";
const result = await generateImage({
adapter: cloudflareImage("@cf/black-forest-labs/flux-1-schnell"),
prompt: "a red bicycle on a beach",
+ threadId: "thread-1",
modelOptions: { steps: 4 },
});
-// result.images[0].b64Json
+
+console.log(result.images[0]?.b64Json);
```
-Text to speech:
+### Text to speech
```typescript
import { generateSpeech } from "@tanstack/ai";
@@ -463,12 +471,14 @@ import { cloudflareTTS } from "@tanstack/ai-cloudflare";
const result = await generateSpeech({
adapter: cloudflareTTS("@cf/deepgram/aura-2-en"),
text: "Hello from the edge.",
+ threadId: "thread-1",
voice: "luna",
});
-// result.audio is base64 MP3
+
+console.log(result.audio); // base64 MP3
```
-Transcription:
+### Transcription
```typescript
import { generateTranscription } from "@tanstack/ai";
@@ -478,19 +488,93 @@ import { audioBuffer } from "./audio";
const result = await generateTranscription({
adapter: cloudflareTranscription("@cf/openai/whisper-large-v3-turbo"),
audio: audioBuffer,
+ threadId: "thread-1",
});
-// result.text, result.segments, result.words
+
+console.log(result.text, result.segments, result.words);
```
-Whisper models take the audio as base64. `@cf/deepgram/nova-3` takes the raw bytes. The adapter handles both, so pass a `File`, `Blob`, `ArrayBuffer`, or base64 string.
+Pass the audio as a `File`, a `Blob`, an `ArrayBuffer`, or a base64 string. Whisper models take base64, and `@cf/deepgram/nova-3` takes the raw bytes. The adapter sends each model the format that it needs.
-## Environment variables
+## Models and options
-For the REST path:
+### Models
-```bash
-CLOUDFLARE_ACCOUNT_ID=your-account-id
-CLOUDFLARE_API_TOKEN=your-api-token
+These ids work:
+
+- Any id from the [Workers AI catalog](https://developers.cloudflare.com/workers-ai/models/).
+- Any `provider/model` id from the [AI Gateway catalog](https://developers.cloudflare.com/ai/models/), when a gateway is set.
+
+Ids in the bundled catalog get editor autocomplete.
+
+### Model options
+
+Sampling options go in `modelOptions`. The thinking level goes in `reasoning`:
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { cloudflareText } from "@tanstack/ai-cloudflare";
+
+const stream = chat({
+ adapter: cloudflareText("@cf/zai-org/glm-5.3-flash"),
+ messages: [{ role: "user", content: "Summarize this in one sentence." }],
+ threadId: "thread-1",
+ modelOptions: {
+ temperature: 0.3,
+ max_tokens: 512,
+ },
+ reasoning: "low",
+});
```
-Get the account id from `wrangler whoami`. Create the token in the Cloudflare dashboard under Workers AI, then Use REST API.
+### Reasoning
+
+`reasoning` goes out as `reasoning_effort`. `off` sends `null`, which turns thinking off. Reasoning models stream their thinking as `reasoning_content`, which you get as `REASONING_*` events.
+
+Some models and their levels:
+
+| Model | Levels |
+| --- | --- |
+| `@cf/zai-org/glm-5.3`, `@cf/zai-org/glm-5.3-flash` | `low`, `high`, `max` |
+| `@cf/deepseek-ai/deepseek-v4-pro-0813`, `@cf/deepseek-ai/deepseek-v4-flash-0731` | `off`, `low`, `high`, `max` |
+| `@cf/qwen/qwen3.8-27b` | `off`, `low`, `medium`, `xhigh` |
+| `@cf/openai/gpt-oss-120b`, `@cf/openai/gpt-oss-20b` | `low`, `medium`, `high` |
+
+The types know the levels of each model. A model with no reasoning data takes no `reasoning`. See [Reasoning](../chat/reasoning).
+
+## Advanced
+
+### Client options on the REST path
+
+- The text and summarize adapters also take the OpenAI SDK client options: `baseURL`, `defaultHeaders`, `fetch`, `timeout`, and `maxRetries`.
+- The other adapters call the native `/ai/run` endpoint and take only `fetch`.
+
+### Change the HTTP requests
+
+Need a header on each request, or a log of each model call? The chat adapter supports [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call) on the REST path. In binding mode, the wrapper still runs. But the binding reads only the request body, so it drops the URL and the headers that the wrapper sets.
+
+### Tools and prompts added during a conversation
+
+Two kinds of adapter send the full tools and system prompts on every call:
+
+- An adapter with a gateway `baseURL` from `cloudflareGateway()`.
+- An adapter with `cloudflareBindingFetch`.
+
+This is also true on a model with a [mid-conversation channel](../advanced/mid-conversation-changes). So AI Gateway gets the same request as on any other model. To send tools and prompts added during a conversation through the channel, set `midConversationChannels: true`:
+
+```typescript
+import { createAnthropicChat } from "@tanstack/ai-anthropic";
+import { cloudflareBindingFetch } from "@tanstack/ai-cloudflare";
+import type { Ai } from "@cloudflare/workers-types";
+
+export function gatewayClaude(env: { AI: Ai }) {
+ return createAnthropicChat("claude-opus-5-5", "cloudflare-binding", {
+ fetch: cloudflareBindingFetch({
+ binding: env.AI,
+ vendor: "anthropic",
+ gateway: { id: "default" },
+ }),
+ midConversationChannels: true,
+ });
+}
+```
diff --git a/docs/adapters/codex.md b/docs/adapters/codex.md
index e9c3f90299..e41da7982e 100644
--- a/docs/adapters/codex.md
+++ b/docs/adapters/codex.md
@@ -2,7 +2,7 @@
title: Codex
id: codex-adapter
order: 12
-description: "Use OpenAI Codex as a chat backend in TanStack AI — agent harness with local tool execution, stateful coding sessions, and tool bridging via @tanstack/ai-codex."
+description: "Run OpenAI's Codex agent inside a sandbox from TanStack AI. It runs shell commands and edits files in a real repo, and streams each step to your UI."
keywords:
- tanstack ai
- codex
@@ -14,129 +14,182 @@ keywords:
- adapter
---
-The Codex adapter runs [OpenAI Codex](https://developers.openai.com/codex) (via the `@openai/codex-sdk`) as a chat backend. Unlike HTTP provider adapters, this is a **harness adapter**: Codex runs its own agent loop and executes its own tools — shell commands, file changes, web search — locally on your server, inside its sandbox. Each `chat()` call runs one full harness turn; the harness's tool activity streams back as already-resolved tool-call events your UI can render.
+The Codex adapter runs the `codex` CLI in a sandbox and streams each step to `chat()`. The agent runs shell commands and edits files in a repo.
-> **Server-only.** The harness spawns the Codex runtime (bundled with the SDK) as a subprocess, so this adapter only works in a Node.js server environment — never in the browser. The sandbox mode is the safety boundary; configure it deliberately.
+You need:
-## Installation
+- A sandbox provider. The samples use Docker, so Docker must run on your machine.
+- A `CODEX_API_KEY`, or a `codex login` on the machine.
+- The `codex` CLI inside the sandbox. A setup step in the sandbox installs it.
+
+Do you only want a GPT chat, with no repo and no shell? Use the [OpenAI adapter](./openai).
+
+## 1. Install
-react: @tanstack/ai-codex
-vue: @tanstack/ai-codex
-solid: @tanstack/ai-codex
-svelte: @tanstack/ai-codex
-preact: @tanstack/ai-codex
-angular: @tanstack/ai-codex
-vanilla: @tanstack/ai-codex
-octane: @tanstack/ai-codex
+react: @tanstack/ai-codex @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
+vue: @tanstack/ai-codex @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
+solid: @tanstack/ai-codex @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
+svelte: @tanstack/ai-codex @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
+preact: @tanstack/ai-codex @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
+angular: @tanstack/ai-codex @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
+vanilla: @tanstack/ai-codex @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
+octane: @tanstack/ai-codex @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
-A runnable demo lives at [`examples/sandbox-cloudflare`](https://github.com/TanStack/ai/tree/main/examples/sandbox-cloudflare) — pick Claude Code, Codex, or Grok Build in the UI, with session resume, the harness tool timeline, and tool bridging, wired into a TanStack Start app on Workers. For the same wiring on plain Node with durable, refresh-surviving runs (Claude Code on Docker), see [`examples/sandbox-web`](https://github.com/TanStack/ai/tree/main/examples/sandbox-web) — swapping in this adapter is a one-line change (`src/sandbox-agent.ts`).
+## 2. Set the API key
-## Authentication
+Put your OpenAI API key in the environment of your server, with the name `CODEX_API_KEY`:
-Your laptop can already have `codex login`. A CI runner only has
-`CODEX_API_KEY`. The default `authMode` is `'api-key'`. Set `'host'` when
-you want `codex login`. See [Harness Auth](../sandbox/auth).
-
-```ts
-import { codexText } from "@tanstack/ai-codex"
-
-codexText("gpt-5.5")
-codexText("gpt-5.5", { authMode: "host" })
+```bash
+CODEX_API_KEY=sk-...
```
-- `'api-key'` (default): expect `CODEX_API_KEY` (or pass `apiKey`).
-- `'host'`: use `codex login`. Do not inject `CODEX_API_KEY`.
+The sandbox in step 3 gives this key to the `codex` process as a workspace secret. `codex exec` signs in with `CODEX_API_KEY`. A bare `OPENAI_API_KEY` is not enough.
-## Basic Usage
+Did the machine already run `codex login`? Then you can use that login and no key. See [Harness Auth](../sandbox/auth).
-```typescript
-import { chat } from "@tanstack/ai";
-import { codexText } from "@tanstack/ai-codex";
+## 3. Run the agent from the server
-const stream = chat({
- adapter: codexText("gpt-5.1-codex", {
- cwd: "/path/to/project",
- sandboxMode: "workspace-write",
+Define the sandbox once. It clones your repo into a Docker container and installs the `codex` CLI:
+
+```ts
+// sandbox.ts
+import {
+ createSecrets,
+ defineSandbox,
+ defineWorkspace,
+ githubRepo,
+} from "@tanstack/ai-sandbox";
+import { dockerSandbox } from "@tanstack/ai-sandbox-docker";
+
+export const sandbox = defineSandbox({
+ id: "codex-agent",
+ provider: dockerSandbox({ image: "node:22" }),
+ workspace: defineWorkspace({
+ source: githubRepo({ repo: "owner/app" }),
+ setup: ["npm install -g @openai/codex --include=optional && codex --version"],
+ secrets: createSecrets({
+ CODEX_API_KEY: process.env.CODEX_API_KEY ?? "",
+ }),
}),
- messages: [{ role: "user", content: "Fix the failing test in utils.test.ts" }],
});
```
-## Configuration
-
-| Option | Description |
-| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
-| `cwd` | Working directory for the harness session. Defaults to `process.cwd()`. |
-| `sandboxMode` | Codex sandbox: `'read-only'`, `'workspace-write'`, or `'danger-full-access'`. Default is `'workspace-write'` on local-process and Docker. Default is `'danger-full-access'` on Daytona and Cloudflare, because those providers cannot create a nested bubblewrap namespace. Isolation is then the outer VM plus `defineSandboxPolicy`. |
-| `approvalPolicy` | Codex approval policy. Defaults to `'never'` — headless runs have no approval UI, so anything else can stall a turn. |
-| `modelReasoningEffort` | The default effort when a call sets no `reasoning`: `'minimal'` \| `'low'` \| `'medium'` \| `'high'`. |
-| `skipGitRepoCheck` | Skip the harness's git-repo safety check. Defaults to `true` (server adapters routinely point at scratch directories). |
-| `networkAccessEnabled` | Allow network access inside the `workspace-write` sandbox. |
-| `webSearchMode` | `'disabled'` \| `'cached'` \| `'live'`. |
-| `additionalDirectories`| Extra writable directories beyond `cwd`. |
-| `authMode` | `'api-key'` (default) expects `CODEX_API_KEY`. `'host'` uses `codex login`. See [Harness Auth](../sandbox/auth). |
-| `apiKey` | OpenAI API key for the harness subprocess. |
-| `baseUrl` | Override the Codex backend base URL. |
-| `codexPathOverride` | Use a specific codex executable instead of the SDK's bundled binary. |
-| `env` | Environment variables for the subprocess. When set, `process.env` is **not** inherited (Codex SDK semantics). |
-| `config` | Extra `--config key=value` overrides passed to the Codex CLI (e.g. additional `mcp_servers` entries). |
-
-Per-call overrides go through `modelOptions`: `sessionId`, `sandboxMode`,
-`approvalPolicy`, `workingDirectory`, `skipGitRepoCheck`, and `authMode`.
-Set the effort per call with `reasoning` on `chat()`. See [Reasoning](#reasoning).
-
-## Reasoning
+Then pass the adapter and the sandbox to `chat()` in your route:
-Set `reasoning` on `chat()`. Codex gets it as `model_reasoning_effort`:
-
-```typescript
-import { chat } from "@tanstack/ai";
+```ts
+// api/agent.ts
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
import { codexText } from "@tanstack/ai-codex";
+import { withSandbox } from "@tanstack/ai-sandbox";
+import { sandbox } from "./sandbox";
-const stream = chat({
- adapter: codexText("gpt-5.3-codex"),
- messages: [{ role: "user", content: "Fix the failing test in utils.test.ts" }],
- reasoning: "xhigh",
-});
+export async function POST(request: Request) {
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
+
+ const stream = chat({
+ adapter: codexText("gpt-5.3-codex"),
+ messages,
+ threadId,
+ runId,
+ middleware: [withSandbox(sandbox)],
+ });
+
+ return toServerSentEventsResponse(stream);
+}
```
-- `gpt-5.3-codex`: `off`, `low` to `xhigh`. `gpt-5.2-codex`: `low` to `xhigh`.
-- `gpt-5.1-codex`, `gpt-5.1-codex-mini`: `low`, `medium`, `high`. `gpt-5.1`: `off`, `low`, `medium`, `high`.
-- A call without `reasoning` uses `modelReasoningEffort` from the config.
+Replace `owner/app` with your repo.
-## Stateful Sessions
+## 4. Render the agent's work
-Codex threads are stateful — the harness keeps the full working context (files read, commands run, conclusions reached) between turns. The adapter surfaces the thread id of every fresh run as a custom stream event named `codex.session-id`; thread it back via `modelOptions.sessionId` to resume. When resuming, only the latest user message is sent — the harness already holds the prior context.
+The client is the same as for any chat. Render the text, and show each tool that the agent ran:
-Server endpoint:
+```tsx
+import { useState } from "react";
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
-```typescript
+export function CodingAgent() {
+ const [input, setInput] = useState("");
+ const { messages, sendMessage } = useChat({
+ connection: fetchServerSentEvents("/api/agent"),
+ threadId: "support-chat",
+ });
+
+ return (
+
+ );
+}
+```
+
+Ask it "Fix the failing test in utils.test.ts". You see the agent's text, plus a line for each tool it used: `command_execution`, `file_change`, and more. The agent edited the files inside the container.
+
+## How it works
+
+- `codexText` declares that it needs a sandbox. `withSandbox(sandbox)` gives it one. Without a sandbox, `chat()` fails at the call site.
+- Each `chat()` call runs one full agent turn. Codex runs its own loop and its own tools.
+- The tools of the agent stream back as `tool-call` parts with the results attached. Your code does not run them.
+- Codex also has its own inner sandbox. `sandboxMode` sets it. The outer TanStack sandbox is the real boundary.
+
+To learn more about sandboxes, see [Sandboxes](../sandbox/overview).
+
+## Resume a session
+
+Codex keeps its working context between turns: the files it read, the commands it ran, and what it found. The adapter sends the thread id of each new run as a custom event named `codex.session-id`. Send it back in `modelOptions.sessionId` to continue the thread.
+
+On the server, read the id from the request:
+
+```ts
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from "@tanstack/ai";
import { codexText } from "@tanstack/ai-codex";
+import { withSandbox } from "@tanstack/ai-sandbox";
+import { sandbox } from "./sandbox";
export async function POST(request: Request) {
const params = await chatParamsFromRequest(request);
- // Extra fields the client puts in the connection `body` arrive here.
+ // The client sends the id in `forwardedProps`.
const sessionId =
typeof params.forwardedProps.sessionId === "string"
? params.forwardedProps.sessionId
: undefined;
const stream = chat({
- adapter: codexText("gpt-5.1-codex", {
- cwd: "/path/to/project",
- sandboxMode: "workspace-write",
- }),
+ adapter: codexText("gpt-5.3-codex"),
messages: params.messages,
+ threadId: params.threadId,
+ runId: params.runId,
+ middleware: [withSandbox(sandbox)],
modelOptions: { sessionId },
});
@@ -144,20 +197,19 @@ export async function POST(request: Request) {
}
```
-Client (React) — capture the session id from the custom event and send it back on subsequent requests:
+On the client, keep the id from the custom event and pass it to `forwardedProps`. `useChat` sends the latest value with each request:
-```typescript
+```tsx
import { useState } from "react";
-import { useChat } from "@tanstack/ai-react";
-import { fetchServerSentEvents } from "@tanstack/ai-client";
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
-function CodingAssistant() {
+export function useCodingAgent() {
const [sessionId, setSessionId] = useState(undefined);
- const { messages, sendMessage } = useChat({
- connection: fetchServerSentEvents("/api/chat", () => ({
- body: { sessionId },
- })),
+ return useChat({
+ connection: fetchServerSentEvents("/api/agent"),
+ forwardedProps: { sessionId },
+ threadId: "support-chat",
onCustomEvent: (name, value) => {
if (
name === "codex.session-id" &&
@@ -170,26 +222,22 @@ function CodingAssistant() {
}
},
});
-
- // ... render messages; harness tool activity (command_execution,
- // file_change, ...) arrives as regular tool-call parts with results.
}
```
-Sessions are stored on the machine that ran them (`~/.codex/sessions/`), so resuming only works on the same server instance.
-
-## Tools
+- On a resumed thread, the adapter sends only the latest user message. The agent already has the rest.
+- Codex saves the thread inside the sandbox. Resume works only when the next call runs in the same sandbox. See [Lifecycle & Snapshots](../sandbox/lifecycle).
-Two kinds of tools flow through this adapter:
+## Give the agent your own tools
-1. **Built-in harness tools** are executed by Codex itself and stream back as tool-call events with results already attached: `command_execution` (shell), `file_change` (patches), `web_search`, and `todo_list` (the agent's running plan). Your code never executes them.
+The agent has its own tools for files and the shell. It cannot reach your database or your secrets. Pass your server tools to `chat()`, and the adapter bridges them into the sandbox:
-2. **Your TanStack tools** are bridged *into* the harness: the adapter starts a short-lived Streamable-HTTP MCP server on `127.0.0.1` for the duration of the turn and points Codex at it. Define tools as usual with `toolDefinition().server()`; tool-call events come back under the names you registered.
-
-```typescript group=tools
+```ts group=tools
import { z } from "zod";
import { chat, toolDefinition } from "@tanstack/ai";
import { codexText } from "@tanstack/ai-codex";
+import { withSandbox } from "@tanstack/ai-sandbox";
+import { sandbox } from "./sandbox";
const lookupTicket = toolDefinition({
name: "lookup_ticket",
@@ -200,24 +248,32 @@ const lookupTicket = toolDefinition({
});
const stream = chat({
- adapter: codexText("gpt-5.1-codex"),
+ adapter: codexText("gpt-5.3-codex"),
messages: [{ role: "user", content: "What's the status of ticket T-123?" }],
+ threadId: "thread-1",
tools: [lookupTicket],
+ middleware: [withSandbox(sandbox)],
});
```
-**Client-side and approval-gated tools are not supported.** The harness executes tools inside a live subprocess, which cannot pause across HTTP requests to wait for a browser round-trip or a human approval. Passing a tool without a server `execute()` implementation — or one marked `needsApproval` — fails fast with a descriptive error. Run those tools outside the harness with a regular provider adapter.
+- Each tool runs on your server, with its closures and secrets. The result goes back into the sandbox.
+- The tool-call events use the names you gave.
+- Only server tools work here. A client tool has no server `execute()`, so the agent cannot run it.
+
+For how the bridge reaches your server from each provider, see [Sandbox Tools](../sandbox/tools).
## Tool choice
-You want Codex to skip your tools, or to see just one of them. Pass `toolChoice` to `chat()`:
+You want Codex to skip your tools, or to see only one of them. Pass `toolChoice` to `chat()`:
-```typescript group=tools
+```ts group=tools
const noBridgedTools = chat({
adapter: codexText("gpt-5.3-codex"),
messages: [{ role: "user", content: "Explain the bug in src/app.ts." }],
+ threadId: "thread-1",
tools: [lookupTicket],
toolChoice: "none",
+ middleware: [withSandbox(sandbox)],
});
```
@@ -234,71 +290,150 @@ Each value except `'auto'` logs a warning the first time an adapter instance get
For the values on other adapters, see [Choose when the model calls a tool](../tools/tools#choose-when-the-model-calls-a-tool).
-## Structured Output
+## Structured output
-Pass `outputSchema` on `chat()`. Codex runs one harness turn and constrains the last message with `--output-schema`. Tool activity and assistant text stream as Codex writes them. The last message is also parsed as the schema object and arrives as `structured-output.complete`.
+You want a typed report after the agent reads the repo. Pass `outputSchema` on the same `chat()` call:
```ts
-import { chat } from "@tanstack/ai"
-import { codexText } from "@tanstack/ai-codex"
-import { defineSandbox, withSandbox } from "@tanstack/ai-sandbox"
-import { dockerSandbox } from "@tanstack/ai-sandbox-docker"
-import { z } from "zod"
+import { chat } from "@tanstack/ai";
+import { codexText } from "@tanstack/ai-codex";
+import { withSandbox } from "@tanstack/ai-sandbox";
+import { z } from "zod";
+import { sandbox } from "./sandbox";
const Report = z.object({
summary: z.string(),
filesChanged: z.array(z.string()),
-})
-
-const sandbox = defineSandbox({
- id: "repo-report",
- provider: dockerSandbox({ image: "node:22" }),
-})
+});
const report = await chat({
adapter: codexText("gpt-5.3-codex"),
messages: [{ role: "user", content: "Review this repo." }],
+ threadId: "thread-1",
outputSchema: Report,
middleware: [withSandbox(sandbox)],
-})
+});
-report.summary
+console.log(report.summary);
```
On the client, pass the same schema to `useChat` and read `final`. `partial` stays empty until the end.
```tsx
-import { fetchServerSentEvents, useChat } from "@tanstack/ai-react"
-import { z } from "zod"
+import { fetchServerSentEvents, useChat } from "@tanstack/ai-react";
+import { z } from "zod";
const Report = z.object({
summary: z.string(),
filesChanged: z.array(z.string()),
-})
+});
-function ReportView() {
+export function ReportView() {
const { final, isLoading } = useChat({
connection: fetchServerSentEvents("/api/repo-report"),
+ threadId: "support-chat",
outputSchema: Report,
- })
+ });
- if (isLoading) return
The agent is inspecting the repo.
- if (!final) return null
- return
{final.summary}
+ if (isLoading) return
The agent is inspecting the repo.
;
+ if (!final) return null;
+ return
{final.summary}
;
}
```
-If you only need to extract JSON from a prompt and do not need a sandbox, use `@tanstack/ai-openai`. That path is faster.
+- Codex runs one turn and limits its last message with `--output-schema`.
+- Tool activity and text stream as Codex writes them.
+- The adapter parses the last message as the schema object. It arrives as `structured-output.complete`.
+
+Do you only need JSON from a prompt, with no repo? Use `@tanstack/ai-openai`. That is faster. For the full walkthrough, see [Harness Agents](../structured-outputs/harnesses).
+
+## Models and options
+
+### Models
+
+| Model | Reasoning levels |
+| --- | --- |
+| `gpt-5.3-codex` | `off`, `low`, `medium`, `high`, `xhigh` |
+| `gpt-5.2-codex` | `low`, `medium`, `high`, `xhigh` |
+| `gpt-5.1-codex`, `gpt-5.1-codex-mini` | `low`, `medium`, `high` |
+| `gpt-5.1` | `off`, `low`, `medium`, `high` |
+
+The package exports this list as `CODEX_MODELS`. The adapter also takes any other model id that Codex accepts, as a string.
+
+### Reasoning
+
+Set `reasoning` on `chat()`. Codex gets it as `model_reasoning_effort`:
+
+```ts
+import { chat } from "@tanstack/ai";
+import { codexText } from "@tanstack/ai-codex";
+import { withSandbox } from "@tanstack/ai-sandbox";
+import { sandbox } from "./sandbox";
+
+const stream = chat({
+ adapter: codexText("gpt-5.3-codex"),
+ messages: [{ role: "user", content: "Fix the failing test in utils.test.ts" }],
+ threadId: "thread-1",
+ reasoning: "xhigh",
+ middleware: [withSandbox(sandbox)],
+});
+```
+
+A call with no `reasoning` uses `modelReasoningEffort` from the adapter options. See [Reasoning](../chat/reasoning).
+
+### Adapter options
+
+Pass these as the second argument of `codexText`:
+
+| Option | What it does |
+| --- | --- |
+| `cwd` | The working directory inside the sandbox. Default `/workspace`. |
+| `sandboxMode` | The inner Codex sandbox: `'read-only'`, `'workspace-write'`, or `'danger-full-access'`. See [Inner sandbox mode](#inner-sandbox-mode). |
+| `approvalPolicy` | `'never'` (default), `'on-failure'`, `'on-request'`, or `'untrusted'`. A headless run has no approval UI, so another value can stop a turn. |
+| `modelReasoningEffort` | The effort when a call sets no `reasoning`: `'minimal'`, `'low'`, `'medium'`, or `'high'`. |
+| `skipGitRepoCheck` | Skip the git repo check of Codex. Default `true`. |
+| `networkAccessEnabled` | Allow network access inside the `workspace-write` sandbox. |
+| `webSearchMode` | `'disabled'` or `'live'`. |
+| `additionalDirectories` | More writable directories next to `cwd`. |
+| `authMode` | `'api-key'` (default) expects `CODEX_API_KEY`. `'host'` uses `codex login`. See [Harness Auth](../sandbox/auth). |
+| `codexExecutable` | The path or name of the `codex` executable in the sandbox. Default `codex`. |
+| `env` | More environment variables for the `codex` process. |
+| `config` | More `--config key=value` overrides for the Codex CLI, for example more `mcp_servers` entries. Values go through as TOML. |
+
+### Per-call options
+
+Pass these in `modelOptions` on `chat()`. They override the adapter options for one call:
+
+- `sessionId`: resume a thread. See [Resume a session](#resume-a-session).
+- `sandboxMode`, `approvalPolicy`, `skipGitRepoCheck`.
+- `workingDirectory`: the working directory for this call.
+- `authMode`: `'host'` or `'api-key'`.
+
+## Advanced
+
+### Inner sandbox mode
+
+The default `sandboxMode` depends on the provider:
+
+- Local-process and Docker: `'workspace-write'`.
+- Daytona and Cloudflare: `'danger-full-access'`. These providers cannot create a nested bubblewrap namespace. The outer VM and your sandbox [Policy](../sandbox/policy) are then the isolation.
+
+The inner Codex sandbox does not work on Windows and on some Linux images. There it fails with `os error 2`. If you see this error, set `sandboxMode: 'danger-full-access'`. The outer sandbox is still the boundary.
+
+### Text streaming
+
+Codex reports text as items that grow. The adapter streams each new piece of an item, so text can arrive in larger pieces than with an HTTP adapter. Tool activity, such as a command that starts or ends, streams live.
-Full walkthrough, including the client: [Harness Agents](../structured-outputs/harnesses).
+### Limitations
-## Limitations
+- **A sandbox is required.** Every call needs `withSandbox(...)`.
+- **The agent owns the loop.** TanStack agent-loop strategies and per-iteration middleware do not apply inside a turn.
+- **No sampling options.** Options such as `temperature` do not exist here.
+- **Sessions stay in the sandbox.** Resume needs the same sandbox.
+- **Cold starts.** Each call starts a CLI turn, so the first token arrives later than with an HTTP adapter.
+- **No `wrapFetch`.** This adapter ignores [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call), because the `codex` process sends the model requests.
-- **No token-level text streaming.** The Codex SDK reports assistant text and reasoning only as completed items, so text arrives message-at-a-time. Tool activity (commands starting/finishing) still streams live, which keeps the UI feeling alive during long turns.
-- **Server-only (Node).** The harness spawns a subprocess.
-- **The harness owns the agent loop.** TanStack's agent-loop strategies and per-iteration middleware don't apply inside a harness turn.
-- **No sampling controls.** `temperature`-style options don't exist here.
-- **Sessions are machine-local.** Resume requires hitting the same server instance.
-- **Cold starts.** Each call spawns a harness turn; expect higher first-token latency than HTTP adapters.
+### Example apps
-This adapter ignores [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call), because the harness process sends the model requests.
+- [`examples/sandbox-cloudflare`](https://github.com/TanStack/ai/tree/main/examples/sandbox-cloudflare): a TanStack Start app on Workers. You pick Claude Code, Codex, or Grok Build in the UI. It shows session resume, the tool timeline, and tool bridging.
+- [`examples/sandbox-web`](https://github.com/TanStack/ai/tree/main/examples/sandbox-web): Claude Code on Docker, with durable runs that continue after a page refresh. To use Codex there, change the adapter in `src/sandbox-agent.ts`.
diff --git a/docs/adapters/cohere.md b/docs/adapters/cohere.md
index ce13f129c3..17301998cb 100644
--- a/docs/adapters/cohere.md
+++ b/docs/adapters/cohere.md
@@ -2,7 +2,7 @@
title: Cohere
id: cohere-adapter
order: 11
-description: "Use Cohere with TanStack AI via @tanstack/ai-cohere: embed-v4.0 multimodal embeddings for semantic search and RAG, plus rerank models for relevance."
+description: "Use Cohere in TanStack AI with @tanstack/ai-cohere: embed-v4.0 multimodal embeddings for semantic search and RAG, and rerank models to sort results by relevance."
keywords:
- tanstack ai
- cohere
@@ -16,14 +16,11 @@ keywords:
- adapter
---
-The Cohere adapter covers the two retrieval steps of a RAG pipeline:
+The Cohere adapter embeds text and images, and it reranks documents for a query. This package has no `chat()`, `summarize()`, or media generation.
-- **Embeddings** (`cohereEmbedding`): turn text, images, and fused text+image inputs into vectors with `embed()`.
-- **Reranking** (`cohereRerank`): reorder candidate documents by relevance to a query with `rerank()`.
+For those, use [OpenAI](./openai), [Anthropic](./anthropic), or [Gemini](./gemini). The adapter calls the HTTP API of Cohere with `fetch`, with no SDK dependency.
-It does not support `chat()`, `summarize()`, or media generation. Use OpenAI, Anthropic, or Gemini for those. The adapter talks to Cohere's HTTP API directly over `fetch`, with no SDK dependency.
-
-## Installation
+## 1. Install
@@ -38,7 +35,17 @@ octane: @tanstack/ai @tanstack/ai-cohere
-## Embeddings
+## 2. Set the API key
+
+Get a key from the [Cohere dashboard](https://dashboard.cohere.com/api-keys). Put it in the environment of your server:
+
+```bash
+COHERE_API_KEY=your-cohere-api-key
+```
+
+`cohereEmbedding` and `cohereRerank` read `COHERE_API_KEY`. To pass the key in code, see [Explicit API keys](#explicit-api-keys).
+
+## 3. Embed documents
```typescript
import { embed } from "@tanstack/ai";
@@ -54,11 +61,37 @@ console.log(result.embeddings[0]?.vector);
console.log(result.usage?.promptTokens);
```
-`inputType` is required by Cohere's API. Use `search_document` at index time and `search_query` at query time (or `classification` / `clustering` for those workloads). TanStack AI enforces this at the type level, so `modelOptions` is required for Cohere embedding calls.
+The Cohere API requires `inputType`, and the types require `modelOptions` on every Cohere embedding call:
-### Multimodal Embeddings
+- `search_document`: when you index documents.
+- `search_query`: when you embed the search query.
+- `classification` or `clustering`: for those workloads.
-embed-v4.0 embeds images alongside text. An image part produces an image vector. A nested array of parts (`[textPart, imagePart]`) fuses text and image into one vector, which suits product catalogs and screenshot search. The outer array is the item list, so nest to fuse:
+## 4. Rerank results
+
+```typescript
+import { rerank } from "@tanstack/ai";
+import { cohereRerank } from "@tanstack/ai-cohere";
+
+const { rerankedDocuments } = await rerank({
+ adapter: cohereRerank("rerank-v3.5"),
+ query: "talk about rain",
+ documents: ["sunny day at the beach", "rainy afternoon in the city"],
+});
+
+console.log(rerankedDocuments[0]); // 'rainy afternoon in the city'
+```
+
+You now have both retrieval steps. For object documents, RAG pipelines, the options, and the result shape, see [Reranking](../rerank/rerank).
+
+## Multimodal embeddings
+
+embed-v4.0 embeds images next to text:
+
+- An image part gives an image vector.
+- A nested array of parts (`[textPart, imagePart]`) joins text and image into one vector. Use it for product catalogs and screenshot search.
+
+The outer array is the list of items, so nest the parts to join them:
```typescript
import { embed } from "@tanstack/ai";
@@ -96,27 +129,11 @@ const result = await embed({
console.log(result.embeddings.length); // 2
```
-Cohere's API does not fetch remote image URLs. Pass base64 data (or a `data:` URI), or opt into adapter-side downloading:
-
-```typescript
-import { embed } from "@tanstack/ai";
-import { cohereEmbedding } from "@tanstack/ai-cohere";
-
-const adapter = cohereEmbedding("embed-v4.0", { allowUrlFetch: true });
-
-const result = await embed({
- adapter,
- input: {
- type: "image",
- source: { type: "url", value: "https://example.com/guitar.png" },
- },
- modelOptions: { inputType: "search_document" },
-});
-```
+The Cohere API does not fetch remote image URLs. Pass base64 data (or a `data:` URI), or turn on [downloads in the adapter](#image-urls).
-### Requesting Dimensions
+## Shorter vectors
-embed-v4.0 supports Matryoshka output dimensions via the top-level `dimensions` option:
+embed-v4.0 takes the top-level `dimensions` option (`256`, `512`, `1024`, or `1536`):
```typescript
import { embed } from "@tanstack/ai";
@@ -130,24 +147,9 @@ const result = await embed({
});
```
-## Reranking
-
-```typescript
-import { rerank } from "@tanstack/ai";
-import { cohereRerank } from "@tanstack/ai-cohere";
-
-const { rerankedDocuments } = await rerank({
- adapter: cohereRerank("rerank-v3.5"),
- query: "talk about rain",
- documents: ["sunny day at the beach", "rainy afternoon in the city"],
-});
-
-console.log(rerankedDocuments[0]); // 'rainy afternoon in the city'
-```
-
-For the full reranking guide, with object documents, RAG pipelines, options, and the result shape, see [Reranking](../rerank/rerank).
+## Rerank options
-Per-request rerank options go on `modelOptions`:
+Rerank options for one request go in `modelOptions`:
```typescript
import { rerank } from "@tanstack/ai";
@@ -167,30 +169,18 @@ console.log(ranking);
## Models
-| Model | Capability | Description |
-| -------------------------- | ---------- | ----------------------------------------------------------------- |
-| `embed-v4.0` | Embeddings | Multimodal (text + images), Matryoshka `dimensions` support |
-| `rerank-v3.5` | Reranking | Latest multilingual reranker (recommended) |
-| `rerank-english-v3.0` | Reranking | English-optimized reranker |
-| `rerank-multilingual-v3.0` | Reranking | Multilingual reranker |
+| Model | Activity | Notes |
+| --- | --- | --- |
+| `embed-v4.0` | Embeddings | Text and images, `dimensions` support |
+| `rerank-v3.5` | Reranking | Newest multilingual reranker. Recommended |
+| `rerank-english-v3.0` | Reranking | Tuned for English |
+| `rerank-multilingual-v3.0` | Reranking | Multilingual reranker |
-## Environment Variables
+## Advanced
-Both adapters read your API key from the environment:
+### Explicit API keys
-```bash
-COHERE_API_KEY=your-cohere-api-key
-```
-
-| Variable | Required | Description |
-| ---------------- | -------- | ------------------- |
-| `COHERE_API_KEY` | Yes | Your Cohere API key |
-
-Get a key from the [Cohere dashboard](https://dashboard.cohere.com/api-keys).
-
-## Explicit API Keys
-
-To pass a key directly instead of reading the environment, use the `create*` factories:
+To pass a key in code, not from the environment, use the `create*` factories:
```typescript
import {
@@ -205,9 +195,29 @@ const embedAdapter = createCohereEmbedding(
const rerankAdapter = createCohereRerank("rerank-v3.5", "your-cohere-api-key");
```
-## Behind a proxy
+### Image URLs
-Route every request through a gateway, such as Cloudflare AI Gateway or a corporate proxy, with `baseURL` and `defaultHeaders`. These two option names are the same on every TanStack AI adapter, so one gateway config works for all of them.
+To let the adapter download `http(s)` image URLs and send them as base64, set `allowUrlFetch`:
+
+```typescript
+import { embed } from "@tanstack/ai";
+import { cohereEmbedding } from "@tanstack/ai-cohere";
+
+const adapter = cohereEmbedding("embed-v4.0", { allowUrlFetch: true });
+
+const result = await embed({
+ adapter,
+ input: {
+ type: "image",
+ source: { type: "url", value: "https://example.com/guitar.png" },
+ },
+ modelOptions: { inputType: "search_document" },
+});
+```
+
+### Behind a proxy
+
+Send every request through a gateway, such as Cloudflare AI Gateway or a corporate proxy, with `baseURL` and `defaultHeaders`. These two option names are the same on every TanStack AI adapter, so one gateway config works for all of them.
```typescript
import { createCohereEmbedding } from "@tanstack/ai-cohere";
@@ -220,35 +230,19 @@ const adapter = createCohereEmbedding("embed-v4.0", process.env.COHERE_API_KEY!,
`baseUrl` and `headers` are aliases of the same two options. If you set both forms, `baseURL` and `defaultHeaders` win.
-## API Reference
+### API reference
-### `cohereEmbedding(model, config?)`
+| Factory | Creates |
+| --- | --- |
+| `cohereEmbedding(model, config?)` | Embedding adapter. Reads `COHERE_API_KEY` |
+| `createCohereEmbedding(model, apiKey, config?)` | Embedding adapter with an explicit API key |
+| `cohereRerank(model, config?)` | Rerank adapter. Reads `COHERE_API_KEY` |
+| `createCohereRerank(model, apiKey, config?)` | Rerank adapter with an explicit API key |
-Creates an embedding adapter using `COHERE_API_KEY` from the environment.
+Config for both adapters:
-- `model`: `"embed-v4.0"`
-- `config.baseURL`: override the API base URL (default `https://api.cohere.com`). `baseUrl` is an alias.
+- `config.baseURL`: the API base URL (default `https://api.cohere.com`). `baseUrl` is an alias.
- `config.defaultHeaders`: extra request headers. `headers` is an alias.
-- `config.allowUrlFetch`: download `http(s)` image URLs and inline them as base64 (default `false`)
-
-### `createCohereEmbedding(model, apiKey, config?)`
-
-Same as `cohereEmbedding` with an explicit API key.
-
-### `cohereRerank(model, config?)`
-
-Creates a rerank adapter using `COHERE_API_KEY` from the environment.
-
-- `model`: one of the rerank models above
-- `config.baseURL`: override the API base URL (default `https://api.cohere.com`). `baseUrl` is an alias.
-- `config.defaultHeaders`: extra request headers. `headers` is an alias.
-
-### `createCohereRerank(model, apiKey, config?)`
-
-Same as `cohereRerank` with an explicit API key.
-
-## Next Steps
+- `config.allowUrlFetch` (embeddings only): download `http(s)` image URLs and send them as base64. Default `false`.
-- [Embeddings guide](../embeddings.md): the full `embed()` API
-- [Reranking guide](../rerank/rerank): full walkthrough including RAG pipelines
-- [Generation Hooks](../media/generation-hooks.md): usage and lifecycle middleware
+For the full APIs, see the [Embeddings guide](../embeddings.md), the [Reranking guide](../rerank/rerank), and [Generation Hooks](../media/generation-hooks.md) for usage and lifecycle middleware.
diff --git a/docs/adapters/elevenlabs.md b/docs/adapters/elevenlabs.md
index 17d3ecbf0d..a2454e109e 100644
--- a/docs/adapters/elevenlabs.md
+++ b/docs/adapters/elevenlabs.md
@@ -2,7 +2,7 @@
title: ElevenLabs
id: elevenlabs-adapter
order: 9
-description: "Build realtime voice-to-voice conversational AI with ElevenLabs agents in TanStack AI via the @tanstack/ai-elevenlabs adapter."
+description: "Use ElevenLabs in TanStack AI with @tanstack/ai-elevenlabs: realtime voice agents, text-to-speech, music and sound effects, transcription, and voice design."
keywords:
- tanstack ai
- elevenlabs
@@ -13,175 +13,114 @@ keywords:
- adapter
---
-The ElevenLabs adapter is **voice-focused**. It exposes five capabilities:
+`@tanstack/ai-elevenlabs` calls ElevenLabs for voice agents, speech, music, sound effects, transcription, and voice design. It has no text chat.
-- **Realtime voice agents** (`elevenlabsRealtime` / `elevenlabsRealtimeToken`) — full-duplex voice-to-voice conversations powered by ElevenLabs Conversational AI agents.
-- **Text-to-speech** (`elevenlabsSpeech`) — one-shot speech generation via `generateSpeech()`.
-- **Music & sound effects** (`elevenlabsAudio`) — one-shot audio generation via `generateAudio()`.
-- **Transcription** (`elevenlabsTranscription`) — speech-to-text via `generateTranscription()`.
-- **Voice design** (`elevenlabsVoiceDesign`): create a new voice via `generateVoice()`.
+The quick setup below builds a live voice conversation with an ElevenLabs agent.
-It does not support text `chat()` or `summarize()` — use OpenAI, Anthropic, or Gemini for those.
-
-The realtime adapter uses an **agent-based architecture** where you configure your conversational AI agent in the [ElevenLabs dashboard](https://elevenlabs.io/) (voice, personality, knowledge base, tools) and then connect to it at runtime. The adapter wraps the `@elevenlabs/client` SDK for seamless integration with `useRealtimeChat` and `RealtimeClient`.
-
-## Installation
+## 1. Install
-react: @tanstack/ai-elevenlabs
-vue: @tanstack/ai-elevenlabs
-solid: @tanstack/ai-elevenlabs
-svelte: @tanstack/ai-elevenlabs
-preact: @tanstack/ai-elevenlabs
-angular: @tanstack/ai-elevenlabs
-vanilla: @tanstack/ai-elevenlabs
-octane: @tanstack/ai-elevenlabs
+react: @tanstack/ai @tanstack/ai-client @tanstack/ai-elevenlabs
+vue: @tanstack/ai @tanstack/ai-client @tanstack/ai-elevenlabs
+solid: @tanstack/ai @tanstack/ai-client @tanstack/ai-elevenlabs
+svelte: @tanstack/ai @tanstack/ai-client @tanstack/ai-elevenlabs
+preact: @tanstack/ai @tanstack/ai-client @tanstack/ai-elevenlabs
+angular: @tanstack/ai @tanstack/ai-client @tanstack/ai-elevenlabs
+vanilla: @tanstack/ai @tanstack/ai-client @tanstack/ai-elevenlabs
+octane: @tanstack/ai @tanstack/ai-client @tanstack/ai-elevenlabs
-Peer dependencies:
+`@tanstack/ai` and `@tanstack/ai-client` are peer dependencies of the adapter. Step 4 also uses the React package, `@tanstack/ai-react`.
-
+## 2. Set the API key and the agent
-react: @tanstack/ai @tanstack/ai-client
-vue: @tanstack/ai @tanstack/ai-client
-solid: @tanstack/ai @tanstack/ai-client
-svelte: @tanstack/ai @tanstack/ai-client
-preact: @tanstack/ai @tanstack/ai-client
-angular: @tanstack/ai @tanstack/ai-client
-vanilla: @tanstack/ai @tanstack/ai-client
-octane: @tanstack/ai @tanstack/ai-client
+1. Get an API key from the [ElevenLabs dashboard](https://elevenlabs.io/).
+2. In the **Conversational AI** section of the dashboard, create an agent. Set its voice, personality, knowledge, and tools there.
+3. Put both values in the environment of your server:
-
+```bash
+ELEVENLABS_API_KEY=your-elevenlabs-api-key
+ELEVENLABS_AGENT_ID=your-agent-id
+```
+
+All adapters read `ELEVENLABS_API_KEY`. If you pass `agentId` in code, `ELEVENLABS_AGENT_ID` is optional.
-## Server Setup
+## 3. Create a voice token on the server
-The server generates a **signed WebSocket URL** so your API key never reaches the client. The signed URL is valid for 30 minutes.
+The browser must not see your API key. So the server makes a signed WebSocket URL, and the browser connects with it. The URL is valid for 30 minutes.
-```typescript group=elevenlabs-1
-import { realtimeToken } from '@tanstack/ai'
-import { elevenlabsRealtimeToken } from '@tanstack/ai-elevenlabs'
+```typescript
+import { realtimeToken } from "@tanstack/ai";
+import { elevenlabsRealtimeToken } from "@tanstack/ai-elevenlabs";
-// In your API route (Express, Hono, TanStack Start, etc.)
export async function POST() {
const token = await realtimeToken({
adapter: elevenlabsRealtimeToken({
agentId: process.env.ELEVENLABS_AGENT_ID!,
}),
- })
+ });
- return Response.json(token)
+ return Response.json(token);
}
```
-### With Overrides
+Serve this handler at `/api/realtime-token`.
-You can override agent settings at token generation time without changing your dashboard configuration:
+## 4. Talk to the agent
-```typescript group=elevenlabs-1
-const token = await realtimeToken({
- adapter: elevenlabsRealtimeToken({
- agentId: process.env.ELEVENLABS_AGENT_ID!,
- overrides: {
- voiceId: 'custom-voice-id',
- systemPrompt: 'You are a helpful voice assistant.',
- firstMessage: 'Hello! How can I help you today?',
- language: 'en',
- },
- }),
-})
-```
-
-## Client Setup
-
-### React (useRealtimeChat)
+`useRealtimeChat` gets the token, opens the microphone, and plays the voice of the agent:
```tsx
-import { useRealtimeChat } from '@tanstack/ai-react'
-import { elevenlabsRealtime } from '@tanstack/ai-elevenlabs'
+import { useRealtimeChat } from "@tanstack/ai-react";
+import { elevenlabsRealtime } from "@tanstack/ai-elevenlabs";
-function VoiceChat() {
- const {
- status,
- mode,
- messages,
- connect,
- disconnect,
- pendingUserTranscript,
- pendingAssistantTranscript,
- inputLevel,
- outputLevel,
- } = useRealtimeChat({
- getToken: () =>
- fetch('/api/realtime-token', { method: 'POST' }).then((r) => r.json()),
- adapter: elevenlabsRealtime(),
- })
+export function VoiceChat() {
+ const { status, messages, connect, disconnect, pendingAssistantTranscript } =
+ useRealtimeChat({
+ getToken: () =>
+ fetch("/api/realtime-token", { method: "POST" }).then((r) => r.json()),
+ adapter: elevenlabsRealtime(),
+ });
return (
-
Status: {status}
-
Mode: {mode}
-
- )
+ );
}
```
-### Non-React (RealtimeClient)
+Click **Start conversation** and speak. The agent answers out loud, and the transcript shows up below the button. For the other frameworks and for the full realtime API, see [Realtime Voice Chat](../media/realtime-chat).
-```typescript
-import { RealtimeClient } from '@tanstack/ai-client'
-import { elevenlabsRealtime } from '@tanstack/ai-elevenlabs'
+## Client tools
-const client = new RealtimeClient({
- getToken: () =>
- fetch('/api/realtime-token', { method: 'POST' }).then((r) => r.json()),
- adapter: elevenlabsRealtime(),
- onMessage: (message) => {
- console.log(`${message.role}:`, message.parts)
- },
- onStatusChange: (status) => {
- console.log('Status:', status)
- },
- onModeChange: (mode) => {
- console.log('Mode:', mode)
- },
-})
-
-await client.connect()
-```
-
-## Client Tools
-
-ElevenLabs supports client-side tools that execute in the browser. Define tools using the standard `toolDefinition()` API:
+The agent can call tools that run in the browser. Define them with `toolDefinition()` and pass them to the hook:
```typescript
-import { toolDefinition } from '@tanstack/ai'
-import { useRealtimeChat } from '@tanstack/ai-react'
-import { elevenlabsRealtime } from '@tanstack/ai-elevenlabs'
-import { z } from 'zod'
+import { toolDefinition } from "@tanstack/ai";
+import { useRealtimeChat } from "@tanstack/ai-react";
+import { elevenlabsRealtime } from "@tanstack/ai-elevenlabs";
+import { z } from "zod";
const getWeatherDef = toolDefinition({
- name: 'getWeather',
- description: 'Get weather for a location',
+ name: "getWeather",
+ description: "Get weather for a location",
inputSchema: z.object({
location: z.string(),
}),
@@ -189,105 +128,28 @@ const getWeatherDef = toolDefinition({
temperature: z.number(),
conditions: z.string(),
}),
-})
+});
const getWeather = getWeatherDef.client(async ({ location }) => {
- const res = await fetch(`/api/weather?location=${location}`)
- return res.json()
-})
-
-// Pass tools to the hook
-const chat = useRealtimeChat({
- getToken: () =>
- fetch('/api/realtime-token', { method: 'POST' }).then((r) => r.json()),
- adapter: elevenlabsRealtime(),
- tools: [getWeather],
-})
-```
-
-Tool results are automatically serialized to strings and returned to the ElevenLabs agent. The adapter converts TanStack tool definitions into the `@elevenlabs/client` clientTools format internally.
-
-## Configuration
-
-### `elevenlabsRealtimeToken` Options
-
-Used on the **server** to generate a signed WebSocket URL.
-
-| Option | Type | Required | Description |
-|--------|------|----------|-------------|
-| `agentId` | `string` | No\* | Agent ID configured in the ElevenLabs dashboard. \*Falls back to `ELEVENLABS_AGENT_ID`; required only if that env var is unset |
-| `overrides.voiceId` | `string` | No | Custom voice ID to override the agent's default voice |
-| `overrides.systemPrompt` | `string` | No | Custom system prompt to override the agent's default |
-| `overrides.firstMessage` | `string` | No | First message the agent speaks when the session starts |
-| `overrides.language` | `string` | No | Language code (e.g., `'en'`, `'es'`, `'fr'`) |
-
-### `elevenlabsRealtime` Options
-
-Used on the **client** to establish the connection.
-
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `connectionMode` | `'websocket' \| 'webrtc'` | auto-detect | Transport protocol for the connection |
-| `debug` | `boolean \| DebugConfig` | `false` | Enable debug logging — pass `true` for all categories, or a `DebugConfig` to select categories/sink |
-
-## Differences from OpenAI Realtime
-
-ElevenLabs and OpenAI take different approaches to realtime voice:
-
-| | ElevenLabs | OpenAI |
-|---|---|---|
-| **Configuration** | Agent-based. Configure voice, personality, and knowledge in the ElevenLabs dashboard or via `overrides` at token time. | Session-based. Configure `instructions`, `voice`, `temperature`, etc. per session via `useRealtimeChat` options. |
-| **Token type** | Signed WebSocket URL (valid 30 minutes) | Ephemeral API token (valid ~10 minutes) |
-| **Transport** | WebSocket (default) or WebRTC | WebRTC |
-| **Audio handling** | `@elevenlabs/client` SDK manages audio capture and playback automatically | TanStack AI manages WebRTC peer connection and audio tracks |
-| **VAD** | Handled by ElevenLabs server-side | Supports `server`, `semantic`, and `manual` modes |
-| **Runtime updates** | Session config is set at creation time and cannot be changed mid-session | Supports `updateSession()` for mid-session config changes |
-| **Image input** | Not supported | Supported via `sendImage()` |
-| **Time domain data** | Not available from the SDK | Available for waveform visualizations |
-
-## Audio Visualization
-
-The ElevenLabs adapter provides audio visualization data through the same interface as other realtime adapters:
-
-```typescript
-import { useRealtimeChat } from '@tanstack/ai-react'
-import { elevenlabsRealtime } from '@tanstack/ai-elevenlabs'
-
-const {
- inputLevel, // 0-1 normalized microphone volume
- outputLevel, // 0-1 normalized speaker volume
- getInputFrequencyData, // Uint8Array frequency spectrum
- getOutputFrequencyData,
-} = useRealtimeChat({
- getToken: () =>
- fetch('/api/realtime-token', { method: 'POST' }).then((r) => r.json()),
- adapter: elevenlabsRealtime(),
-})
-```
-
-**Note:** ElevenLabs provides volume levels and frequency data but does not expose time-domain data. The `getInputTimeDomainData()` and `getOutputTimeDomainData()` methods return static placeholder arrays. The default audio sample rate is 16kHz.
-
-## Environment Variables
-
-Set these in your server environment:
+ const res = await fetch(`/api/weather?location=${location}`);
+ return res.json();
+});
-```bash
-ELEVENLABS_API_KEY=your-elevenlabs-api-key
-ELEVENLABS_AGENT_ID=your-agent-id
+export function useWeatherVoiceChat() {
+ return useRealtimeChat({
+ getToken: () =>
+ fetch("/api/realtime-token", { method: "POST" }).then((r) => r.json()),
+ adapter: elevenlabsRealtime(),
+ tools: [getWeather],
+ });
+}
```
-| Variable | Required | Description |
-|----------|----------|-------------|
-| `ELEVENLABS_API_KEY` | Yes | Your ElevenLabs API key, used server-side for generating signed URLs |
-| `ELEVENLABS_AGENT_ID` | No | Default agent ID. Can also be passed directly to `elevenlabsRealtimeToken()` |
+The adapter turns each result into a string and sends it to the agent.
-Get your API key from the [ElevenLabs dashboard](https://elevenlabs.io/). Create and configure agents in the **Conversational AI** section of the dashboard.
+## Text-to-speech
-## Text-to-Speech
-
-For one-shot speech generation (not realtime), use `elevenlabsSpeech` with `generateSpeech()`:
-
-The `format` option supports `mp3` (default), `pcm`, `opus`, and `wav`. WAV output contains 44.1 kHz, 16-bit mono PCM with a RIFF header. Requests for `aac` or `flac` throw before the API call. An explicit `modelOptions.outputFormat` overrides `format` and returns the selected provider format without WAV wrapping.
+`elevenlabsSpeech` makes one audio clip from text. `voice` is an ElevenLabs voice id:
```typescript
import { generateSpeech } from "@tanstack/ai";
@@ -296,84 +158,64 @@ import { elevenlabsSpeech } from "@tanstack/ai-elevenlabs";
const result = await generateSpeech({
adapter: elevenlabsSpeech("eleven_v4"),
text: "Hello from ElevenLabs!",
- voice: "Rachel",
+ threadId: "thread-1",
+ voice: "21m00Tcm4TlvDq8ikWAM",
format: "mp3",
});
console.log(result.audio); // Base64-encoded audio
```
-### Dialogue and timings
-
-ElevenLabs has a separate dialogue endpoint that takes up to **10 distinct
-voices**, and a timestamped twin of each endpoint. `turns` and `timestamps`
-pick between them, so you never choose an endpoint by hand:
-
-```typescript
-import { generateSpeech } from "@tanstack/ai";
-import { elevenlabsSpeech } from "@tanstack/ai-elevenlabs";
-
-const result = await generateSpeech({
- adapter: elevenlabsSpeech("eleven_v4"),
- turns: [
- { text: "Knock knock.", voice: "bYTqZQo3Jz7LQtmGTgwi" },
- { text: "Who is there?", voice: "6lCwbsX1yVjD49QmpkTR" },
- ],
- timestamps: true,
-});
-
-// Character timings for the whole clip.
-console.log(result.alignment?.unit); // 'character'
-
-// One entry per turn, with the voice that spoke it.
-for (const segment of result.segments ?? []) {
- console.log(segment.turnIndex, segment.voice, segment.text);
-}
-```
+- `format` takes `mp3` (the default), `pcm`, `opus`, or `wav`. The adapter throws for `aac` and `flac` before it calls the API.
+- For a dialogue with several voices, or for timings, see [Dialogue and timings](#dialogue-and-timings).
-`segments` only comes back from the dialogue endpoint. A single-voice request
-with `timestamps: true` returns `alignment` alone.
+See [Text-to-Speech](../media/text-to-speech).
-## Music & Sound Effects
+## Music and sound effects
-`elevenlabsAudio` covers both music generation and sound effects depending on the model:
+`elevenlabsAudio` makes music or a sound effect. The model picks which one:
```typescript
import { generateAudio } from "@tanstack/ai";
import { elevenlabsAudio } from "@tanstack/ai-elevenlabs";
-// Music generation
const music = await generateAudio({
adapter: elevenlabsAudio("music_v2_5"),
prompt: "An upbeat synthwave track for a product launch",
+ threadId: "thread-1",
});
-// Sound effects
const sfx = await generateAudio({
adapter: elevenlabsAudio("eleven_text_to_sound_v2"),
prompt: "A glass shattering on concrete",
+ threadId: "thread-1",
});
+
+console.log(music.audio, sfx.audio);
```
-## Listing Voices
+See [Audio Generation](../media/audio-generation).
-`elevenlabsSpeech` can read the account's voice catalog, which includes anything `generateVoice()` has saved:
+## Transcription
```typescript
-import { listVoices } from "@tanstack/ai";
-import { elevenlabsSpeech } from "@tanstack/ai-elevenlabs";
+import { generateTranscription } from "@tanstack/ai";
+import { elevenlabsTranscription } from "@tanstack/ai-elevenlabs";
-const { voices } = await listVoices({
- adapter: elevenlabsSpeech("eleven_v4"),
- origins: ["generated", "cloned"],
+const result = await generateTranscription({
+ adapter: elevenlabsTranscription("scribe_v2"),
+ audio: "https://example.com/recording.mp3",
+ threadId: "thread-1",
});
+
+console.log(result.text);
```
-ElevenLabs has no origin filter on `GET /v1/voices`, so the adapter fetches the catalog and narrows in memory. Its `famous` and `high_quality` categories both read as `professional`.
+See [Transcription](../media/transcription).
-## Voice Design
+## Voice design
-`elevenlabsVoiceDesign` creates a new voice from a description. The voice IDs it returns go straight into `generateSpeech()`:
+`elevenlabsVoiceDesign` makes a new voice from a description. Use the voice id that it returns in `generateSpeech()`:
```typescript
import { generateVoice } from "@tanstack/ai";
@@ -382,6 +224,7 @@ import { elevenlabsVoiceDesign } from "@tanstack/ai-elevenlabs";
const result = await generateVoice({
adapter: elevenlabsVoiceDesign("eleven_ttv_v3"),
prompt: "A warm, gravelly narrator in his sixties with a slight Irish lilt",
+ threadId: "thread-1",
name: "Irish Narrator",
});
@@ -392,112 +235,215 @@ console.log(voice.voiceId);
console.log(voice.saved); // true, because a name was given
```
-Without a `name`, you get preview voices to audition. With a `name`, the best candidate is kept in your ElevenLabs voice library.
+- Without a `name`, you get preview voices to listen to.
+- With a `name`, ElevenLabs keeps the best voice in your voice library.
-`eleven_ttv_v3` also accepts `referenceAudio`, a clip of a real speaker used as a design reference — it still needs a `prompt`, and `modelOptions.promptStrength` balances the two. `eleven_multilingual_ttv_v2` takes no reference audio. See [Voice Creation](../media/voice-creation) for the full guide.
+For reference audio and the other options, see [Voice Creation](../media/voice-creation).
-## Models
+## Models and options
-| Family | Models |
-| --- | --- |
-| Text-to-speech | `eleven_v4`, `eleven_v4_turbo`, `eleven_v3`, `eleven_v3_conversational`, `eleven_multilingual_v2`, `eleven_flash_v2_5`, `eleven_flash_v2` |
-| Music | `music_v2_5`, `music_v2` |
-| Sound effects | `eleven_text_to_sound_v2` |
-| Transcription | `scribe_v2`, `scribe_v2_medical` |
-| Voice design | `eleven_ttv_v3`, `eleven_multilingual_ttv_v2` |
+| Activity | Factory | Models |
+| --- | --- | --- |
+| Text-to-speech | `elevenlabsSpeech` | `eleven_v4`, `eleven_v4_turbo`, `eleven_v3`, `eleven_v3_conversational`, `eleven_multilingual_v2`, `eleven_flash_v2_5`, `eleven_flash_v2` |
+| Music | `elevenlabsAudio` | `music_v2_5`, `music_v2` |
+| Sound effects | `elevenlabsAudio` | `eleven_text_to_sound_v2` |
+| Transcription | `elevenlabsTranscription` | `scribe_v2`, `scribe_v2_medical` |
+| Voice design | `elevenlabsVoiceDesign` | `eleven_ttv_v3`, `eleven_multilingual_ttv_v2` |
-ElevenLabs deprecated `eleven_turbo_v2`, `eleven_turbo_v2_5`, `eleven_monolingual_v1`, `scribe_v1`, and `music_v1`. The adapter still accepts them, and each is commented as deprecated in `model-meta.ts`. Move to the current model in the same row.
+The adapter also accepts the deprecated ids `eleven_turbo_v2`, `eleven_turbo_v2_5`, `eleven_monolingual_v1`, `scribe_v1`, and `music_v1`. Use the newest model in the same row.
-## Transcription
+The package exports the lists as `ELEVENLABS_TTS_MODELS`, `ELEVENLABS_AUDIO_MODELS`, `ELEVENLABS_TRANSCRIPTION_MODELS`, and `ELEVENLABS_VOICE_MODELS`.
-Transcribe audio with `elevenlabsTranscription`:
+## Advanced
+
+### Agent overrides
+
+To change the agent for one session, set `overrides` when you make the token. Your dashboard settings do not change:
```typescript
-import { generateTranscription } from "@tanstack/ai";
-import { elevenlabsTranscription } from "@tanstack/ai-elevenlabs";
-import { audioFile } from "./audio";
+import { realtimeToken } from "@tanstack/ai";
+import { elevenlabsRealtimeToken } from "@tanstack/ai-elevenlabs";
-const result = await generateTranscription({
- adapter: elevenlabsTranscription("scribe_v2"),
- audio: audioFile,
+const token = await realtimeToken({
+ adapter: elevenlabsRealtimeToken({
+ agentId: process.env.ELEVENLABS_AGENT_ID!,
+ overrides: {
+ voiceId: "custom-voice-id",
+ systemPrompt: "You are a helpful voice assistant.",
+ firstMessage: "Hello! How can I help you today?",
+ language: "en",
+ },
+ }),
});
-console.log(result.text);
+console.log(token.expiresAt);
```
-## Behind a proxy
+| Option | Type | Description |
+| --- | --- | --- |
+| `agentId` | `string` | The agent id from the dashboard. If you do not set it, the adapter reads `ELEVENLABS_AGENT_ID`. |
+| `overrides.voiceId` | `string` | A voice id that replaces the default voice of the agent. |
+| `overrides.systemPrompt` | `string` | A system prompt that replaces the default prompt of the agent. |
+| `overrides.firstMessage` | `string` | The first thing the agent says when the session starts. |
+| `overrides.language` | `string` | A language code, for example `"en"`, `"es"`, or `"fr"`. |
+
+### Client adapter options
+
+`elevenlabsRealtime(options?)` runs in the browser:
-Route every request through a gateway, such as Cloudflare AI Gateway or a corporate proxy, with `baseURL` and `defaultHeaders`. These two option names are the same on every TanStack AI adapter, so one gateway config works for all of them.
+| Option | Type | Default | Description |
+| --- | --- | --- | --- |
+| `connectionMode` | `"websocket"` or `"webrtc"` | auto-detect | The transport of the connection. |
+| `debug` | `boolean` or `DebugConfig` | `false` | Debug logs. `true` turns on all categories. A `DebugConfig` selects categories or a sink. |
+
+### Without React
+
+Use `RealtimeClient` from `@tanstack/ai-client`:
```typescript
-import { createElevenLabsSpeech } from "@tanstack/ai-elevenlabs";
+import { RealtimeClient } from "@tanstack/ai-client";
+import { elevenlabsRealtime } from "@tanstack/ai-elevenlabs";
-const adapter = createElevenLabsSpeech("eleven_v4", process.env.ELEVENLABS_API_KEY!, {
- baseURL: "https://gateway.example.com/elevenlabs",
- defaultHeaders: { "cf-aig-authorization": `Bearer ${process.env.GATEWAY_TOKEN}` },
+const client = new RealtimeClient({
+ getToken: () =>
+ fetch("/api/realtime-token", { method: "POST" }).then((r) => r.json()),
+ adapter: elevenlabsRealtime(),
+ onMessage: (message) => {
+ console.log(`${message.role}:`, message.parts);
+ },
+ onStatusChange: (status) => {
+ console.log("Status:", status);
+ },
+ onModeChange: (mode) => {
+ console.log("Mode:", mode);
+ },
});
+
+await client.connect();
```
-This applies to the speech, audio, and transcription adapters. `baseUrl` and `headers` are aliases of the same two options. If you set both forms, `baseURL` and `defaultHeaders` win.
+### Audio levels for a visualizer
-## API Reference
+The hook gives you the audio data of the microphone and the speaker:
-### `elevenlabsRealtimeToken(options)`
+```typescript
+import { useRealtimeChat } from "@tanstack/ai-react";
+import { elevenlabsRealtime } from "@tanstack/ai-elevenlabs";
-Creates an ElevenLabs realtime token adapter for server-side use with `realtimeToken()`.
+export function useVoiceLevels() {
+ const {
+ inputLevel, // 0 to 1, microphone volume
+ outputLevel, // 0 to 1, speaker volume
+ getInputFrequencyData, // Uint8Array frequency spectrum
+ getOutputFrequencyData,
+ } = useRealtimeChat({
+ getToken: () =>
+ fetch("/api/realtime-token", { method: "POST" }).then((r) => r.json()),
+ adapter: elevenlabsRealtime(),
+ });
+
+ return { inputLevel, outputLevel, getInputFrequencyData, getOutputFrequencyData };
+}
+```
+
+ElevenLabs gives volume levels and frequency data, but no time-domain data. `getInputTimeDomainData()` and `getOutputTimeDomainData()` return fixed placeholder arrays. The default sample rate is 16 kHz.
+
+### ElevenLabs and OpenAI realtime
+
+| | ElevenLabs | OpenAI |
+|---|---|---|
+| Configuration | Agent-based. Set voice, personality, and knowledge in the dashboard, or with `overrides` on the token. | Session-based. Set `instructions`, `voice`, and more for each session in the `useRealtimeChat` options. |
+| Token | Signed WebSocket URL, valid for 30 minutes | Ephemeral API token, valid for about 10 minutes |
+| Transport | WebSocket (default) or WebRTC | WebRTC |
+| Audio | The `@elevenlabs/client` SDK records and plays the audio | TanStack AI runs the WebRTC connection and the audio tracks |
+| Voice activity detection | ElevenLabs does it on its server | `server`, `semantic`, and `manual` modes |
+| Session changes | Fixed when the session starts | `updateSession()` changes the session while it runs |
+| Image input | Not supported | `sendImage()` |
+| Time-domain data | Not available | Available for waveforms |
-**Parameters:**
+### Dialogue and timings
+
+ElevenLabs has a dialogue endpoint that takes up to 10 different voices. Each endpoint also has a version with timestamps. `turns` and `timestamps` pick the endpoint for you:
+
+```typescript
+import { generateSpeech } from "@tanstack/ai";
+import { elevenlabsSpeech } from "@tanstack/ai-elevenlabs";
-- `options.agentId` - Agent ID from the ElevenLabs dashboard
-- `options.overrides?.voiceId` - Custom voice ID
-- `options.overrides?.systemPrompt` - Custom system prompt
-- `options.overrides?.firstMessage` - First message the agent speaks
-- `options.overrides?.language` - Language code
+const result = await generateSpeech({
+ adapter: elevenlabsSpeech("eleven_v4"),
+ turns: [
+ { text: "Knock knock.", voice: "bYTqZQo3Jz7LQtmGTgwi" },
+ { text: "Who is there?", voice: "6lCwbsX1yVjD49QmpkTR" },
+ ],
+ timestamps: true,
+ threadId: "thread-1",
+});
-**Returns:** A `RealtimeTokenAdapter` for use with `realtimeToken()`.
+// Character timings for the whole clip.
+console.log(result.alignment?.unit); // "character"
-### `elevenlabsRealtime(options?)`
+// One entry per turn, with the voice that spoke it.
+for (const segment of result.segments ?? []) {
+ console.log(segment.turnIndex, segment.voice, segment.text);
+}
+```
-Creates an ElevenLabs realtime client adapter for use with `useRealtimeChat` or `RealtimeClient`.
+Only the dialogue endpoint returns `segments`. A request with one voice and `timestamps: true` returns `alignment` only.
-**Parameters:**
+### Speech output format
-- `options.connectionMode?` - `'websocket'` or `'webrtc'` (default: auto-detect)
-- `options.debug?` - Enable debug logging
+- `wav` output is 44.1 kHz, 16-bit mono PCM with a RIFF header.
+- `modelOptions.outputFormat` overrides `format`. The adapter then returns the format of ElevenLabs as it is, with no WAV header.
-**Returns:** A `RealtimeAdapter` for use with `useRealtimeChat()` or `RealtimeClient`.
+### List voices
-### `elevenlabsSpeech(model, config?)` / `createElevenLabsSpeech(model, apiKey, config?)`
+`elevenlabsSpeech` reads the voices of your account. This includes the voices that `generateVoice()` saved:
-Creates an ElevenLabs text-to-speech adapter for use with `generateSpeech()`.
+```typescript
+import { listVoices } from "@tanstack/ai";
+import { elevenlabsSpeech } from "@tanstack/ai-elevenlabs";
-### `elevenlabsAudio(model, config?)` / `createElevenLabsAudio(model, apiKey, config?)`
+const { voices } = await listVoices({
+ adapter: elevenlabsSpeech("eleven_v4"),
+ origins: ["generated", "cloned"],
+});
-Creates an ElevenLabs audio adapter that covers both music generation and sound effects (selected via the model id) for use with `generateAudio()`.
+console.log(voices.length);
+```
-### `elevenlabsTranscription(model, config?)` / `createElevenLabsTranscription(model, apiKey, config?)`
+`GET /v1/voices` has no origin filter, so the adapter gets all voices and filters them in memory. The `famous` and `high_quality` categories both come back as `professional`.
-Creates an ElevenLabs transcription adapter for use with `generateTranscription()`.
+### Behind a proxy
-### `elevenlabsVoiceDesign(model, config?)` / `createElevenLabsVoiceDesign(model, apiKey, config?)`
+To send each request through a gateway, such as Cloudflare AI Gateway or a company proxy, set `baseURL` and `defaultHeaders`. All TanStack AI adapters use these two option names, so one gateway configuration works for all of them.
-Creates an ElevenLabs voice-design adapter for use with `generateVoice()`.
+```typescript
+import { createElevenLabsSpeech } from "@tanstack/ai-elevenlabs";
-### `elevenlabsSpeech(...).listVoices(options?)`
+const adapter = createElevenLabsSpeech("eleven_v4", process.env.ELEVENLABS_API_KEY!, {
+ baseURL: "https://gateway.example.com/elevenlabs",
+ defaultHeaders: { "cf-aig-authorization": `Bearer ${process.env.GATEWAY_TOKEN}` },
+});
+```
-Reads the account's voice catalog via `GET /v1/voices`, for use with `listVoices()`.
+- This works for the speech, audio, transcription, and voice design adapters.
+- `baseUrl` and `headers` are aliases. If you set both forms, `baseURL` and `defaultHeaders` win.
-## Limitations
+### Limits
-- **No text chat support** -- Use OpenAI, Anthropic, Gemini, or another text adapter for `chat()`.
-- **No summarization** -- Use a text adapter for `summarize()`.
-- **No image input** (realtime) -- ElevenLabs realtime does not support sending images during a conversation.
-- **No runtime session updates** (realtime) -- Session configuration is fixed at connection time.
-- **No time-domain audio data** (realtime) -- Frequency data and volume levels are available, but waveform data is not.
-- **Agent required** (realtime) -- You must create and configure an agent in the ElevenLabs dashboard before using the realtime adapter.
+- Realtime does not take images during a conversation.
+- Realtime fixes the session configuration when the connection starts.
+- Realtime needs an agent that you made in the ElevenLabs dashboard.
-## Next Steps
+### API reference
-- [Realtime Voice Chat Guide](../media/realtime-chat) - Complete guide to building realtime voice applications
-- [OpenAI Adapter](./openai) - Alternative realtime voice provider with WebRTC
-- [Tools Guide](../tools/tools) - Learn about the isomorphic tool system
+| Factory | Use it with |
+| --- | --- |
+| `elevenlabsRealtimeToken(options)` | `realtimeToken()` on the server. Returns a `RealtimeTokenAdapter`. |
+| `elevenlabsRealtime(options?)` | `useRealtimeChat()` or `RealtimeClient` in the browser. Returns a `RealtimeAdapter`. |
+| `elevenlabsSpeech(model, config?)` / `createElevenLabsSpeech(model, apiKey, config?)` | `generateSpeech()` and `listVoices()` |
+| `elevenlabsAudio(model, config?)` / `createElevenLabsAudio(model, apiKey, config?)` | `generateAudio()`, for music and sound effects |
+| `elevenlabsTranscription(model, config?)` / `createElevenLabsTranscription(model, apiKey, config?)` | `generateTranscription()` |
+| `elevenlabsVoiceDesign(model, config?)` / `createElevenLabsVoiceDesign(model, apiKey, config?)` | `generateVoice()` |
+
+The short factories read `ELEVENLABS_API_KEY`. The `create*` factories take an explicit API key.
diff --git a/docs/adapters/fal.md b/docs/adapters/fal.md
index c8fc2503ec..e921c9a51c 100644
--- a/docs/adapters/fal.md
+++ b/docs/adapters/fal.md
@@ -1,7 +1,7 @@
---
title: fal.ai
id: fal-adapter
-description: "Generate images and videos with 600+ models on fal.ai using TanStack AI — Nano Banana Pro, FLUX, and more via the @tanstack/ai-fal adapter."
+description: "Use fal.ai in TanStack AI with @tanstack/ai-fal: 600+ hosted models for images, video, live video, speech, transcription, music, and sound effects."
keywords:
- tanstack ai
- fal.ai
@@ -13,11 +13,11 @@ keywords:
- adapter
---
-The fal.ai adapter provides access to 600+ models on the fal.ai platform for image, video, live, audio, speech, and transcription. Unlike text-focused adapters, the fal adapter is **media-focused**. It supports `generateImage()`, `generateVideo()`, `generateLiveVideo()`, `generateAudio()`, `generateSpeech()`, and `generateTranscription()`. It does not support `chat()` or tools.
+The fal.ai adapter calls hosted models for images, video, live video, speech, transcription, music, and sound effects. It has no text chat and no tools.
-For a full working example, see the [fal.ai example app](https://github.com/TanStack/ai/tree/main/examples/ts-react-media).
+The quick setup below generates an image on your server and shows it in React. For a full app, see the [media example](https://github.com/TanStack/ai/tree/main/examples/ts-react-media).
-## Installation
+## 1. Install
@@ -32,110 +32,99 @@ octane: @tanstack/ai-fal
-## Type Safety with String Literals
+## 2. Set the API key
-The fal adapter provides full type safety when you pass the model ID as a **string literal**. This gives you autocomplete for `size` and `modelOptions` specific to that model. Always use string literals — not variables — when creating adapters:
+Create a key at [fal.ai](https://fal.ai). Put it in the environment of your server:
-```typescript
-import { falImage } from "@tanstack/ai-fal";
-
-// Good — full type safety and autocomplete
-const adapter = falImage("fal-ai/z-image/turbo");
-```
-
-```typescript
-import { falImage } from "@tanstack/ai-fal";
-
-// Bad — no type inference for model-specific options
-const modelId = "fal-ai/z-image/turbo";
-const adapter = falImage(modelId);
+```bash
+FAL_KEY=your-fal-api-key
```
-You can also pass any string for new models that fal.ai hasn't provided types for yet — you just won't get type safety on those endpoints.
-
-## Basic Usage
+All fal adapters read `FAL_KEY`. To pass the key in code, set `apiKey` in the config:
```typescript
-import { generateImage } from "@tanstack/ai";
import { falImage } from "@tanstack/ai-fal";
-const result = await generateImage({
- adapter: falImage("fal-ai/flux/dev"),
- prompt: "A futuristic cityscape at sunset",
- numberOfImages: 1,
+const adapter = falImage("fal-ai/nano-banana-pro", {
+ apiKey: process.env.FAL_KEY!,
});
-
-console.log(result.images);
```
-## Basic Usage - Custom API Key
+## 3. Generate an image on the server
+
+Add a `POST` route at `/api/generate/image`:
```typescript
-import { generateImage } from "@tanstack/ai";
+import {
+ generateImage,
+ generationParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
import { falImage } from "@tanstack/ai-fal";
-const adapter = falImage("fal-ai/flux/dev", {
- apiKey: process.env.FAL_KEY!,
-});
-
-const result = await generateImage({
- adapter,
- prompt: "A futuristic cityscape at sunset",
- numberOfImages: 1,
-});
-```
-
-## Configuration
-
-```typescript
-import { falImage, type FalClientConfig } from "@tanstack/ai-fal";
+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 });
+ }
-// Direct API key
-const adapter = falImage("fal-ai/flux/dev", {
- apiKey: "your-api-key",
-});
+ const stream = generateImage({
+ adapter: falImage("fal-ai/nano-banana-pro"),
+ prompt: input.prompt,
+ threadId,
+ stream: true,
+ });
-// Using a proxy URL (for client-side usage)
-const proxiedAdapter = falImage("fal-ai/flux/dev", {
- apiKey: "your-api-key",
- proxyUrl: "https://your-server.com/api/fal/proxy",
-});
+ return toServerSentEventsResponse(stream);
+}
```
-## Example: Image Generation
-
-From the [fal.ai example app](https://github.com/TanStack/ai/tree/main/examples/ts-react-media):
-
-```typescript
-import { generateImage } from "@tanstack/ai";
-import { falImage } from "@tanstack/ai-fal";
-
-// Use string literals for the model to get full type safety
-const result = await generateImage({
- adapter: falImage("fal-ai/nano-banana-pro"),
- prompt: "A futuristic cityscape at sunset",
- numberOfImages: 1,
- size: "16:9_4K",
- modelOptions: {
- output_format: "jpeg",
- },
-});
+## 4. Show the image
+
+```tsx
+import { fetchServerSentEvents, useGenerateImage } from "@tanstack/ai-react";
+
+export function ImageGenerator() {
+ const { generate, result, isLoading, error } = useGenerateImage({
+ connection: fetchServerSentEvents("/api/generate/image"),
+ threadId: "image-studio",
+ });
+
+ return (
+
+ );
+}
```
-## Example: Image with Model Options
+Click the button. The image shows up below it. For the other frameworks, see [Generation Hooks](../media/generation-hooks).
-Each fal.ai model has its own type-safe options. The adapter uses fal.ai's types to provide autocomplete and type checking for model-specific parameters. There are 1000s of combinations. If you provide the proper model endpoint id as as string literal, you will then only be able to provide `size` and `modelOptions` that the model supports.
+## Model options
-_IMPORTANT_: It is possible to pass strings and new endpoint ids that Fal has not provided types for. You just won't get type safety on those endpoints. You'll find this happens for very new models.
+Each fal model has its own options. Write the model id as a string literal, and TypeScript completes `size` and `modelOptions` for that model:
```typescript
import { generateImage } from "@tanstack/ai";
import { falImage } from "@tanstack/ai-fal";
-// Model-specific options are type-safe
const result = await generateImage({
adapter: falImage("fal-ai/z-image/turbo"),
prompt: "A serene mountain landscape",
+ threadId: "thread-1",
numberOfImages: 1,
size: "landscape_16_9",
modelOptions: {
@@ -143,69 +132,41 @@ const result = await generateImage({
enable_prompt_expansion: true,
},
});
-```
-
-## Image Size Options
-
-The fal adapter supports a flexible `size` parameter that maps either to `image_size` or to `aspect_ratio` and `resolution` parameters:
-
-| | `size` | Maps To |
-|--------|---------|---------|
-| named | `"landscape_16_9"` | `image_size: "landscape_16_9"` |
-| width x height (OpenAI) | `"1536x1024"` | | `image_size: "1536x1024"` |
-| aspect ratio & resolution | `"16:9_4K"` | `aspect_ratio: "16:9"`, `resolution: "4K"` |
-| aspect ratio only | `"16:9"` | `aspect_ratio: "16:9"` |
-
-```typescript ignore
-// Aspect ratio only
-size: "16:9"
-
-// Aspect ratio with resolution
-size: "16:9_4K"
-// Named size (model-specific)
-size: "landscape_16_9"
+console.log(result.images);
```
-## Video Generation (Experimental)
-
-> **Note:** Video generation is an experimental feature and may change in future releases.
+- Pass the id directly, not through a `string` variable. A variable loses the types of the model.
+- Any string works as a model id. A model that is newer than the fal SDK types runs, but has no autocomplete.
-Video generation uses a queue-based workflow: submit a job, poll for status, then retrieve the video URL when complete.
+For the `size` formats, see [Image sizes](#image-sizes).
-```typescript
-import { generateVideo, getVideoJobStatus } from "@tanstack/ai";
-import { falVideo } from "@tanstack/ai-fal";
-```
+## Video
-## Example: Text-to-Video
+> Video generation is an [experimental feature](../media/video-generation).
-From the [fal.ai example app](https://github.com/TanStack/ai/tree/main/examples/ts-react-media):
+Video runs as a job. `generateVideo()` starts the job, and `getVideoJobStatus()` reads its status:
```typescript
import { generateVideo, getVideoJobStatus } from "@tanstack/ai";
import { falVideo } from "@tanstack/ai-fal";
-// 1. Submit the video generation job
const adapter = falVideo("fal-ai/kling-video/v2.6/pro/text-to-video");
const job = await generateVideo({
adapter,
prompt: "A timelapse of a flower blooming",
+ threadId: "thread-1",
size: "16:9",
duration: "5",
});
-// 2. Poll for status
-const status = await getVideoJobStatus({
- adapter,
- jobId: job.jobId,
-});
+const status = await getVideoJobStatus({ adapter, jobId: job.jobId });
console.log(status.status); // "pending" | "processing" | "completed"
```
-## Example: Image-to-Video
+To animate an image, use an image-to-video model and set the start image in `modelOptions`:
```typescript
import { generateVideo } from "@tanstack/ai";
@@ -214,68 +175,26 @@ import { falVideo } from "@tanstack/ai-fal";
const job = await generateVideo({
adapter: falVideo("fal-ai/kling-video/v2.6/pro/image-to-video"),
prompt: "Animate this scene with gentle wind",
+ threadId: "thread-1",
duration: "5",
modelOptions: {
start_image_url: "https://example.com/image.jpg",
generate_audio: true,
},
});
-```
-
-`duration` is typed per model from `@fal-ai/client`'s `EndpointTypeMap`. Popular models also implement `availableDurations()` / `snapDuration()` for UI sliders:
-
-| Model | `duration` type | `availableDurations()` |
-| --- | --- | --- |
-| `fal-ai/kling-video/v1.6/{standard,pro}/text-to-video`, `fal-ai/kling-video/v2.6/pro/{text,image}-to-video` | `'5' \| '10'` | discrete |
-| `fal-ai/kling-video/v3/pro/{text,image}-to-video` | `'3'` … `'15'` | discrete |
-| `fal-ai/pika/v2.2/text-to-video` | `'5' \| '10'` | discrete |
-| `fal-ai/ltx-2.3/{text,image}-to-video` (+ `/fast`) | `'6' \| '8' \| '10'` | discrete |
-| `fal-ai/luma-dream-machine/ray-2` | `'5s' \| '9s'` | discrete |
-| `fal-ai/veo3.1`, `fal-ai/veo3.1/fast` (+ `/image-to-video`), `fal-ai/veo3` (+ `/image-to-video`) | `'4s' \| '6s' \| '8s'` | discrete |
-| `fal-ai/wan-25-preview/text-to-video` | `'2'` … `'15'` | discrete |
-| `fal-ai/minimax/video-01` | not accepted | `{ kind: 'none' }` |
-| `fal-ai/hunyuan-video-v1.5/text-to-video` | not accepted (`num_frames`) | `{ kind: 'none' }` |
-
-```typescript
-import { generateVideo } from "@tanstack/ai";
-import { falVideo } from "@tanstack/ai-fal";
-const adapter = falVideo("fal-ai/veo3.1");
-adapter.availableDurations(); // { kind: 'discrete', values: ['4s', '6s', '8s'] }
-adapter.snapDuration(7); // '6s'
-
-await generateVideo({
- adapter,
- prompt: "A timelapse of a city skyline at dusk",
- duration: adapter.snapDuration(7),
-});
+console.log(job.jobId);
```
-Uncurated models still type `duration` from the SDK when the endpoint declares the field, but `availableDurations()` returns `{ kind: 'none' }` until they are added to the runtime map.
-
-## Text-to-Speech
-
-Text-to-speech uses `falSpeech()` with the `generateSpeech()` activity. The adapter fetches the generated audio from fal's CDN and returns it as base64-encoded data to match the `TTSResult` contract.
+The `duration` values of each model are in [Video durations](#video-durations). To poll from a React component, see [Video Generation](../media/video-generation).
-```typescript
-import { generateSpeech } from "@tanstack/ai";
-import { falSpeech } from "@tanstack/ai-fal";
-
-const result = await generateSpeech({
- adapter: falSpeech("fal-ai/kokoro/american-english"),
- text: "Hello from fal!",
- voice: "af_heart",
- speed: 1.0,
-});
+## Live video
-// result.audio is a base64-encoded string
-console.log(result.format); // e.g. "wav"
-console.log(result.contentType); // e.g. "audio/wav"
-```
+`falLiveVideo("minimax/h3-max/director")` starts a live video stream that you can steer. The browser connects through a proxy on your server. See [Live Generation](../media/live-generation#fal-h3-max-director).
-### Google Gemini 3.1 Flash TTS
+## Speech
-Google's newest TTS model (`fal-ai/gemini-3.1-flash-tts`) supports 80+ languages and introduces **granular audio tags** for expressive control — you can embed speaker tags and style cues directly in the text.
+`falSpeech` gets the audio from the fal CDN and returns it as base64:
```typescript
import { generateSpeech } from "@tanstack/ai";
@@ -284,13 +203,16 @@ import { falSpeech } from "@tanstack/ai-fal";
const result = await generateSpeech({
adapter: falSpeech("fal-ai/gemini-3.1-flash-tts"),
text: "[warm, enthusiastic] Welcome to TanStack AI! [pause] Let's build something great.",
+ threadId: "thread-1",
voice: "Kore",
});
+
+console.log(result.format, result.contentType);
```
-> **Note:** This model is newer than `@fal-ai/client@1.9.1`'s type map, so `modelOptions` won't autocomplete. The call still works — the fal adapter accepts any model ID as a string. Type-safe autocomplete will land when fal's SDK types catch up.
+`fal-ai/gemini-3.1-flash-tts` is the TTS model of Google. It supports 80+ languages. You can put speaker tags and style tags, such as `[pause]`, in the text. Its `modelOptions` have no autocomplete yet.
-### ElevenLabs v3
+Other models put the voice in `modelOptions`:
```typescript
import { generateSpeech } from "@tanstack/ai";
@@ -299,16 +221,19 @@ import { falSpeech } from "@tanstack/ai-fal";
const result = await generateSpeech({
adapter: falSpeech("fal-ai/elevenlabs/tts/eleven-v3"),
text: "Welcome to TanStack AI.",
+ threadId: "thread-1",
modelOptions: {
voice: "Rachel",
stability: 0.5,
},
});
+
+console.log(result.audio); // Base64-encoded audio
```
-## Transcription
+See [Text-to-Speech](../media/text-to-speech).
-Speech-to-text uses `falTranscription()` with the `generateTranscription()` activity. The `audio` input accepts a URL string, `Blob`, `File`, or `ArrayBuffer` — `ArrayBuffer` is automatically wrapped in a `Blob` for upload.
+## Transcription
```typescript
import { generateTranscription } from "@tanstack/ai";
@@ -318,234 +243,232 @@ const result = await generateTranscription({
adapter: falTranscription("fal-ai/whisper"),
audio: "https://example.com/recording.mp3",
language: "en",
+ threadId: "thread-1",
});
console.log(result.text);
console.log(result.language);
-// When the model returns word/segment timestamps, they're mapped to result.segments
+// When the model returns timestamps, they are in result.segments
for (const segment of result.segments ?? []) {
- console.log(`[${segment.start}s → ${segment.end}s] ${segment.text}`);
+ console.log(`[${segment.start}s to ${segment.end}s] ${segment.text}`);
}
```
-## Audio Generation (Music & Sound Effects)
+`audio` takes a URL string, a `Blob`, a `File`, or an `ArrayBuffer`. The adapter wraps an `ArrayBuffer` in a `Blob` for the upload.
+
+## Music and sound effects
-Music and sound-effect generation uses `falAudio()` with the `generateAudio()` activity. Unlike TTS, the result is returned as a URL in `result.audio.url` (you can fetch it yourself if you need raw bytes).
+`falAudio` returns a URL in `result.audio.url`. If you need the bytes, fetch the URL:
```typescript
import { generateAudio } from "@tanstack/ai";
import { falAudio } from "@tanstack/ai-fal";
-// Music generation with MiniMax Music 2.6 (latest)
const music = await generateAudio({
adapter: falAudio("fal-ai/minimax-music/v2.6"),
prompt: "City Pop, 80s retro, groovy synth bass, warm female vocal, 104 BPM, nostalgic urban night",
+ threadId: "thread-1",
+});
+
+const sfx = await generateAudio({
+ adapter: falAudio("fal-ai/elevenlabs/sound-effects/v2"),
+ prompt: "Thunderclap with rain",
+ threadId: "thread-1",
+ duration: 5,
});
-console.log(music.audio.url);
+console.log(music.audio.url, sfx.audio.url);
```
+Some music models take lyrics in `modelOptions`:
+
```typescript
import { generateAudio } from "@tanstack/ai";
import { falAudio } from "@tanstack/ai-fal";
-// DiffRhythm with explicit lyrics
-const lyrical = await generateAudio({
+const song = await generateAudio({
adapter: falAudio("fal-ai/diffrhythm"),
prompt: "An upbeat electronic track with synths",
+ threadId: "thread-1",
modelOptions: {
lyrics: "[verse]\nHello world\n[chorus]\nLa la la",
},
});
+
+console.log(song.audio.url);
```
-```typescript
-import { generateAudio } from "@tanstack/ai";
-import { falAudio } from "@tanstack/ai-fal";
+See [Audio Generation](../media/audio-generation).
-// Sound effects
-const sfx = await generateAudio({
- adapter: falAudio("fal-ai/elevenlabs/sound-effects/v2"),
- prompt: "Thunderclap with rain",
- duration: 5,
-});
-```
+## Files
+
+`falFiles` uploads media to fal storage, so a request can point to the file by URL. fal storage is upload-only. See [Files API](../advanced/files-api).
-## Popular Models
+## Models and options
-### Image Models
+fal has hundreds of models. These are popular picks. Any fal model id works.
+
+### Image models
| Model | Description |
|-------|-------------|
-| `fal-ai/nano-banana-pro` | Fast, high-quality image generation (4K) |
-| `fal-ai/flux-2/klein/9b` | Enhanced realism, crisp text generation |
-| `fal-ai/z-image/turbo` | Super fast 6B parameter model |
-| `xai/grok-imagine-image` | xAI highly aesthetic images with prompt enhancement |
+| `fal-ai/nano-banana-pro` | Fast, high-quality images, up to 4K |
+| `fal-ai/flux-2/klein/9b` | Realistic images, clear text |
+| `fal-ai/z-image/turbo` | A very fast 6B-parameter model |
+| `xai/grok-imagine-image` | xAI images with prompt enhancement |
-### Video Models
+### Video models
| Model | Mode | Description |
|-------|------|-------------|
-| `fal-ai/kling-video/v2.6/pro/text-to-video` | Text-to-Video | High-quality text-to-video |
-| `fal-ai/kling-video/v2.6/pro/image-to-video` | Image-to-Video | Animate images with Kling |
-| `fal-ai/veo3.1` | Text-to-Video | Google Veo text-to-video |
-| `fal-ai/veo3.1/image-to-video` | Image-to-Video | Google Veo image-to-video |
-| `xai/grok-imagine-video/text-to-video` | Text-to-Video | xAI video from text |
-| `xai/grok-imagine-video/image-to-video` | Image-to-Video | xAI animate images to video |
-| `fal-ai/ltx-2/text-to-video/fast` | Text-to-Video | Fast text-to-video |
-| `fal-ai/ltx-2/image-to-video/fast` | Image-to-Video | Fast image-to-video animation |
+| `fal-ai/kling-video/v2.6/pro/text-to-video` | Text-to-video | High-quality text-to-video |
+| `fal-ai/kling-video/v2.6/pro/image-to-video` | Image-to-video | Animate images with Kling |
+| `fal-ai/veo3.1` | Text-to-video | Google Veo text-to-video |
+| `fal-ai/veo3.1/image-to-video` | Image-to-video | Google Veo image-to-video |
+| `xai/grok-imagine-video/text-to-video` | Text-to-video | xAI video from text |
+| `xai/grok-imagine-video/image-to-video` | Image-to-video | xAI video from an image |
+| `fal-ai/ltx-2/text-to-video/fast` | Text-to-video | Fast text-to-video |
+| `fal-ai/ltx-2/image-to-video/fast` | Image-to-video | Fast image-to-video |
-### Live Models
+### Live models
-| Model | Mode | Description |
-|-------|------|-------------|
-| `minimax/h3-max/director` | Live | Steerable live stream. Use `falLiveVideo()` with `generateLiveVideo()`. |
+| Model | Description |
+|-------|-------------|
+| `minimax/h3-max/director` | A live stream that you can steer. Use `falLiveVideo()` with `generateLiveVideo()`. |
-### Text-to-Speech Models
+### Text-to-speech models
| Model | Description |
|-------|-------------|
-| `fal-ai/gemini-3.1-flash-tts` | **New** — Google's flagship TTS with 80+ languages and expressive audio tags |
-| `fal-ai/elevenlabs/tts/eleven-v3` | ElevenLabs v3 expressive multi-voice TTS |
-| `fal-ai/elevenlabs/tts/turbo-v2.5` | Low-latency ElevenLabs TTS |
-| `fal-ai/minimax/speech-2.6-hd` | MiniMax HD speech synthesis |
-| `fal-ai/minimax/speech-2.6-turbo` | MiniMax low-latency variant |
-| `fal-ai/kokoro/american-english` | Kokoro multilingual TTS — also `british-english`, `french`, `spanish`, `italian`, `japanese`, `mandarin-chinese`, `hindi`, `brazilian-portuguese` |
+| `fal-ai/gemini-3.1-flash-tts` | Google TTS with 80+ languages and style tags |
+| `fal-ai/elevenlabs/tts/eleven-v3` | ElevenLabs v3, expressive, several voices |
+| `fal-ai/elevenlabs/tts/turbo-v2.5` | ElevenLabs, low latency |
+| `fal-ai/minimax/speech-2.6-hd` | MiniMax HD speech |
+| `fal-ai/minimax/speech-2.6-turbo` | MiniMax, low latency |
+| `fal-ai/kokoro/american-english` | Kokoro. Also `british-english`, `french`, `spanish`, `italian`, `japanese`, `mandarin-chinese`, `hindi`, `brazilian-portuguese` |
| `fal-ai/inworld-tts` | Inworld TTS-1.5 Max |
-| `fal-ai/chatterbox/text-to-speech/multilingual` | Chatterbox multilingual TTS |
-| `fal-ai/dia-tts` | Dia expressive dialogue TTS |
-| `fal-ai/orpheus-tts` | Orpheus open-source TTS |
-| `fal-ai/f5-tts` | F5-TTS voice cloning |
-| `fal-ai/vibevoice/7b` | VibeVoice 7B conversational TTS |
+| `fal-ai/chatterbox/text-to-speech/multilingual` | Chatterbox, many languages |
+| `fal-ai/dia-tts` | Dia, expressive dialogue |
+| `fal-ai/orpheus-tts` | Orpheus, open source |
+| `fal-ai/f5-tts` | F5-TTS, voice cloning |
+| `fal-ai/vibevoice/7b` | VibeVoice 7B, conversation |
-### Transcription Models
+### Transcription models
| Model | Description |
|-------|-------------|
-| `fal-ai/whisper` | OpenAI Whisper on fal infra |
-| `fal-ai/wizper` | Faster-whisper variant with word-level timestamps |
-| `fal-ai/speech-to-text/turbo` | Turbo STT with diarization |
-| `fal-ai/elevenlabs/speech-to-text` | ElevenLabs STT |
+| `fal-ai/whisper` | OpenAI Whisper on fal |
+| `fal-ai/wizper` | A faster Whisper with word timestamps |
+| `fal-ai/speech-to-text/turbo` | Fast speech-to-text with speaker labels |
+| `fal-ai/elevenlabs/speech-to-text` | ElevenLabs speech-to-text |
-### Audio / Music Models
+### Music and audio models
| Model | Mode | Description |
|-------|------|-------------|
-| `fal-ai/minimax-music/v2.6` | Music | **New** — MiniMax Music 2.6, full vocal + instrumental compositions from a prompt |
+| `fal-ai/minimax-music/v2.6` | Music | MiniMax Music 2.6, vocals and instruments from a prompt |
| `fal-ai/minimax-music/v2.5` | Music | MiniMax Music 2.5 |
-| `fal-ai/minimax-music/v2` | Music | MiniMax Music v2 — supports `lyrics_prompt` |
-| `fal-ai/diffrhythm` | Music | DiffRhythm — prompt + lyrics |
-| `fal-ai/lyria2` | Music | Google Lyria 2 high-fidelity music |
-| `fal-ai/stable-audio-25/text-to-audio` | Music / Audio | Stability AI Stable Audio 2.5 |
-| `fal-ai/mmaudio-v2/text-to-audio` | Audio | MMAudio v2 text-to-audio |
-| `fal-ai/elevenlabs/sound-effects/v2` | SFX | ElevenLabs sound-effect generation |
-| `fal-ai/beatoven/sound-effect-generation` | SFX | Beatoven professional sound effects |
-| `fal-ai/thinksound` | Audio | Thinksound reasoning-based audio generation |
+| `fal-ai/minimax-music/v2` | Music | MiniMax Music v2, takes `lyrics_prompt` |
+| `fal-ai/diffrhythm` | Music | DiffRhythm, prompt and lyrics |
+| `fal-ai/lyria2` | Music | Google Lyria 2 |
+| `fal-ai/stable-audio-25/text-to-audio` | Music or audio | Stability AI Stable Audio 2.5 |
+| `fal-ai/mmaudio-v2/text-to-audio` | Audio | MMAudio v2 |
+| `fal-ai/elevenlabs/sound-effects/v2` | Sound effects | ElevenLabs sound effects |
+| `fal-ai/beatoven/sound-effect-generation` | Sound effects | Beatoven sound effects |
+| `fal-ai/thinksound` | Audio | Thinksound audio |
-> **Very new models** (e.g. `gemini-3.1-flash-tts`, `minimax-music/v2.6`, `beatoven/sound-effect-generation`) may not yet appear in `@fal-ai/client`'s type map — they still work as plain string model IDs, you just won't get autocomplete for their `modelOptions`.
+Some very new models, for example `fal-ai/gemini-3.1-flash-tts`, `fal-ai/minimax-music/v2.6`, and `fal-ai/beatoven/sound-effect-generation`, are not in the type map of `@fal-ai/client` yet. They work as plain string ids, with no autocomplete for `modelOptions`.
-## Environment Variables
-
-Create an API key at [fal.ai](https://fal.ai) and set it in your environment:
-
-```bash
-FAL_KEY=your-fal-api-key
-```
+## Advanced
-## API Reference
+### Image sizes
-### `falImage(model, config?)`
+`size` maps to `image_size`, or to `aspect_ratio` and `resolution`:
-Creates a fal.ai image adapter using the `FAL_KEY` environment variable or an explicit config.
-
-**Parameters:**
-
-- `model` - The fal.ai model ID (e.g., `"fal-ai/flux/dev"`)
-- `config.apiKey?` - Your fal.ai API key (falls back to `FAL_KEY` env var)
-- `config.proxyUrl?` - Proxy URL for client-side usage
-
-**Returns:** A `FalImageAdapter` instance for use with `generateImage()`.
-
-### `falVideo(model, config?)`
-
-Creates a fal.ai video adapter using the `FAL_KEY` environment variable or an explicit config.
-
-**Parameters:**
-
-- `model` - The fal.ai model ID (e.g., `"fal-ai/kling-video/v2.6/pro/text-to-video"`)
-- `config.apiKey?` - Your fal.ai API key (falls back to `FAL_KEY` env var)
-- `config.proxyUrl?` - Proxy URL for client-side usage
-
-**Returns:** A `FalVideoAdapter` instance for use with `generateVideo()` and `getVideoJobStatus()`.
-
-### `falLiveVideo(model, config?)`
-
-Creates a fal.ai live-video adapter for H3 Max Director. `generateLiveVideo()` returns the WMA app id on `result.model` (`fal-ai/minimax-h3-max-director`). Open that id with `wma(live.model)` through a server proxy that attaches `FAL_KEY`. Do not send `live.token` as `Key` credentials. Call `allowedFalLiveVideoProxyTarget()` in the proxy so it forwards only WMA `/ice`, `/session`, `/session/heartbeat`, and Director `/ice`.
-
-**Parameters:**
-
-- `model` - `"minimax/h3-max/director"`
-- `config.apiKey?` - Your fal.ai API key (falls back to `FAL_KEY` env var)
-
-**Returns:** A `FalLiveVideoAdapter` instance for use with `generateLiveVideo()`.
-
-See [Live Generation](../media/live-generation) for the browser connect step.
-
-### `falSpeech(model, config?)`
-
-Creates a fal.ai text-to-speech adapter.
-
-**Parameters:**
-
-- `model` - The fal.ai TTS model ID (e.g., `"fal-ai/kokoro/american-english"`)
-- `config.apiKey?` - Your fal.ai API key (falls back to `FAL_KEY` env var)
-- `config.proxyUrl?` - Proxy URL for client-side usage
-
-**Returns:** A `FalSpeechAdapter` instance for use with `generateSpeech()`. The adapter fetches the generated audio URL from fal and returns it as base64 in `result.audio`.
+| Form | `size` | Sent to fal as |
+|--------|---------|---------|
+| Named | `"landscape_16_9"` | `image_size: "landscape_16_9"` |
+| Width x height | `"1536x1024"` | `image_size: "1536x1024"` |
+| Ratio and resolution | `"16:9_4K"` | `aspect_ratio: "16:9"`, `resolution: "4K"` |
+| Ratio only | `"16:9"` | `aspect_ratio: "16:9"` |
-### `falTranscription(model, config?)`
+Named sizes change by model.
-Creates a fal.ai transcription (speech-to-text) adapter.
+### Video durations
-**Parameters:**
+`duration` gets its type for each model from the `EndpointTypeMap` of `@fal-ai/client`. Popular models also have `availableDurations()` and `snapDuration()`, for a UI slider:
-- `model` - The fal.ai STT model ID (e.g., `"fal-ai/whisper"`)
-- `config.apiKey?` - Your fal.ai API key (falls back to `FAL_KEY` env var)
-- `config.proxyUrl?` - Proxy URL for client-side usage
+| Model | `duration` type | `availableDurations()` |
+| --- | --- | --- |
+| `fal-ai/kling-video/v1.6/{standard,pro}/text-to-video`, `fal-ai/kling-video/v2.6/pro/{text,image}-to-video` | `"5"` or `"10"` | discrete |
+| `fal-ai/kling-video/v3/pro/{text,image}-to-video` | `"3"` to `"15"` | discrete |
+| `fal-ai/pika/v2.2/text-to-video` | `"5"` or `"10"` | discrete |
+| `fal-ai/ltx-2.3/{text,image}-to-video` (and `/fast`) | `"6"`, `"8"`, or `"10"` | discrete |
+| `fal-ai/luma-dream-machine/ray-2` | `"5s"` or `"9s"` | discrete |
+| `fal-ai/veo3.1`, `fal-ai/veo3.1/fast` (and `/image-to-video`), `fal-ai/veo3` (and `/image-to-video`) | `"4s"`, `"6s"`, or `"8s"` | discrete |
+| `fal-ai/wan-25-preview/text-to-video` | `"2"` to `"15"` | discrete |
+| `fal-ai/minimax/video-01` | not accepted | `{ kind: "none" }` |
+| `fal-ai/hunyuan-video-v1.5/text-to-video` | not accepted (uses `num_frames`) | `{ kind: "none" }` |
-**Returns:** A `FalTranscriptionAdapter` instance for use with `generateTranscription()`.
+```typescript
+import { generateVideo } from "@tanstack/ai";
+import { falVideo } from "@tanstack/ai-fal";
-### `falAudio(model, config?)`
+const adapter = falVideo("fal-ai/veo3.1");
+adapter.availableDurations(); // { kind: "discrete", values: ["4s", "6s", "8s"] }
+adapter.snapDuration(7); // "6s"
-Creates a fal.ai audio generation adapter (music and sound effects).
+await generateVideo({
+ adapter,
+ prompt: "A timelapse of a city skyline at dusk",
+ threadId: "thread-1",
+ duration: adapter.snapDuration(7),
+});
+```
-**Parameters:**
+Other models still get the `duration` type from the SDK when the endpoint has the field. For these models, `availableDurations()` returns `{ kind: "none" }`.
-- `model` - The fal.ai audio model ID (e.g., `"fal-ai/diffrhythm"`, `"fal-ai/minimax-music/v2"`)
-- `config.apiKey?` - Your fal.ai API key (falls back to `FAL_KEY` env var)
-- `config.proxyUrl?` - Proxy URL for client-side usage
+### Configuration
-**Returns:** A `FalAudioAdapter` instance for use with `generateAudio()`. The result contains a URL at `result.audio.url`.
+Each factory takes an optional `FalClientConfig`:
-### `getFalApiKeyFromEnv()`
+- `apiKey`: your fal key. If you do not set a config, the adapter reads `FAL_KEY`.
+- `proxyUrl`: a proxy URL on your server, for calls from the browser.
+- `fetch`: a custom `fetch` for the fal requests. The adapter wraps it to read the billed units.
-Reads the `FAL_KEY` environment variable. Throws if not set.
+```typescript
+import { falImage } from "@tanstack/ai-fal";
-**Returns:** The API key string.
+const adapter = falImage("fal-ai/nano-banana-pro", {
+ apiKey: process.env.FAL_KEY!,
+ proxyUrl: "https://your-server.com/api/fal/proxy",
+});
+```
-### `configureFalClient(config?)`
+### Live video proxy
-Configures the underlying `@fal-ai/client`. Called automatically by adapter constructors. Uses `proxyUrl` if provided, otherwise sets credentials from the API key.
+`generateLiveVideo()` returns the WMA app id on `result.model` (`fal-ai/minimax-h3-max-director`). In the browser:
-## Limitations
+- Open that id with `wma(live.model)` through a proxy on your server. The proxy attaches `FAL_KEY`.
+- Do not send `live.token` as `Key` credentials.
+- In the proxy, call `allowedFalLiveVideoProxyTarget()`. It lets the proxy forward only to WMA `/ice`, `/session`, `/session/heartbeat`, and Director `/ice`.
-- **No text/chat support** — Use OpenAI, Anthropic, Gemini, or another text adapter for `chat()`
-- **No tools support** — Tool definitions are not applicable to media generation
-- **No summarization** — Use a text adapter for `summarize()`
-- **Video is experimental** — The video generation API may change in future releases
+### API reference
-## Next Steps
+Each factory takes `model` first and an optional `config` (`apiKey`, `proxyUrl`, `fetch`). Without `apiKey`, it reads `FAL_KEY`.
-- [Getting Started](../getting-started/quick-start) - Learn the basics
-- [Other Adapters](./openai) - Explore other providers
+| Factory | Returns | Use it with |
+| --- | --- | --- |
+| `falImage(model, config?)` | `FalImageAdapter` | `generateImage()` |
+| `falVideo(model, config?)` | `FalVideoAdapter` | `generateVideo()`, `getVideoJobStatus()` |
+| `falLiveVideo(model, config?)` / `createFalLiveVideo(model, apiKey, config?)` | `FalLiveVideoAdapter` | `generateLiveVideo()` |
+| `falSpeech(model, config?)` | `FalSpeechAdapter` | `generateSpeech()`. The audio comes back as base64 in `result.audio`. |
+| `falTranscription(model, config?)` | `FalTranscriptionAdapter` | `generateTranscription()` |
+| `falAudio(model, config?)` | `FalAudioAdapter` | `generateAudio()`. The result has a URL at `result.audio.url`. |
+| `falFiles(config?)` | `FalFilesAdapter` | The [Files API](../advanced/files-api) |
+
+Helpers:
+
+- `getFalApiKeyFromEnv()`: reads `FAL_KEY`. Throws if it is not set.
+- `configureFalClient(config?)`: configures `@fal-ai/client`. The adapter constructors call it for you. It sets the credentials from the API key, and the proxy when you set `proxyUrl`.
diff --git a/docs/adapters/gemini-advanced.md b/docs/adapters/gemini-advanced.md
new file mode 100644
index 0000000000..7edc6d5f3f
--- /dev/null
+++ b/docs/adapters/gemini-advanced.md
@@ -0,0 +1,663 @@
+---
+title: "Google Gemini: Advanced"
+id: gemini-advanced
+order: 3
+description: "Advanced Gemini setup in TanStack AI: proxies, the experimental Interactions API, image sizes and edits, video understanding options, two-voice speech, every provider tool, and the API reference."
+keywords:
+ - tanstack ai
+ - gemini
+ - interactions api
+ - nano banana
+ - imagen
+ - provider tools
+ - adapter
+---
+
+This page has the details of the Gemini adapter. To get a first chat working, start at [Google Gemini](./gemini).
+
+## Configuration
+
+```typescript
+import { createGeminiChat, type GeminiTextConfig } from "@tanstack/ai-gemini";
+
+const config: Omit = {
+ baseURL: "https://generativelanguage.googleapis.com/v1beta", // Optional
+ defaultHeaders: { "X-Custom-Header": "value" }, // Optional
+};
+
+const adapter = createGeminiChat("gemini-3.8-flash", process.env.GEMINI_API_KEY!, config);
+```
+
+## Behind a proxy
+
+Send every request through a gateway, such as Cloudflare AI Gateway or a corporate proxy, with `baseURL` and `defaultHeaders`. These two option names are the same on every TanStack AI adapter, so one gateway config works for all of them.
+
+```typescript
+import { createGeminiChat } from "@tanstack/ai-gemini";
+
+const adapter = createGeminiChat("gemini-3.8-flash", process.env.GEMINI_API_KEY!, {
+ baseURL: "https://gateway.example.com/google-ai-studio",
+ defaultHeaders: { "cf-aig-authorization": `Bearer ${process.env.GATEWAY_TOKEN}` },
+});
+```
+
+- `baseURL` sets `httpOptions.baseUrl`, and `defaultHeaders` sets `httpOptions.headers`. If you set both forms, `baseURL` and `defaultHeaders` win.
+- This adapter ignores [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call), because the `@google/genai` SDK does not take a custom fetch.
+
+## Video understanding options
+
+By default, Gemini samples the video at 1 frame per second. Change the sampling in the part metadata:
+
+```typescript ignore
+// Watch a 30s-90s clip at half a frame per second.
+geminiVideoPart(file, { fps: 0.5, startOffset: "30s", endOffset: "90s" });
+```
+
+### Agentic understanding
+
+Sampling one time can miss detail in long or fast videos. Set `processing: "agentic"` to let the model drive. It moves through the timeline and gets frames, transcripts, and audio when it needs them.
+
+```typescript ignore
+geminiVideoPart(file, { processing: "agentic" });
+```
+
+Supported models:
+
+- `gemini-3.8-flash`
+- `gemini-3.7-flash`
+- `gemini-3.6-flash`
+- `gemini-3.5-flash-lite`
+
+With `agentic`, the adapter sends the request through the Interactions API of Gemini. You give the sampling rate in your prompt ("watch it at 2 fps"), not with `fps`. The result is deeper but slower, because the model looks at the video again on each turn.
+
+## Stateful conversations: Interactions API (experimental)
+
+The [Interactions API](https://ai.google.dev/gemini-api/docs/interactions) of Gemini keeps the conversation on the server. It is the Gemini equivalent of the OpenAI Responses API, and it is in Beta. You pass a `previous_interaction_id`, and the server keeps the transcript, so you do not send the full history on every turn. This also gets more cache hits for repeated starts of a prompt.
+
+The `geminiTextInteractions` adapter calls `client.interactions.create`. Right before `RUN_FINISHED`, it sends the interaction id from the server as an AG-UI `CUSTOM` event (`name: 'gemini.interactionId'`), so you can chain turns.
+
+> [!WARNING]
+> The Interactions API is experimental. Google marks it as Beta and can make breaking changes until it is generally available. Import the adapter from `@tanstack/ai-gemini/experimental`.
+
+What the adapter supports:
+
+- Text output and function tools.
+- The built-in tools `google_search`, `code_execution`, `url_context`, `file_search`, and `computer_use`.
+- `google_search_retrieval`, `google_maps`, and `mcp_server` throw on this adapter. Use `geminiText()` for the first two.
+
+### Basic usage
+
+```typescript ignore
+import { chat } from "@tanstack/ai";
+import { geminiTextInteractions } from "@tanstack/ai-gemini/experimental";
+
+// Turn 1: introduce yourself, capture the interaction id.
+let interactionId: string | undefined;
+
+for await (const chunk of chat({
+ adapter: geminiTextInteractions("gemini-3.5-flash"),
+ messages: [{ role: "user", content: "Hi, my name is Amir." }],
+ threadId: "thread-1",
+})) {
+ if (
+ chunk.type === "CUSTOM" &&
+ chunk.name === "gemini.interactionId" &&
+ chunk.value &&
+ typeof chunk.value === "object" &&
+ "interactionId" in chunk.value
+ ) {
+ interactionId = String(chunk.value.interactionId);
+ }
+}
+
+// Turn 2: only send the new turn. The server has the history.
+for await (const chunk of chat({
+ adapter: geminiTextInteractions("gemini-3.5-flash"),
+ messages: [{ role: "user", content: "What is my name?" }],
+ threadId: "thread-1",
+ modelOptions: {
+ previous_interaction_id: interactionId,
+ },
+})) {
+ // ...stream "Your name is Amir." back to the client.
+}
+```
+
+### Wiring with `useChat` (React)
+
+The Interactions API **does not accept a history of many turns without a `previous_interaction_id`**. If a chat client sends `[user, assistant, user]` to a new interaction, the adapter throws `cannot send prior conversation history on a fresh interaction`. To make `useChat` work, keep the id from the server and send it back on the next turn.
+
+**Server route** (for example, a TanStack Start handler):
+
+```typescript
+import {
+ chat,
+ chatParamsFromRequestBody,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
+import { geminiTextInteractions } from "@tanstack/ai-gemini/experimental";
+
+export async function POST({ request }: { request: Request }) {
+ const params = await chatParamsFromRequestBody(await request.json());
+
+ // The client sends previousInteractionId in `forwardedProps`.
+ const previousInteractionId =
+ typeof params.forwardedProps.previousInteractionId === "string"
+ ? params.forwardedProps.previousInteractionId
+ : undefined;
+
+ const stream = chat({
+ adapter: geminiTextInteractions("gemini-3.5-flash"),
+ messages: params.messages,
+ threadId: params.threadId,
+ modelOptions: {
+ previous_interaction_id: previousInteractionId,
+ store: true, // required for chaining on the next turn
+ },
+ });
+
+ return toServerSentEventsResponse(stream);
+}
+```
+
+**React client**:
+
+```tsx
+import { useEffect, useMemo, useState } from "react";
+import { fetchServerSentEvents, useChat } from "@tanstack/ai-react";
+
+function GeminiChat() {
+ const [interactionId, setInteractionId] = useState();
+
+ const forwardedProps = useMemo(
+ () => (interactionId ? { previousInteractionId: interactionId } : {}),
+ [interactionId],
+ );
+
+ const { messages, setMessages, sendMessage } = useChat({
+ connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
+ forwardedProps,
+ onCustomEvent: (eventType, data) => {
+ if (
+ eventType === "gemini.interactionId" &&
+ typeof data === "object" &&
+ data !== null &&
+ "interactionId" in data
+ ) {
+ setInteractionId(String(data.interactionId));
+ }
+ },
+ });
+
+ // A new provider or model starts a new chain on the server. Clear the id
+ // AND the local messages together. If you do not, the next turn sends
+ // many messages with no previous_interaction_id, and the adapter throws.
+ const [provider, setProvider] = useState("gemini-interactions");
+ useEffect(() => {
+ setInteractionId(undefined);
+ setMessages([]);
+ }, [provider]);
+
+ // ...render messages, call sendMessage(input)
+}
+```
+
+The full example is in [`examples/ts-react-chat`](https://github.com/TanStack/ai/tree/main/examples/ts-react-chat). See `src/routes/index.tsx` for the client and `src/routes/api.tanchat.ts` for the route.
+
+### How it differs from `geminiText`
+
+| | `geminiText` | `geminiTextInteractions` |
+| --- | --- | --- |
+| Endpoint | `models:generateContent` | `interactions:create` |
+| Conversation state | Stateless: send the full history each turn | Stateful: the server keeps the transcript through `previous_interaction_id` |
+| Provider options | camelCase (`stopSequences`, `responseModalities`, `safetySettings`) | snake_case (`generation_config`, `response_modalities`, `previous_interaction_id`) |
+| Built-in tools | `google_search`, `code_execution`, `url_context`, `file_search`, `google_maps`, `google_search_retrieval`, `computer_use` | `google_search`, `code_execution`, `url_context`, `file_search`, `computer_use`. Only the first four stream `CUSTOM` activity events |
+| Stability | GA | Experimental (Google Beta) |
+
+### Provider options
+
+The adapter has options for the Interactions API on `modelOptions`:
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { geminiTextInteractions } from "@tanstack/ai-gemini/experimental";
+
+const stream = chat({
+ adapter: geminiTextInteractions("gemini-3.5-flash"),
+ messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
+ // Goes out as generation_config.thinking_level and thinking_summaries.
+ reasoning: "low",
+ modelOptions: {
+ // Chain to the last turn. Pass it only on turn 2 and later.
+ previous_interaction_id: "int_abc123",
+
+ // Keep the interaction on the server (default true). Must be true for
+ // previous_interaction_id to work on the next turn.
+ store: true,
+
+ // System instruction for this interaction only. Send it again each turn.
+ system_instruction: "You are a helpful assistant.",
+
+ // snake_case generation config, not the camelCase config of geminiText.
+ generation_config: {
+ stop_sequences: [""],
+ },
+
+ response_modalities: ["text"],
+ },
+});
+```
+
+### Reading the interaction id
+
+The interaction id from the server arrives as an AG-UI `CUSTOM` event, right before `RUN_FINISHED`:
+
+```typescript ignore
+for await (const chunk of stream) {
+ if (
+ chunk.type === "CUSTOM" &&
+ chunk.name === "gemini.interactionId" &&
+ typeof chunk.value === "object" &&
+ chunk.value !== null &&
+ "interactionId" in chunk.value
+ ) {
+ const id = String(chunk.value.interactionId);
+ // Keep `id` where you store the conversation pointer of each user.
+ // Pass it back on the next turn as `previous_interaction_id`.
+ }
+}
+```
+
+### Limits
+
+Know these before you ship:
+
+- **A history of many turns needs `previous_interaction_id`.** The Interactions API has no stateless replay. More than one message in `messages` without a `previous_interaction_id` throws. A chat UI with a local history must keep the id from the server and chain (see [Wiring with `useChat`](#wiring-with-usechat-react)). When the provider or model changes, also clear the local messages.
+- **Tools, `system_instruction`, and `generation_config` belong to one interaction.** Google does not carry them over through `previous_interaction_id`. Pass them again on every turn that needs them.
+- `store: false` does not work with `previous_interaction_id` (there is no state to recall) or with `background: true`.
+- Retention, at the time of writing: **55 days on the Paid Tier, 1 day on the Free Tier.** See the [Interactions API docs](https://ai.google.dev/gemini-api/docs/interactions) of Google for the current policy.
+
+Tools and output:
+
+- The built-in tools (`google_search`, `code_execution`, `url_context`, `file_search`, `computer_use`) go out as request tools. The four search and code tools stream their activity back as AG-UI `CUSTOM` events with the raw Interactions delta: `gemini.googleSearchCall` and `gemini.googleSearchResult`, and the matching `codeExecutionCall`/`Result`, `urlContextCall`/`Result`, and `fileSearchCall`/`Result`.
+- The Interactions API sends no `CUSTOM` events for `computer_use`. Function-tool `TOOL_CALL_*` events do not change. If only built-in tools ran, `finishReason` stays `stop`.
+- `google_search_retrieval`, `google_maps`, and `mcp_server` throw an error on this adapter.
+- This adapter gives text only. For images and audio, use `geminiImage` and `geminiSpeech`.
+
+## Images
+
+`geminiImage` picks the API from the model:
+
+- A model in `GEMINI_NATIVE_IMAGE_MODELS` uses the Interactions API. `result.id` is the interaction id.
+- An Imagen model, or an unknown id, uses `generateImages`.
+
+Import the list or `isGeminiNativeImageModel` from `@tanstack/ai-gemini`.
+
+### Edit a Gemini native image
+
+You have an image from a Gemini native model. You want to change that image, without a second upload of it. Pass the last `id` back, and write only the change in `prompt`:
+
+```typescript
+import { generateImage } from "@tanstack/ai";
+import { geminiImage } from "@tanstack/ai-gemini";
+
+const adapter = geminiImage("gemini-3.1-flash-image");
+
+const first = await generateImage({
+ adapter,
+ prompt: "A red bicycle on a quiet street",
+ threadId: "thread-1",
+});
+
+const edited = await generateImage({
+ adapter,
+ prompt: "Make the bicycle blue",
+ threadId: "thread-1",
+ modelOptions: { previous_interaction_id: first.id },
+});
+```
+
+1. Generate the first image.
+2. Call `generateImage` again with `previous_interaction_id` set to `first.id`.
+3. Read `edited.images` for the new picture. `edited.id` is the id for the next edit.
+
+Keep the interaction, so that the next edit can run:
+
+- Leave `store` unset. The API default for `store` is `true`.
+- If you set `store` to `false`, a later call cannot use that id.
+- A free tier keeps a stored interaction for 1 day. A paid tier keeps it for 55 days.
+
+Also:
+
+- If the model is Imagen, do not pass `previous_interaction_id`. The call throws.
+- If this edit needs a new reference image, add that image part on this call. Do not send the image from `first`.
+- The Interactions API is in Beta.
+
+The same steps are in [Image Generation](../media/image-generation#edit-a-gemini-native-image).
+
+### Imagen
+
+```typescript
+import { generateImage } from "@tanstack/ai";
+import { geminiImage } from "@tanstack/ai-gemini";
+
+const result = await generateImage({
+ adapter: geminiImage("imagen-4.0-generate-001"),
+ prompt: "A futuristic cityscape at sunset",
+ threadId: "thread-1",
+ numberOfImages: 1,
+});
+
+console.log(result.images);
+```
+
+### Image sizes
+
+#### Gemini native models (Nano Banana)
+
+The size of a native model joins an aspect ratio and a resolution tier:
+
+```typescript ignore
+// Format: "aspectRatio_resolution"
+size: "16:9_4K"
+size: "1:1_2K"
+size: "9:16_1K"
+```
+
+Each model has its own set. TypeScript narrows `size` to the set of the model:
+
+| Model | Aspect ratios | Resolutions |
+|-------|---------------|-------------|
+| `gemini-3.1-flash-image` | `1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `4:5`, `5:4`, `9:16`, `16:9`, `21:9`, `1:4`, `4:1`, `1:8`, `8:1` | `512`, `1K`, `2K`, `4K` |
+| `gemini-3.1-flash-lite-image` | the same 14 as above (see the note) | `1K` only |
+| `gemini-nano-banana-2.1` | the same 14 as above | `1K`, `2K`, `4K` |
+| `gemini-3-pro-image` | `1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `4:5`, `5:4`, `9:16`, `16:9`, `21:9` | `1K`, `2K`, `4K` |
+
+- The `K` is case-sensitive. The API rejects `1k`.
+- The smallest tier is `512`, not `512px` or `0.5K`.
+- `9:21` is for Vertex and Cloud only, so this adapter does not accept it.
+
+> [!NOTE]
+> For `gemini-3.1-flash-lite-image`, the four banner ratios (`1:4`, `4:1`, `1:8`, `8:1`) are partly inferred. The Gemini API guide has no ratio table for this model. The set of 14 comes from the Cloud model page and from the statement in the guide of "a discrete set of 14 aspect ratios". If the API rejects those four, use the 10 standard ratios on this model.
+
+#### Imagen models
+
+Imagen models take WIDTHxHEIGHT. The adapter maps the size to an aspect ratio:
+
+| Size | Aspect ratio |
+|------|-------------|
+| `1024x1024` | 1:1 |
+| `1920x1080` | 16:9 |
+| `1080x1920` | 9:16 |
+
+You can also set the aspect ratio in `modelOptions`:
+
+```typescript
+import { generateImage } from "@tanstack/ai";
+import { geminiImage } from "@tanstack/ai-gemini";
+
+const result = await generateImage({
+ adapter: geminiImage("imagen-4.0-generate-001"),
+ prompt: "A landscape photo",
+ threadId: "thread-1",
+ modelOptions: {
+ aspectRatio: "16:9",
+ },
+});
+```
+
+### Image model options
+
+The two model groups call different APIs, so `modelOptions` has a different type for each.
+
+Imagen models (`generateImages`) take `GenerateImagesConfig` fields:
+
+```typescript ignore
+import { generateImage } from "@tanstack/ai";
+import { geminiImage } from "@tanstack/ai-gemini";
+
+const result = await generateImage({
+ adapter: geminiImage("imagen-4.0-generate-001"),
+ prompt: "...",
+ threadId: "thread-1",
+ modelOptions: {
+ aspectRatio: "16:9", // "1:1" | "3:4" | "4:3" | "9:16" | "16:9"
+ personGeneration: "DONT_ALLOW", // Control person generation
+ safetyFilterLevel: "BLOCK_SOME", // Safety filtering
+ },
+});
+```
+
+#### Gemini native models
+
+Set native image options on `modelOptions`:
+
+```typescript
+import { generateImage } from "@tanstack/ai";
+import { geminiImage, ThinkingLevel } from "@tanstack/ai-gemini";
+
+const result = await generateImage({
+ adapter: geminiImage("gemini-3.1-flash-image"),
+ prompt: "A beautiful garden",
+ threadId: "thread-1",
+ size: "16:9_4K",
+ modelOptions: {
+ thinkingConfig: { thinkingLevel: ThinkingLevel.LOW },
+ // imageConfig replaces one field from size. The 16:9 ratio from size stays.
+ // imageConfig accepts only aspectRatio and imageSize.
+ imageConfig: { imageSize: "2K" },
+ },
+});
+```
+
+- `thinkingConfig.thinkingLevel` takes `ThinkingLevel.MINIMAL`, `ThinkingLevel.LOW`, `ThinkingLevel.MEDIUM`, or `ThinkingLevel.HIGH`.
+- `thinkingConfig.thinkingBudget` throws.
+- If `systemInstruction` contains an image, a file, or a tool call, the call throws.
+- To edit, pass the last result `id` as `previous_interaction_id`. See [Edit a Gemini native image](#edit-a-gemini-native-image).
+
+See [Image Generation](../media/image-generation) for the full list of native options.
+
+## Speech with two speakers
+
+Gemini names the speakers in the prompt and maps each name to a voice. Pass `turns`, and the adapter does both for you. It uses the voice name as the speaker label:
+
+```typescript
+import { generateSpeech } from "@tanstack/ai";
+import { geminiSpeech } from "@tanstack/ai-gemini";
+
+const result = await generateSpeech({
+ adapter: geminiSpeech("gemini-3.1-flash-tts-preview"),
+ turns: [
+ { text: "Hey, how is it going?", voice: "Puck" },
+ { text: "Not bad, you?", voice: "Kore" },
+ ],
+ threadId: "thread-1",
+});
+```
+
+- Gemini accepts **two different voices** in one request. The adapter rejects a third before the call.
+- Each voice must be one of the prebuilt Gemini voices (`GeminiTTSVoices`).
+- Gemini reports no timings, so this adapter rejects `timestamps: true`.
+- Gemini TTS is experimental. Full support can need the Live API.
+
+## Provider tools
+
+Gemini has tools of its own, next to the function tools that you define. Import them from `@tanstack/ai-gemini/tools` and pass them into `chat({ tools: [...] })`.
+
+For the concept, a comparison table, and type gating, see [Provider Tools](../tools/provider-tools.md).
+
+### `codeExecutionTool`
+
+Gemini runs Python code in a sandbox and returns the results inline. It takes no arguments. Add it to the `tools` array.
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { geminiText } from "@tanstack/ai-gemini";
+import { codeExecutionTool } from "@tanstack/ai-gemini/tools";
+
+const stream = chat({
+ adapter: geminiText("gemini-3.8-flash"),
+ messages: [{ role: "user", content: "Calculate the first 10 Fibonacci numbers" }],
+ threadId: "thread-1",
+ tools: [codeExecutionTool()],
+});
+```
+
+**Supported models:** Gemini 1.5 Pro, Gemini 2.x, Gemini 2.5 and later. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+
+### `fileSearchTool`
+
+Searches the files that you uploaded to the Gemini File API. Pass a `FileSearch` config object with the stores to search.
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { geminiText } from "@tanstack/ai-gemini";
+import { fileSearchTool } from "@tanstack/ai-gemini/tools";
+
+const stream = chat({
+ adapter: geminiText("gemini-3.8-flash"),
+ messages: [{ role: "user", content: "Find the quarterly revenue figures" }],
+ threadId: "thread-1",
+ tools: [
+ fileSearchTool({
+ fileSearchStoreNames: ["fileSearchStores/my-file-search-store-123"],
+ }),
+ ],
+});
+```
+
+**Supported models:** Gemini 2.x and later. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+
+### `googleSearchTool`
+
+Gemini searches Google and uses the grounded results in its answer. Pass a `GoogleSearch` config, or call it with no arguments for the defaults.
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { geminiText } from "@tanstack/ai-gemini";
+import { googleSearchTool } from "@tanstack/ai-gemini/tools";
+
+const stream = chat({
+ adapter: geminiText("gemini-3.8-flash"),
+ messages: [{ role: "user", content: "What's the weather in Tokyo right now?" }],
+ threadId: "thread-1",
+ tools: [googleSearchTool()],
+});
+```
+
+**Supported models:** Gemini 1.5 Pro, Gemini 2.x, Gemini 2.5. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+
+When Google Search returns grounding data:
+
+- The assistant tool-call part has `metadata.providerExecuted: true` and the normalized `metadata.sources` array.
+- The raw `groundingMetadata` of Gemini stays under `metadata.gemini`.
+- The agent loop does not run this call in your app.
+
+### `googleSearchRetrievalTool`
+
+A Google Search variant for retrieval. It returns ranked passages from the web, with a dynamic retrieval mode that you can set. Pass a `GoogleSearchRetrieval` config.
+
+```typescript ignore
+import { chat } from "@tanstack/ai";
+import { geminiText } from "@tanstack/ai-gemini";
+import { googleSearchRetrievalTool } from "@tanstack/ai-gemini/tools";
+
+const stream = chat({
+ adapter: geminiText("gemini-3.1-pro-preview"),
+ messages: [{ role: "user", content: "Explain the latest JavaScript proposals" }],
+ threadId: "thread-1",
+ tools: [
+ googleSearchRetrievalTool({
+ dynamicRetrievalConfig: { mode: "MODE_DYNAMIC", dynamicThreshold: 0.7 },
+ }),
+ ],
+});
+```
+
+**Supported models:** Gemini 1.5 Pro and later. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+
+### `googleMapsTool`
+
+Connects Gemini to Google Maps for questions about places: directions, place search, and geocoding. Pass a `GoogleMaps` config, or call it with no arguments.
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { geminiText } from "@tanstack/ai-gemini";
+import { googleMapsTool } from "@tanstack/ai-gemini/tools";
+
+const stream = chat({
+ adapter: geminiText("gemini-2.5-pro"),
+ messages: [{ role: "user", content: "Find coffee shops near Union Square, SF" }],
+ threadId: "thread-1",
+ tools: [googleMapsTool()],
+});
+```
+
+**Supported models:** Gemini 2.5 and later. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+
+### `urlContextTool`
+
+Gets the content of the URLs in the conversation, so Gemini can use live web pages. It takes no arguments.
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { geminiText } from "@tanstack/ai-gemini";
+import { urlContextTool } from "@tanstack/ai-gemini/tools";
+
+const stream = chat({
+ adapter: geminiText("gemini-3.8-flash"),
+ messages: [{ role: "user", content: "Summarise https://example.com/article" }],
+ threadId: "thread-1",
+ tools: [urlContextTool()],
+});
+```
+
+**Supported models:** Gemini 2.x and later. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+
+### `computerUseTool`
+
+Gemini sees a virtual desktop through screenshots. It acts on the desktop with predefined computer-use functions. Give the `environment`. You can limit the functions with `excludedPredefinedFunctions`.
+
+```typescript ignore
+import { chat } from "@tanstack/ai";
+import { geminiText } from "@tanstack/ai-gemini";
+import { computerUseTool } from "@tanstack/ai-gemini/tools";
+
+const stream = chat({
+ adapter: geminiText("gemini-3.1-pro-preview"),
+ messages: [{ role: "user", content: "Navigate to example.com in the browser" }],
+ threadId: "thread-1",
+ tools: [
+ computerUseTool({
+ environment: "browser",
+ }),
+ ],
+});
+```
+
+**Supported models:** Gemini 2.5 and later. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+
+## API reference
+
+Each factory pair has the same shape. The short factory (`geminiText`, `geminiImage`, and the others) reads `GOOGLE_API_KEY` or `GEMINI_API_KEY` from the environment. The `create*` factory takes an explicit API key. Both take `model` as the first argument.
+
+| Factories | Creates |
+| --- | --- |
+| `geminiText(model, config?)` / `createGeminiChat(model, apiKey, config?)` | Chat adapter |
+| `geminiTextInteractions(model, config?)` / `createGeminiTextInteractions(model, apiKey, config?)` | Chat adapter on the Interactions API. Experimental. Import from `@tanstack/ai-gemini/experimental` |
+| `geminiSummarize(model, config?)` / `createGeminiSummarize(model, apiKey, config?)` | Summarization adapter |
+| `geminiImage(model, config?)` / `createGeminiImage(model, apiKey, config?)` | Image adapter. See [Images](#images) |
+| `geminiSpeech(model, config?)` / `createGeminiSpeech(model, apiKey, config?)` | Text-to-speech adapter. Experimental |
+| `geminiAudio(model, config?)` / `createGeminiAudio(model, apiKey, config?)` | Lyria music adapter. Experimental |
+| `geminiVideo(model, config?)` / `createGeminiVideo(model, apiKey, config?)` | Video adapter. Experimental |
+| `geminiEmbedding(model, config?)` / `createGeminiEmbedding(model, apiKey, config?)` | Embedding adapter |
+| `geminiFiles` / `createGeminiFiles` | Files API adapter. See [Files API](../advanced/files-api) |
+
+Chat adapter config:
+
+- `config.baseURL`: a custom base URL. Optional.
+- `config.defaultHeaders`: headers for every request. Optional.
+
+`adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts).
diff --git a/docs/adapters/gemini.md b/docs/adapters/gemini.md
index 75680c8ac8..5d427750b6 100644
--- a/docs/adapters/gemini.md
+++ b/docs/adapters/gemini.md
@@ -2,7 +2,7 @@
title: Google Gemini
id: gemini-adapter
order: 3
-description: "Use Google Gemini with TanStack AI — text, image generation via Imagen and Gemini native (NanoBanana), and experimental TTS via @tanstack/ai-gemini."
+description: "Use Google Gemini in TanStack AI with @tanstack/ai-gemini: multimodal chat with thinking, native and Imagen images, speech, embeddings, video understanding, and Google tools."
keywords:
- tanstack ai
- gemini
@@ -14,11 +14,11 @@ keywords:
- google ai
---
-The Google Gemini adapter provides access to Google's Gemini models, including text generation, image generation with both Imagen and Gemini native image models (NanoBanana), and experimental text-to-speech.
+The Gemini adapter calls Gemini for multimodal chat, thinking, and Google tools. It also calls image, speech, embedding, music, and video models.
-For a full working example with image generation, see the [media generation example app](https://github.com/TanStack/ai/tree/main/examples/ts-react-media).
+Need Gemini on Vertex AI, for regional endpoints and Google Cloud credentials? Use the [Vertex adapter](./vertex).
-## Installation
+## 1. Install
@@ -33,127 +33,120 @@ octane: @tanstack/ai-gemini
-Need Gemini on Vertex AI (regional endpoints and Google Cloud credentials)? Use the [Vertex adapter](./vertex).
+## 2. Set the API key
-## Basic Usage
+1. Open [Google AI Studio](https://aistudio.google.com/apikey).
+2. Create an API key.
+3. Put the key in the environment of your server:
-Use `gemini-3.8-flash` for chat with multimodal input, thinking, and built-in tools. It also supports structured output and caching.
-
-To set how hard Gemini 3.8 Flash thinks, pass `reasoning: "low"`, `"medium"`, or `"high"` to `chat()`. It has no `minimal` level. See [Reasoning](#reasoning).
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { geminiText } from "@tanstack/ai-gemini";
-
-const stream = chat({
- adapter: geminiText("gemini-3.8-flash"),
- messages: [{ role: "user", content: "Hello!" }],
-});
-```
-
-## Basic Usage - Custom API Key
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { createGeminiChat } from "@tanstack/ai-gemini";
-
-const adapter = createGeminiChat("gemini-3.1-pro-preview", process.env.GEMINI_API_KEY!, {
- // ... your config options
-});
-
-const stream = chat({
- adapter,
- messages: [{ role: "user", content: "Hello!" }],
-});
-```
-
-## Configuration
-
-```typescript
-import { createGeminiChat, type GeminiTextConfig } from "@tanstack/ai-gemini";
-
-const config: Omit = {
- baseURL: "https://generativelanguage.googleapis.com/v1beta", // Optional
- defaultHeaders: { "X-Custom-Header": "value" }, // Optional
-};
-
-const adapter = createGeminiChat("gemini-3.1-pro-preview", process.env.GEMINI_API_KEY!, config);
+```bash
+GEMINI_API_KEY=your-api-key-here
```
-
-## Behind a proxy
-
-Route every request through a gateway, such as Cloudflare AI Gateway or a corporate proxy, with `baseURL` and `defaultHeaders`. These two option names are the same on every TanStack AI adapter, so one gateway config works for all of them.
+The short factories (`geminiText`, `geminiImage`, and the others) read `GOOGLE_API_KEY` first, then `GEMINI_API_KEY`. To pass the key in code, use the `create*` factory of the same adapter:
```typescript
import { createGeminiChat } from "@tanstack/ai-gemini";
-const adapter = createGeminiChat("gemini-3.8-flash", process.env.GEMINI_API_KEY!, {
- baseURL: "https://gateway.example.com/google-ai-studio",
- defaultHeaders: { "cf-aig-authorization": `Bearer ${process.env.GATEWAY_TOKEN}` },
-});
+const adapter = createGeminiChat("gemini-3.8-flash", process.env.GEMINI_API_KEY!);
```
-`baseURL` sets `httpOptions.baseUrl` and `defaultHeaders` sets `httpOptions.headers`. If you set both forms, `baseURL` and `defaultHeaders` win.
-
-This adapter ignores [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call), because the `@google/genai` SDK does not take a custom fetch.
-
-## Example: Chat Completion
+## 3. Stream a chat from the server
```typescript
-import { chat, toServerSentEventsResponse } from "@tanstack/ai";
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
import { geminiText } from "@tanstack/ai-gemini";
export async function POST(request: Request) {
- const { messages } = await request.json();
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
const stream = chat({
- adapter: geminiText("gemini-3.1-pro-preview"),
+ adapter: geminiText("gemini-3.8-flash"),
messages,
+ threadId,
+ runId,
});
return toServerSentEventsResponse(stream);
}
```
-## Example: With Tools
+`gemini-3.8-flash` takes text, images, audio, video, and documents. It supports thinking, built-in tools, structured output, and caching.
-```typescript
-import { chat, toServerSentEventsResponse, toolDefinition } from "@tanstack/ai";
-import { geminiText } from "@tanstack/ai-gemini";
-import { z } from "zod";
-
-const getCalendarEventsDef = toolDefinition({
- name: "get_calendar_events",
- description: "Get calendar events for a date",
- inputSchema: z.object({
- date: z.string(),
- }),
-});
+## 4. Render the chat
-const getCalendarEvents = getCalendarEventsDef.server(async ({ date }) => {
- // Fetch calendar events
- return { events: [] };
-});
+The client is the same for every provider. Point `useChat` at your route:
-export async function POST(request: Request) {
- const { messages } = await request.json();
+```tsx
+import { useState } from "react";
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
- const stream = chat({
- adapter: geminiText("gemini-3.1-pro-preview"),
- messages,
- tools: [getCalendarEvents],
+export function Chat() {
+ const [input, setInput] = useState("");
+ const { messages, sendMessage } = useChat({
+ connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
});
- return toServerSentEventsResponse(stream);
+ return (
+
+ );
}
```
-## Video Understanding
+For Vue, Solid, Svelte, Angular, and the other frameworks, see [Quick Start](../getting-started/quick-start#3-render-the-chat).
+
+You now have a streaming Gemini chat. [Tools](../tools/tools) and [Structured Outputs](../structured-outputs/overview) work the same as with every adapter.
+
+## Images
+
+`geminiImage` takes two model groups:
+
+- **Gemini native image models** (Nano Banana), such as `gemini-nano-banana-2.1`. You set an aspect ratio and a resolution tier in `size`, for example `"16:9_4K"`. You can edit the last image with a short prompt.
+- **Imagen models**, such as `imagen-4.0-generate-001`. You set `size` as WIDTHxHEIGHT.
+
+```typescript
+import { generateImage } from "@tanstack/ai";
+import { geminiImage } from "@tanstack/ai-gemini";
+
+const result = await generateImage({
+ adapter: geminiImage("gemini-nano-banana-2.1"),
+ prompt: "A futuristic cityscape at sunset",
+ threadId: "thread-1",
+ numberOfImages: 1,
+ size: "16:9_4K",
+});
+
+console.log(result.images);
+```
+
+The sizes of each model, image edits, and the image options are on [Gemini: Advanced](./gemini-advanced#images). For a full app, see the [media generation example](https://github.com/TanStack/ai/tree/main/examples/ts-react-media).
-Ask questions about a video: "what happens at 0:30?", "summarize the demo", "list every product shown". Upload the file once, then chat about it.
+## Video understanding
-Video is too large to inline as base64, so upload it to the Gemini Files API first. `uploadGeminiFile()` uploads the file and waits until it is ready to use. `geminiVideoPart()` turns that upload into a message content part.
+Ask questions about a video: "what happens at 0:30?", "summarize the demo", "list every product shown". A video is too large to send inline, so upload it to the Gemini Files API first:
+
+- `uploadGeminiFile()` uploads the file and waits until it is ready.
+- `geminiVideoPart()` turns that upload into a content part for a message.
```typescript
import { chat, toServerSentEventsResponse } from "@tanstack/ai";
@@ -173,245 +166,126 @@ export async function POST(request: Request) {
],
},
],
+ threadId: "thread-1",
});
return toServerSentEventsResponse(stream);
}
```
-By default Gemini samples the video at 1 frame per second. Tune that single-pass sampling through the part metadata:
+To change the frame rate, cut a clip, or let the model explore the video, see [Video understanding options](./gemini-advanced#video-understanding-options).
-```typescript ignore
-// Watch a 30s-90s clip at half a frame per second.
-geminiVideoPart(file, { fps: 0.5, startOffset: "30s", endOffset: "90s" });
-```
+## Speech
-### Agentic understanding
+Gemini speech is experimental.
-Single-pass sampling can miss detail in long or fast-moving videos. Set `processing: "agentic"` to let the model drive: it navigates the timeline and pulls frames, transcripts, and audio on demand. Supported models:
+```typescript
+import { generateSpeech } from "@tanstack/ai";
+import { geminiSpeech } from "@tanstack/ai-gemini";
-- `gemini-3.8-flash`
-- `gemini-3.7-flash`
-- `gemini-3.6-flash`
-- `gemini-3.5-flash-lite`
+const result = await generateSpeech({
+ adapter: geminiSpeech("gemini-3.1-flash-tts-preview"),
+ text: "Hello from Gemini TTS!",
+ threadId: "thread-1",
+});
-```typescript ignore
-geminiVideoPart(file, { processing: "agentic" });
+console.log(result.audio); // Base64 encoded audio
```
-With `agentic`, the adapter routes the request through Gemini's Interactions API, and you express the sampling rate in your prompt ("watch it at 2 fps") instead of through `fps`. It is deeper but slower, since it re-analyzes the video on each turn.
-
-## Stateful Conversations — Interactions API (Experimental)
-
-Gemini's [Interactions API](https://ai.google.dev/gemini-api/docs/interactions) (currently in Beta) offers server-side conversation state — the Gemini equivalent of OpenAI's Responses API. Instead of replaying the full message history on every turn, you pass a `previous_interaction_id` and the server retains the transcript. This also improves cache hit rates for repeated prefixes.
-
-The `geminiTextInteractions` adapter routes through `client.interactions.create` and surfaces the server-assigned interaction id via an AG-UI `CUSTOM` event (`name: 'gemini.interactionId'`) emitted just before `RUN_FINISHED`, so you can chain turns.
-
-> **⚠️ Experimental.** Google marks the Interactions API as Beta and explicitly flags possible breaking changes until it reaches general availability. The adapter is exported from the `@tanstack/ai-gemini/experimental` subpath so the experimental status is load-bearing in your editor and bundle. Text output, function tools, and the built-in tools `google_search`, `code_execution`, `url_context`, `file_search`, and `computer_use` are supported. `google_search_retrieval`, `google_maps`, and `mcp_server` still throw on this adapter — use `geminiText()` for those or wait for follow-up work.
+For a dialogue with two voices, see [Speech with two speakers](./gemini-advanced#speech-with-two-speakers).
-### Basic Usage
+## Embeddings
-```typescript ignore
-import { chat } from "@tanstack/ai";
-import { geminiTextInteractions } from "@tanstack/ai-gemini/experimental";
-
-// Turn 1: introduce yourself, capture the interaction id.
-let interactionId: string | undefined;
-
-for await (const chunk of chat({
- adapter: geminiTextInteractions("gemini-3.5-flash"),
- messages: [{ role: "user", content: "Hi, my name is Amir." }],
-})) {
- if (
- chunk.type === "CUSTOM" &&
- chunk.name === "gemini.interactionId" &&
- chunk.value &&
- typeof chunk.value === "object" &&
- "interactionId" in chunk.value
- ) {
- interactionId = String(chunk.value.interactionId);
- }
-}
+```typescript
+import { embed } from "@tanstack/ai";
+import { geminiEmbedding } from "@tanstack/ai-gemini";
-// Turn 2: only send the new turn's content — the server has the history.
-for await (const chunk of chat({
- adapter: geminiTextInteractions("gemini-3.5-flash"),
- messages: [{ role: "user", content: "What is my name?" }],
+const result = await embed({
+ adapter: geminiEmbedding("gemini-embedding-001"),
+ input: "a red guitar",
+ dimensions: 1536,
modelOptions: {
- previous_interaction_id: interactionId,
+ taskType: "RETRIEVAL_DOCUMENT",
},
-})) {
- // ...stream "Your name is Amir." back to the client.
-}
-```
-
-### Wiring with `useChat` (React)
-
-The Interactions API is stateful and **does not accept multi-turn history without a `previous_interaction_id`** — if a chat client sends `[user, assistant, user]` to a fresh interaction the adapter throws `cannot send prior conversation history on a fresh interaction`. To make `useChat` work, persist the server-assigned id and send it back on the next turn:
-
-**Server route** (e.g. TanStack Start handler):
-
-```typescript
-import {
- chat,
- chatParamsFromRequestBody,
- toServerSentEventsResponse,
-} from "@tanstack/ai";
-import { geminiTextInteractions } from "@tanstack/ai-gemini/experimental";
-
-export async function POST({ request }: { request: Request }) {
- const params = await chatParamsFromRequestBody(await request.json());
-
- // The client sends body.previousInteractionId; AG-UI maps `body` into
- // `forwardedProps` on the wire.
- const previousInteractionId =
- typeof params.forwardedProps.previousInteractionId === "string"
- ? params.forwardedProps.previousInteractionId
- : undefined;
-
- const stream = chat({
- adapter: geminiTextInteractions("gemini-3.5-flash"),
- messages: params.messages,
- modelOptions: {
- previous_interaction_id: previousInteractionId,
- store: true, // required for chaining on the next turn
- },
- });
+});
- return toServerSentEventsResponse(stream);
-}
+console.log(result.embeddings[0]?.vector);
```
-**React client**:
+- The embedding endpoint of the Gemini API does not report token usage, so `result.usage` is absent.
+- This adapter does not support `multimodalembedding@001`, which is a Vertex-only model.
-```tsx
-import { useEffect, useMemo, useState } from "react";
-import { fetchServerSentEvents, useChat } from "@tanstack/ai-react";
+See the [Embeddings guide](../embeddings.md).
-function GeminiChat() {
- const [interactionId, setInteractionId] = useState();
+## Summarization
- const body = useMemo(
- () => (interactionId ? { previousInteractionId: interactionId } : {}),
- [interactionId],
- );
+```typescript
+import { summarize } from "@tanstack/ai";
+import { geminiSummarize } from "@tanstack/ai-gemini";
- const { messages, setMessages, sendMessage } = useChat({
- connection: fetchServerSentEvents("/api/chat"),
- body,
- onCustomEvent: (eventType, data) => {
- if (
- eventType === "gemini.interactionId" &&
- typeof data === "object" &&
- data !== null &&
- "interactionId" in data
- ) {
- setInteractionId(String(data.interactionId));
- }
- },
- });
+const result = await summarize({
+ adapter: geminiSummarize("gemini-3.1-pro-preview"),
+ text: "Your long text to summarize...",
+ threadId: "thread-1",
+ maxLength: 100,
+ style: "concise", // "concise" | "bullet-points" | "paragraph"
+});
- // Switching provider/model resets the server-side chain — drop the id
- // AND the local message history together, otherwise the next turn
- // ships multi-turn messages with no previous_interaction_id and the
- // adapter errors out.
- const [provider, setProvider] = useState("gemini-interactions");
- useEffect(() => {
- setInteractionId(undefined);
- setMessages([]);
- }, [provider]);
-
- // ...render messages, call sendMessage(input)
-}
+console.log(result.summary);
```
-The full working example is in [`examples/ts-react-chat`](https://github.com/TanStack/ai/tree/main/examples/ts-react-chat) — see `src/routes/index.tsx` for the client and `src/routes/api.tanchat.ts` for the route.
-
-### How it differs from `geminiText`
+## Music, video, voice, and files
-| Concern | `geminiText` | `geminiTextInteractions` |
-| --- | --- | --- |
-| Underlying endpoint | `models:generateContent` | `interactions:create` |
-| Conversation state | Stateless — send full history each turn | Stateful — server retains transcript via `previous_interaction_id` |
-| Provider options shape | camelCase (`stopSequences`, `responseModalities`, `safetySettings`) | snake_case (`generation_config`, `response_modalities`, `previous_interaction_id`) |
-| Built-in tools | `google_search`, `code_execution`, `url_context`, `file_search`, `google_maps`, `google_search_retrieval`, `computer_use` | `google_search`, `code_execution`, `url_context`, `file_search`, `computer_use` (only the first four stream `CUSTOM` event activity; `computer_use` is accepted in the request but does not currently emit per-delta events) |
-| Stability | GA | Experimental (Google Beta) |
+- **Music**: `geminiAudio` makes music with the Lyria models. Experimental. See [Audio Generation](../media/audio-generation).
+- **Video**: `geminiVideo` makes videos with Veo and Gemini Omni. Experimental. See [Video Generation](../media/video-generation).
+- **Realtime voice**: `geminiRealtime` and `geminiRealtimeToken` run a live voice chat. See [Realtime Voice Chat](../media/realtime-chat).
+- **Files**: `geminiFiles` uploads media to the Gemini Files API. See [Files API](../advanced/files-api).
-### Provider Options
+## Provider tools
-The adapter exposes Interactions-specific options on `modelOptions`:
+Gemini runs some tools itself, such as Google Search and a code sandbox. Import them from `@tanstack/ai-gemini/tools` and add them to `tools`:
```typescript
import { chat } from "@tanstack/ai";
-import { geminiTextInteractions } from "@tanstack/ai-gemini/experimental";
+import { geminiText } from "@tanstack/ai-gemini";
+import { googleSearchTool } from "@tanstack/ai-gemini/tools";
const stream = chat({
- adapter: geminiTextInteractions("gemini-3.5-flash"),
- messages: [{ role: "user", content: "Hello!" }],
- // Goes out as generation_config.thinking_level and thinking_summaries.
- reasoning: "low",
- modelOptions: {
- // Stateful chaining — passed only on turn 2+.
- previous_interaction_id: "int_abc123",
-
- // Persist the interaction server-side (default true). Must be true for
- // previous_interaction_id to work on the *next* turn.
- store: true,
+ adapter: geminiText("gemini-3.8-flash"),
+ messages: [{ role: "user", content: "What's the weather in Tokyo right now?" }],
+ threadId: "thread-1",
+ tools: [googleSearchTool()],
+});
+```
- // Per-request system instruction (interaction-scoped — re-specify each turn).
- system_instruction: "You are a helpful assistant.",
+Tools that search or read:
- // snake_case generation config distinct from geminiText's camelCase one.
- generation_config: {
- stop_sequences: [""],
- },
+- `googleSearchTool` and `googleSearchRetrievalTool`: search Google.
+- `googleMapsTool`: find places and directions.
+- `urlContextTool`: read the URLs in the conversation.
+- `fileSearchTool`: search files in Gemini file search stores.
- response_modalities: ["text"],
- },
-});
-```
+Tools that act:
-### Reading the interaction id
-
-The server's interaction id arrives as an AG-UI `CUSTOM` event emitted just before `RUN_FINISHED`:
-
-```typescript ignore
-for await (const chunk of stream) {
- if (
- chunk.type === "CUSTOM" &&
- chunk.name === "gemini.interactionId" &&
- typeof chunk.value === "object" &&
- chunk.value !== null &&
- "interactionId" in chunk.value
- ) {
- const id = String(chunk.value.interactionId);
- // Persist `id` wherever you store per-user conversation pointers —
- // pass it back on the next turn as `previous_interaction_id`.
- }
-}
-```
+- `codeExecutionTool`: run Python in a sandbox.
+- `computerUseTool`: control a virtual desktop.
-### Caveats
+Each tool, with a full sample, is on [Gemini: Advanced](./gemini-advanced#provider-tools). For which models take which tool, see [Provider Tools](../tools/provider-tools.md).
-- **Multi-turn history requires `previous_interaction_id`.** The Interactions API has no stateless replay path — sending more than one message in `messages` without a `previous_interaction_id` throws. Chat UIs that maintain local history must capture the server-assigned id and chain (see [Wiring with `useChat`](#wiring-with-usechat-react)). On provider/model switch, also clear the local message buffer.
-- **Tools, `system_instruction`, and `generation_config` are interaction-scoped.** Per Google's docs these are NOT inherited from a prior interaction via `previous_interaction_id` — pass them again on every turn you need them.
-- `store: false` is incompatible with `previous_interaction_id` (no state to recall) and with `background: true`.
-- Retention (as of the time of writing): **55 days on the Paid Tier, 1 day on the Free Tier.** See [Google's Interactions API docs](https://ai.google.dev/gemini-api/docs/interactions) for current retention policy.
-- Built-in tools in scope (`google_search`, `code_execution`, `url_context`, `file_search`, `computer_use`) are wired through as request tools. Per-delta activity for the four search/exec tools streams back as AG-UI `CUSTOM` events — `gemini.googleSearchCall` / `gemini.googleSearchResult` (and the matching `codeExecutionCall`/`Result`, `urlContextCall`/`Result`, `fileSearchCall`/`Result`) — carrying the raw Interactions delta. `computer_use` is accepted in the request but the Interactions API does not currently emit per-delta `CUSTOM` events for it. Function-tool `TOOL_CALL_*` events are unchanged, and `finishReason` stays `stop` when only built-in tools ran.
-- `google_search_retrieval`, `google_maps`, and `mcp_server` still throw a targeted error on this adapter. Use `geminiText()` for the first two, or wait for a dedicated follow-up for `mcp_server`.
-- Image and audio output via Interactions aren't routed through this adapter yet — it's text-only. Use `geminiImage` / `geminiSpeech` for non-text generation for now.
+## Models and options
-## Model Options
+### Model options
-Gemini supports various model-specific options. Sampling parameters live here too — `temperature`, `topP`, and `maxOutputTokens` — rather than as root-level props on `chat()`:
+Provider options go in `modelOptions`, with the camelCase names of the Gemini SDK. This includes the sampling options `temperature`, `topP`, and `maxOutputTokens`:
```typescript
import { chat } from "@tanstack/ai";
import { geminiText } from "@tanstack/ai-gemini";
const stream = chat({
- adapter: geminiText("gemini-3.1-pro-preview"),
+ adapter: geminiText("gemini-3.8-flash"),
messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
modelOptions: {
maxOutputTokens: 2048,
temperature: 0.7,
@@ -422,7 +296,8 @@ const stream = chat({
});
```
-> If you previously passed `temperature` / `topP` / `maxTokens` at the root of `chat()`, see [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options).
+- For JSON output, set `responseMimeType: "application/json"`. For typed output, use [Structured Outputs](../structured-outputs/overview).
+- Sampling options do not go at the root of `chat()`. See [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options).
### Reasoning
@@ -435,11 +310,12 @@ import { geminiText } from "@tanstack/ai-gemini";
const stream = chat({
adapter: geminiText("gemini-3.8-flash"),
messages: [{ role: "user", content: "Plan a database migration." }],
+ threadId: "thread-1",
reasoning: "high",
});
```
-What goes on the wire:
+What goes to Gemini:
- Gemini 3 models: `thinkingConfig.thinkingLevel`.
- Gemini 2.5 models: `thinkingConfig.thinkingBudget`. Set it with `budgetTokens`, or the level picks it. `off` sends a budget of `0`.
@@ -464,553 +340,35 @@ import { geminiText } from "@tanstack/ai-gemini";
const stream = chat({
adapter: geminiText("gemini-2.5-pro"),
messages: [{ role: "user", content: "Check this proof." }],
+ threadId: "thread-1",
reasoning: { level: "high", budgetTokens: 8000 },
});
```
-Image models keep their own `thinkingConfig` in `modelOptions`. See [Image Model Options](#image-model-options).
-
-### Structured Output
-
-Configure structured output format:
-
-```typescript ignore
-modelOptions: {
- responseMimeType: "application/json",
-}
-```
-
-## Summarization
-
-Summarize long text content:
-
-```typescript
-import { summarize } from "@tanstack/ai";
-import { geminiSummarize } from "@tanstack/ai-gemini";
-
-const result = await summarize({
- adapter: geminiSummarize("gemini-3.1-pro-preview"),
- text: "Your long text to summarize...",
- maxLength: 100,
- style: "concise", // "concise" | "bullet-points" | "paragraph"
-});
-
-console.log(result.summary);
-```
-
-## Embeddings
-
-Generate embedding vectors with gemini-embedding-001:
-
-```typescript
-import { embed } from "@tanstack/ai";
-import { geminiEmbedding } from "@tanstack/ai-gemini";
-
-const result = await embed({
- adapter: geminiEmbedding("gemini-embedding-001"),
- input: "a red guitar",
- dimensions: 1536,
- modelOptions: {
- taskType: "RETRIEVAL_DOCUMENT",
- },
-});
-
-console.log(result.embeddings[0]?.vector);
-```
-
-> The Gemini API's embedding endpoint does not report token usage, so
-> `result.usage` is absent. The Vertex-only multimodal embedding model
-> (`multimodalembedding@001`) is not supported by this adapter.
-
-See the [Embeddings guide](../embeddings.md) for the full API.
-
-## Image Generation
-
-The Gemini adapter has two image model groups:
+Image models keep their own `thinkingConfig` in `modelOptions`. See [Image model options](./gemini-advanced#image-model-options).
-- **Gemini native image models** (NanoBanana). A model such as `gemini-3.1-flash-image` uses the Interactions API. You set an aspect ratio. Some models also take a resolution tier (`512`, `1K`, `2K`, or `4K`). TypeScript checks the set for the model.
-- **Imagen models**. A model such as `imagen-4.0-generate-001` uses `generateImages`. You set the size as WIDTHxHEIGHT.
+### Models
-When the model is in `GEMINI_NATIVE_IMAGE_MODELS`, the adapter uses the Interactions API. An Imagen model uses `generateImages`. An unknown id uses `generateImages`. Import the list or `isGeminiNativeImageModel` from `@tanstack/ai-gemini`.
+Newest first. The package exports the full lists, for example `GeminiTextModels`.
-### Example: Gemini Native Image Generation (NanoBanana)
-
-From the [media generation example app](https://github.com/TanStack/ai/tree/main/examples/ts-react-media):
-
-```typescript
-import { generateImage } from "@tanstack/ai";
-import { geminiImage } from "@tanstack/ai-gemini";
-
-const result = await generateImage({
- adapter: geminiImage("gemini-3.1-flash-image"),
- prompt: "A futuristic cityscape at sunset",
- numberOfImages: 1,
- size: "16:9_4K",
-});
-
-console.log(result.images);
-```
-
-### Edit a Gemini native image
-
-You have an image from a Gemini native model. You want to change that image without a second upload of that image.
-
-Pass the last `id` back. Write only the change in `prompt`:
-
-```typescript
-import { generateImage } from "@tanstack/ai";
-import { geminiImage } from "@tanstack/ai-gemini";
-
-const adapter = geminiImage("gemini-3.1-flash-image");
-
-const first = await generateImage({
- adapter,
- prompt: "A red bicycle on a quiet street",
-});
-
-const edited = await generateImage({
- adapter,
- prompt: "Make the bicycle blue",
- modelOptions: { previous_interaction_id: first.id },
-});
-```
-
-1. Generate the first image.
-2. Call `generateImage` again with `previous_interaction_id` set to `first.id`.
-3. Read `edited.images` for the new picture.
-
-`edited.id` is the id for the next edit.
-
-Keep the interaction so the next edit can run:
-
-1. Leave `store` unset.
-2. If you set `store` to `false`, a later call cannot use that id.
-
-The API default for `store` is `true`.
-
-A free tier keeps a stored interaction for 1 day. A paid tier keeps it for 55 days.
-
-If the model is Imagen, do not pass `previous_interaction_id`. The call throws.
-
-If this edit needs a new reference image, add that image part on this call. Do not send the image from `first`.
-
-The Interactions API is in Beta.
-
-The same steps are in [Image Generation](../media/image-generation#edit-a-gemini-native-image).
-
-### Example: Imagen
-
-```typescript
-import { generateImage } from "@tanstack/ai";
-import { geminiImage } from "@tanstack/ai-gemini";
-
-const result = await generateImage({
- adapter: geminiImage("imagen-4.0-generate-001"),
- prompt: "A futuristic cityscape at sunset",
- numberOfImages: 1,
-});
-
-console.log(result.images);
-```
-
-### Image Size Options
-
-#### Gemini Native Models (NanoBanana)
-
-Gemini native image models use a template literal size format combining aspect ratio and resolution tier:
-
-```typescript ignore
-// Format: "aspectRatio_resolution"
-size: "16:9_4K"
-size: "1:1_2K"
-size: "9:16_1K"
-```
-
-The accepted set differs per model, and each model's `size` is narrowed to its own set at compile time:
-
-| Model | Aspect ratios | Resolutions |
-|-------|---------------|-------------|
-| `gemini-3.1-flash-image` | `1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `4:5`, `5:4`, `9:16`, `16:9`, `21:9`, `1:4`, `4:1`, `1:8`, `8:1` | `512`, `1K`, `2K`, `4K` |
-| `gemini-3.1-flash-lite-image` | same 14 as above (see note) | `1K` only |
-| `gemini-nano-banana-2.1` | same 14 as above | `1K`, `2K`, `4K` |
-| `gemini-3-pro-image` | `1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `4:5`, `5:4`, `9:16`, `16:9`, `21:9` | `1K`, `2K`, `4K` |
-| `gemini-2.5-flash-image` | same 10 as above | none — pass the bare ratio, e.g. `size: "16:9"` |
-
-The `K` is case-sensitive (`1k` is rejected by the API), the smallest tier's token is `512` (not `512px` or `0.5K`), and `9:21` is Vertex/Cloud-only so it is not accepted here. Google documents no `image_size` for `gemini-2.5-flash-image`, so that model takes a bare aspect ratio and the adapter sends no `imageSize`.
-
-> **Note on `gemini-3.1-flash-lite-image`.** The four extreme banner ratios (`1:4`, `4:1`, `1:8`, `8:1`) are partially inferred for this model. Unlike the other three, Flash Lite has no per-model ratio table in Google's Gemini API guide; the 14-value set comes from the Cloud model page's explicit enumeration plus the guide's bare "a discrete set of 14 aspect ratios" assertion. The only Gemini-API enumeration for it is a 10-item bullet headed "New aspect ratios", which we read as a what's-new list rather than an exhaustive one. If the API rejects those four in practice, prefer the 10 standard ratios on this model.
-
-#### Imagen Models
-
-Imagen models use WIDTHxHEIGHT format, which maps to aspect ratios internally:
-
-| Size | Aspect Ratio |
-|------|-------------|
-| `1024x1024` | 1:1 |
-| `1920x1080` | 16:9 |
-| `1080x1920` | 9:16 |
-
-Alternatively, you can specify the aspect ratio directly in Model Options:
-
-```typescript
-import { generateImage } from "@tanstack/ai";
-import { geminiImage } from "@tanstack/ai-gemini";
-
-const result = await generateImage({
- adapter: geminiImage("imagen-4.0-generate-001"),
- prompt: "A landscape photo",
- modelOptions: {
- aspectRatio: "16:9",
- },
-});
-```
-
-### Image Model Options
-
-`modelOptions` is typed per model family, because the two families hit different APIs.
-
-Imagen models (`generateImages`) take `GenerateImagesConfig` fields:
-
-```typescript ignore
-import { generateImage } from "@tanstack/ai";
-import { geminiImage } from "@tanstack/ai-gemini";
-
-const result = await generateImage({
- adapter: geminiImage("imagen-4.0-generate-001"),
- prompt: "...",
- modelOptions: {
- aspectRatio: "16:9", // "1:1" | "3:4" | "4:3" | "9:16" | "16:9"
- personGeneration: "DONT_ALLOW", // Control person generation
- safetyFilterLevel: "BLOCK_SOME", // Safety filtering
- },
-});
-```
-
-#### Gemini native models
-
-Set native image options on `modelOptions`:
-
-```typescript
-import { generateImage } from "@tanstack/ai";
-import { geminiImage, ThinkingLevel } from "@tanstack/ai-gemini";
-
-const result = await generateImage({
- adapter: geminiImage("gemini-3.1-flash-image"),
- prompt: "A beautiful garden",
- size: "16:9_4K",
- modelOptions: {
- thinkingConfig: { thinkingLevel: ThinkingLevel.LOW },
- // imageConfig replaces one field from size. The 16:9 ratio from size stays.
- // imageConfig accepts only aspectRatio and imageSize.
- imageConfig: { imageSize: "2K" },
- },
-});
-```
-
-Set `thinkingConfig.thinkingLevel` to one of these values:
-
-- `ThinkingLevel.MINIMAL`
-- `ThinkingLevel.LOW`
-- `ThinkingLevel.MEDIUM`
-- `ThinkingLevel.HIGH`
-
-`thinkingConfig.thinkingBudget` throws.
-
-If `systemInstruction` contains an image, a file, or a tool call, the call throws.
-
-Pass the last result `id` as `previous_interaction_id`. Read [Edit a Gemini native image](#edit-a-gemini-native-image) for the edit steps.
-
-See [Image Generation](../media/image-generation) for the full native option list.
-
-## Text-to-Speech (Experimental)
-
-> **Note:** Gemini TTS is experimental and may require the Live API for full functionality.
-
-Generate speech from text:
-
-```typescript
-import { generateSpeech } from "@tanstack/ai";
-import { geminiSpeech } from "@tanstack/ai-gemini";
-
-const result = await generateSpeech({
- adapter: geminiSpeech("gemini-3.1-flash-tts-preview"),
- text: "Hello from Gemini TTS!",
-});
-
-console.log(result.audio); // Base64 encoded audio
-```
-
-Gemini names speakers in the prompt and maps each name to a voice. Pass `turns`
-and the adapter does both for you, using the voice name as the speaker label:
-
-```typescript
-import { generateSpeech } from "@tanstack/ai";
-import { geminiSpeech } from "@tanstack/ai-gemini";
-
-const result = await generateSpeech({
- adapter: geminiSpeech("gemini-3.1-flash-tts-preview"),
- turns: [
- { text: "Hey, how is it going?", voice: "Puck" },
- { text: "Not bad, you?", voice: "Kore" },
- ],
-});
-```
-
-Gemini accepts **two distinct voices** per request, and a third is rejected
-before the call is made. Each voice must be one of the prebuilt Gemini voices.
-Gemini reports no timings, so `timestamps: true` is rejected on this adapter.
-
-## Environment Variables
-
-Set your API key in environment variables:
-
-```bash
-GEMINI_API_KEY=your-api-key-here
-# or
-GOOGLE_API_KEY=your-api-key-here
-```
-
-## Getting an API Key
-
-1. Go to [Google AI Studio](https://aistudio.google.com/apikey)
-2. Create a new API key
-3. Add it to your environment variables
-
-## Popular Image Models
-
-### Gemini Native Image Models (NanoBanana)
-
-These models use the Interactions API. Each model has its own resolution tiers.
-
-| Model | Description |
-|-------|-------------|
-| `gemini-3.1-flash-image` | Nano Banana 2 — latest and fastest Gemini native image generation (512/1K/2K/4K) |
-| `gemini-3.1-flash-lite-image` | Nano Banana 2 Lite — ultra-low-latency, low-cost image generation (1K only) |
-| `gemini-nano-banana-2.1` | Nano Banana 2.1 (1K/2K/4K, no 512 tier) |
-| `gemini-3-pro-image` | Nano Banana Pro — higher quality Gemini native image generation (1K/2K/4K) |
-| `gemini-2.5-flash-image` | Nano Banana — legacy; shuts down 2026-10-02. Takes a bare aspect ratio |
-
-The `gemini-3.1-flash-image-preview` and `gemini-3-pro-image-preview` ids were shut down on 2026-06-25. They remain in the type union as deprecated aliases so existing code compiles, but calls to them fail — use the GA ids above.
-
-### Imagen Models
-
-These models use the dedicated `generateImages` API.
-
-| Model | Description |
-|-------|-------------|
-| `imagen-4.0-ultra-generate-001` | Best quality Imagen image generation |
-| `imagen-4.0-generate-001` | High quality Imagen image generation |
-| `imagen-4.0-fast-generate-001` | Fast Imagen image generation |
-
-## API Reference
-
-Every factory pair follows the same shape: the short factory (`geminiText`, `geminiImage`, …) reads `GEMINI_API_KEY` (or `GOOGLE_API_KEY`) from the environment, while the `create*` variant takes an explicit API key. Both take `model` as the first argument.
-
-### `geminiText(model, config?)` / `createGeminiChat(model, apiKey, config?)`
-
-Creates a Gemini text/chat adapter.
-
-`adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts).
-
-**Parameters:**
-
-- `model` - Gemini chat model id (e.g. `"gemini-3.1-pro-preview"`)
-- `config?.baseURL` - Custom base URL (optional)
-
-### `geminiTextInteractions(model, config?)` / `createGeminiTextInteractions(model, apiKey, config?)` (experimental)
-
-Creates a Gemini Interactions API text adapter. Backs the stateful conversation pattern via `previous_interaction_id`. Exported from `@tanstack/ai-gemini/experimental`.
-
-**Parameters:**
-
-- `model` - Gemini chat model id (e.g. `"gemini-3.5-flash"`)
-- `config?.baseURL` - Custom base URL (optional)
-
-### `geminiSummarize(model, config?)` / `createGeminiSummarize(model, apiKey, config?)`
-
-Creates a Gemini summarization adapter.
-
-### `geminiImage(model, config?)` / `createGeminiImage(model, apiKey, config?)`
-
-Creates a Gemini image adapter. When the model is in `GEMINI_NATIVE_IMAGE_MODELS`, the call uses the Interactions API. `result.id` is the interaction id. An Imagen model uses `generateImages`. An unknown id uses `generateImages`.
-
-### `geminiSpeech(model, config?)` / `createGeminiSpeech(model, apiKey, config?)`
-
-Creates a Gemini text-to-speech adapter. _Experimental._
-
-### `geminiAudio(model, config?)` / `createGeminiAudio(model, apiKey, config?)`
-
-Creates a Gemini Lyria music generation adapter. _Experimental._
-
-## Next Steps
-
-- [Image Generation Guide](../media/image-generation) - Learn more about image generation
-- [Media Generation Example](https://github.com/TanStack/ai/tree/main/examples/ts-react-media) - Full working example with Gemini and fal.ai
-- [Getting Started](../getting-started/quick-start) - Learn the basics
-- [Tools Guide](../tools/tools) - Learn about tools
-- [Other Adapters](./openai) - Explore other providers
-
-## Provider Tools
-
-Google Gemini exposes several native tools beyond user-defined function calls.
-Import them from `@tanstack/ai-gemini/tools` and pass them into
-`chat({ tools: [...] })`.
-
-> For the full concept, a comparison matrix, and type-gating details, see
-> [Provider Tools](../tools/provider-tools.md).
-
-### `codeExecutionTool`
-
-Enables Gemini to execute Python code in a sandboxed environment and return
-results inline. Takes no arguments — include it in the `tools` array to
-activate code execution.
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { geminiText } from "@tanstack/ai-gemini";
-import { codeExecutionTool } from "@tanstack/ai-gemini/tools";
-
-const stream = chat({
- adapter: geminiText("gemini-3.1-pro-preview"),
- messages: [{ role: "user", content: "Calculate the first 10 Fibonacci numbers" }],
- tools: [codeExecutionTool()],
-});
-```
-
-**Supported models:** Gemini 1.5 Pro, Gemini 2.x, Gemini 2.5 and above. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
-
-### `fileSearchTool`
-
-Searches files that have been uploaded to the Gemini File API. Pass a
-`FileSearch` config object with the corpus and file IDs to scope the search.
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { geminiText } from "@tanstack/ai-gemini";
-import { fileSearchTool } from "@tanstack/ai-gemini/tools";
-
-const stream = chat({
- adapter: geminiText("gemini-3.1-pro-preview"),
- messages: [{ role: "user", content: "Find the quarterly revenue figures" }],
- tools: [
- fileSearchTool({
- fileSearchStoreNames: ["fileSearchStores/my-file-search-store-123"],
- }),
- ],
-});
-```
-
-**Supported models:** Gemini 2.x and above. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
-
-### `googleSearchTool`
-
-Enables Gemini to query Google Search and incorporate grounded search results
-into its response. Pass an optional `GoogleSearch` config or call with no
-arguments to use defaults.
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { geminiText } from "@tanstack/ai-gemini";
-import { googleSearchTool } from "@tanstack/ai-gemini/tools";
-
-const stream = chat({
- adapter: geminiText("gemini-3.1-pro-preview"),
- messages: [{ role: "user", content: "What's the weather in Tokyo right now?" }],
- tools: [googleSearchTool()],
-});
-```
-
-**Supported models:** Gemini 1.5 Pro, Gemini 2.x, Gemini 2.5. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
-
-When Google Search returns grounding data, the assistant tool-call part
-includes `metadata.providerExecuted: true` and the normalized
-`metadata.sources` array. Gemini's raw `groundingMetadata` stays under
-`metadata.gemini`. The agent loop does not execute this call in your
-application.
-
-### `googleSearchRetrievalTool`
-
-A retrieval-augmented variant of Google Search that returns ranked passages
-from the web with configurable dynamic retrieval mode. Pass an optional
-`GoogleSearchRetrieval` config.
-
-```typescript ignore
-import { chat } from "@tanstack/ai";
-import { geminiText } from "@tanstack/ai-gemini";
-import { googleSearchRetrievalTool } from "@tanstack/ai-gemini/tools";
-
-const stream = chat({
- adapter: geminiText("gemini-3.1-pro-preview"),
- messages: [{ role: "user", content: "Explain the latest JavaScript proposals" }],
- tools: [
- googleSearchRetrievalTool({
- dynamicRetrievalConfig: { mode: "MODE_DYNAMIC", dynamicThreshold: 0.7 },
- }),
- ],
-});
-```
-
-**Supported models:** Gemini 1.5 Pro and above. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
-
-### `googleMapsTool`
-
-Connects Gemini to the Google Maps API for location-aware queries such as
-directions, place search, and geocoding. Pass an optional `GoogleMaps` config
-or call with no arguments.
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { geminiText } from "@tanstack/ai-gemini";
-import { googleMapsTool } from "@tanstack/ai-gemini/tools";
-
-const stream = chat({
- adapter: geminiText("gemini-2.5-pro"),
- messages: [{ role: "user", content: "Find coffee shops near Union Square, SF" }],
- tools: [googleMapsTool()],
-});
-```
-
-**Supported models:** Gemini 2.5 and above. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
-
-### `urlContextTool`
-
-Fetches and includes the content of URLs mentioned in the conversation so
-Gemini can reason over live web pages. Takes no arguments.
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { geminiText } from "@tanstack/ai-gemini";
-import { urlContextTool } from "@tanstack/ai-gemini/tools";
-
-const stream = chat({
- adapter: geminiText("gemini-3.1-pro-preview"),
- messages: [{ role: "user", content: "Summarise https://example.com/article" }],
- tools: [urlContextTool()],
-});
-```
-
-**Supported models:** Gemini 2.x and above. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
-
-### `computerUseTool`
+| Activity | Factory | Models |
+| --- | --- | --- |
+| Chat | `geminiText` | `gemini-3.8-flash`, `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-3.1-pro-preview`, `gemini-3-flash-preview`, `gemini-3.1-flash-lite`, `gemini-3.1-flash-lite-preview`, `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite` |
+| Images (native) | `geminiImage` | `gemini-nano-banana-2.1`, `gemini-3.1-flash-image`, `gemini-3.1-flash-lite-image`, `gemini-3-pro-image` |
+| Images (Imagen) | `geminiImage` | `imagen-4.0-ultra-generate-001`, `imagen-4.0-generate-001`, `imagen-4.0-fast-generate-001` |
+| Speech | `geminiSpeech` | `gemini-3.1-flash-tts-preview`, `gemini-2.5-flash-preview-tts`, `gemini-2.5-pro-preview-tts` |
+| Music | `geminiAudio` | `lyria-3-pro-preview`, `lyria-3-clip-preview` |
+| Video | `geminiVideo` | `veo-3.1-generate-preview`, `veo-3.1-fast-generate-preview`, `veo-3.1-lite-generate-preview`, `gemini-omni-1.1-flash` |
+| Embeddings | `geminiEmbedding` | `gemini-embedding-001` |
-Allows Gemini to observe a virtual desktop via screenshots and interact with
-it using predefined computer-use functions. Provide the `environment` and
-optionally restrict callable functions via `excludedPredefinedFunctions`.
+`adapter.inputModalities` lists the input kinds of the selected model. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts).
-```typescript ignore
-import { chat } from "@tanstack/ai";
-import { geminiText } from "@tanstack/ai-gemini";
-import { computerUseTool } from "@tanstack/ai-gemini/tools";
+## Advanced
-const stream = chat({
- adapter: geminiText("gemini-3.1-pro-preview"),
- messages: [{ role: "user", content: "Navigate to example.com in the browser" }],
- tools: [
- computerUseTool({
- environment: "browser",
- }),
- ],
-});
-```
+These topics are on [Gemini: Advanced](./gemini-advanced):
-**Supported models:** Gemini 2.5 and above. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+- Configuration and a proxy or gateway.
+- Stateful conversations with the Interactions API (experimental).
+- Image sizes, image edits, and image options.
+- Video understanding options and speech with two speakers.
+- Every provider tool, with a full sample, and the API reference.
diff --git a/docs/adapters/grok-build.md b/docs/adapters/grok-build.md
index f1dcf0e00a..1ae80980b7 100644
--- a/docs/adapters/grok-build.md
+++ b/docs/adapters/grok-build.md
@@ -2,7 +2,7 @@
title: Grok Build
id: grok-build-adapter
order: 15
-description: "Use xAI's Grok Build coding agent as a chat backend in TanStack AI — a sandbox harness that runs the grok CLI against a real workspace, with tool bridging via @tanstack/ai-grok-build."
+description: "Run xAI's Grok Build coding agent inside a sandbox from TanStack AI. It edits files and runs commands in a real repo and streams each step to your UI."
keywords:
- tanstack ai
- grok
@@ -14,188 +14,274 @@ keywords:
- adapter
---
-The Grok Build adapter runs xAI's **Grok Build** coding agent as a chat backend.
-Unlike HTTP provider adapters, this is a **harness adapter**: Grok Build runs
-its own agent loop and executes its own tools — shell commands, file edits,
-search — by spawning the `grok` CLI **inside a sandbox**. Each `chat()` call runs
-one full harness turn; the harness's tool activity streams back as
-already-resolved tool-call events your UI can render.
+The Grok Build adapter runs the `grok` CLI in a sandbox and streams each step to `chat()`. The agent edits files, runs commands, and searches a repo.
-> **Requires a sandbox.** `grok-build` declares `requires: [SandboxCapability]`,
-> so `chat()` errors at the call site unless you provide a sandbox with
-> `withSandbox(...)` middleware. The sandbox — your laptop, a Docker container,
-> or a cloud VM — is the filesystem and safety boundary the agent runs in. See
-> the [Sandboxes overview](../sandbox/overview) for the full picture.
+You need:
-## Installation
+- A sandbox provider. The samples use Docker, so Docker must run on your machine.
+- An `XAI_API_KEY`, or a `grok login` on the machine.
+- The `grok` CLI inside the sandbox. A setup step in the sandbox installs it.
-
+Do you only want a Grok chat, with no repo and no shell? Use the [Grok adapter](./grok).
-react: @tanstack/ai-grok-build @tanstack/ai-sandbox
-vue: @tanstack/ai-grok-build @tanstack/ai-sandbox
-solid: @tanstack/ai-grok-build @tanstack/ai-sandbox
-svelte: @tanstack/ai-grok-build @tanstack/ai-sandbox
-preact: @tanstack/ai-grok-build @tanstack/ai-sandbox
-angular: @tanstack/ai-grok-build @tanstack/ai-sandbox
-vanilla: @tanstack/ai-grok-build @tanstack/ai-sandbox
-octane: @tanstack/ai-grok-build @tanstack/ai-sandbox
+## 1. Install
-
+
-You also need a sandbox provider (e.g. `@tanstack/ai-sandbox-docker`) and the
-`grok` CLI available inside the sandbox image.
+react: @tanstack/ai-grok-build @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
+vue: @tanstack/ai-grok-build @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
+solid: @tanstack/ai-grok-build @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
+svelte: @tanstack/ai-grok-build @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
+preact: @tanstack/ai-grok-build @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
+angular: @tanstack/ai-grok-build @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
+vanilla: @tanstack/ai-grok-build @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
+octane: @tanstack/ai-grok-build @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
-## Authentication
+
-Your laptop can already have `grok login`. A CI runner only has `XAI_API_KEY`.
-Both can use the same sandbox provider. The default `authMode` is `'api-key'`.
-Set `'host'` when you want `grok login`. See [Harness Auth](../sandbox/auth).
+## 2. Set the API key
-```ts
-import { grokBuildText } from "@tanstack/ai-grok-build"
+Put the key in the environment of your server:
-grokBuildText("composer-2.5")
-grokBuildText("composer-2.5", { authMode: "host" })
+```bash
+XAI_API_KEY=xai-...
```
-- `'api-key'` (default): inject `XAI_API_KEY` and authenticate with it.
-- `'host'`: skip ACP `authenticate`. Use `grok login`. Do not inject
- `XAI_API_KEY` into that process.
+The sandbox in step 3 gives this key to the `grok` process as a workspace secret.
+
+Did the machine already run `grok login`? Then you can use that login and no key. See [Harness Auth](../sandbox/auth).
-The two modes list the model under slightly different ids. The adapter maps
-the short alias for you (see [Models](#models)).
+## 3. Run the agent from the server
-## Basic Usage
+Define the sandbox once. It clones your repo into a Docker container and installs the `grok` CLI with `GROK_CLI_INSTALL_COMMAND`:
```ts
-import { chat } from '@tanstack/ai'
-import { grokBuildText } from '@tanstack/ai-grok-build'
+// sandbox.ts
+import { GROK_CLI_INSTALL_COMMAND } from "@tanstack/ai-grok-build";
import {
createSecrets,
defineSandbox,
defineWorkspace,
githubRepo,
- withSandbox,
-} from '@tanstack/ai-sandbox'
-import { dockerSandbox } from '@tanstack/ai-sandbox-docker'
-import { messages, threadId } from './chat-context'
-
-const sandbox = defineSandbox({
- id: 'grok-build-agent',
- provider: dockerSandbox({ image: 'node:22' }),
+} from "@tanstack/ai-sandbox";
+import { dockerSandbox } from "@tanstack/ai-sandbox-docker";
+
+export const sandbox = defineSandbox({
+ id: "grok-build-agent",
+ provider: dockerSandbox({ image: "node:22" }),
workspace: defineWorkspace({
- source: githubRepo({ repo: 'owner/app' }),
- setup: ['corepack enable', 'pnpm install'],
- secrets: createSecrets({ XAI_API_KEY: process.env.XAI_API_KEY ?? '' }),
+ source: githubRepo({ repo: "owner/app" }),
+ setup: [GROK_CLI_INSTALL_COMMAND],
+ secrets: createSecrets({
+ XAI_API_KEY: process.env.XAI_API_KEY ?? "",
+ }),
}),
-})
+});
+```
-const stream = chat({
- threadId,
- adapter: grokBuildText('composer-2.5'),
- messages,
- middleware: [withSandbox(sandbox)],
-})
+Then pass the adapter and the sandbox to `chat()` in your route:
+
+```ts
+// api/agent.ts
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
+import { grokBuildText } from "@tanstack/ai-grok-build";
+import { withSandbox } from "@tanstack/ai-sandbox";
+import { sandbox } from "./sandbox";
+
+export async function POST(request: Request) {
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
+
+ const stream = chat({
+ adapter: grokBuildText("grok-build"),
+ messages,
+ threadId,
+ runId,
+ middleware: [withSandbox(sandbox)],
+ });
+
+ return toServerSentEventsResponse(stream);
+}
```
-## Models
+Replace `owner/app` with your repo.
-Grok Build accepts any xAI model id its backend supports; the known ids get
-autocomplete (any string is allowed):
+## 4. Render the agent's work
-| Model id | Notes |
-| --- | --- |
-| `grok-build` | The short alias. With a grok.com browser login the CLI lists it under this name. |
-| `grok-build-0.1` | The fully-qualified id the CLI lists when authenticated with `XAI_API_KEY`. |
-| `composer-2.5` | Also runnable through the Grok Build harness. |
-
-Pass any of these to `grokBuildText(...)` — the adapter resolves `grok-build` to
-the CLI's `grok-build-0.1` automatically, so the same code works under both auth
-modes.
-
-## Configuration
-
-Adapter config (second argument to `grokBuildText`):
-
-| Option | Description |
-| ---------------- | --------------------------------------------------------------------------- |
-| `cwd` | Working directory inside the sandbox. Defaults to `/workspace`. |
-| `grokExecutable` | Path/name of the `grok` executable inside the sandbox. Defaults to `grok`. |
-| `env` | Extra environment variables for the `grok` process inside the sandbox. |
-| `emitDiff` | Emit a `file.changed` CUSTOM event with the working-tree `git diff` after the run. Defaults to `true`. |
-| `protocol` | Harness wire protocol: `'acp'` or `'streaming-json'`. Defaults to `'acp'`. A durable sandbox run with no protocol set uses `'streaming-json'` so the run can journal. |
-| `authMode` | `'api-key'` (default) uses `XAI_API_KEY`. `'host'` uses `grok login`. See [Harness Auth](../sandbox/auth). |
-| `authMethodId` | Explicit ACP auth method. Wins over `authMode`. |
-| `extraArgs` | Extra raw CLI flags appended verbatim (advanced). |
-
-Per-call overrides go through `modelOptions`:
-
-| `modelOptions` | Description |
-| --------------- | ------------------------------------------------------------ |
-| `sessionId` | Resume an existing Grok Build session (see below). |
-| `cwd` | Per-call override of the harness working directory. |
-| `maxTurns` | Per-call cap on the number of harness turns. |
-| `protocol` | Per-call override of the harness wire protocol. |
-| `authMode` | Per-call `'host'` or `'api-key'`. Default `'api-key'`. |
-| `authMethodId` | Per-call ACP auth method. Wins over `authMode`. |
-
-## Stateful Sessions
-
-Grok Build sessions are stateful — the harness keeps the working context (files
-read, commands run, conclusions reached) between turns. The adapter surfaces the
-session id of every fresh run as a custom stream event named
-`grok-build.session-id`; thread it back via `modelOptions.sessionId` to resume.
-When resuming, only the latest user message is sent — the harness already holds
-the prior context.
+The client is the same as for any chat. Render the text, and show each tool that the agent ran:
+
+```tsx
+import { useState } from "react";
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
+
+export function CodingAgent() {
+ const [input, setInput] = useState("");
+ const { messages, sendMessage } = useChat({
+ connection: fetchServerSentEvents("/api/agent"),
+ threadId: "support-chat",
+ });
+
+ return (
+
+ );
+}
+```
+
+Ask it "Fix the failing test in utils.test.ts". You see the agent's text, plus a line for each tool it used. The agent edited the files inside the container.
+
+For a version of this setup that runs as a script and prints the diff, see the [Sandbox Quick Start](../sandbox/quick-start).
+
+## How it works
+
+- `grokBuildText` declares that it needs a sandbox. `withSandbox(sandbox)` gives it one. Without a sandbox, `chat()` fails at the call site.
+- Each `chat()` call runs one full agent turn. Grok Build runs its own loop and its own tools.
+- The tools of the agent stream back as `tool-call` parts with the results attached. Your code does not run them.
+- By default, the adapter talks to `grok` over the Agent Client Protocol (ACP).
+- When the run ends, the adapter sends a `file.changed` custom event with the `git diff` of the workspace.
+
+To learn more about sandboxes, see [Sandboxes](../sandbox/overview).
+
+## Resume a session
+
+Grok Build keeps its working context between turns: the files it read, the commands it ran, and what it found. The adapter sends the session id of each new run as a custom event named `grok-build.session-id`. Send it back in `modelOptions.sessionId` to continue the session.
+
+On the server, read the id from the request:
```ts
-import { chat, chatParamsFromRequest, toServerSentEventsResponse } from '@tanstack/ai'
-import { grokBuildText } from '@tanstack/ai-grok-build'
-import { withSandbox } from '@tanstack/ai-sandbox'
-import { sandbox } from './sandbox'
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
+import { grokBuildText } from "@tanstack/ai-grok-build";
+import { withSandbox } from "@tanstack/ai-sandbox";
+import { sandbox } from "./sandbox";
export async function POST(request: Request) {
- const params = await chatParamsFromRequest(request)
+ const params = await chatParamsFromRequest(request);
+
+ // Extra fields the client puts in the connection `body` arrive here.
const sessionId =
- typeof params.forwardedProps.sessionId === 'string'
+ typeof params.forwardedProps.sessionId === "string"
? params.forwardedProps.sessionId
- : undefined
+ : undefined;
const stream = chat({
- adapter: grokBuildText('grok-build'),
+ adapter: grokBuildText("grok-build"),
messages: params.messages,
+ threadId: params.threadId,
+ runId: params.runId,
middleware: [withSandbox(sandbox)],
modelOptions: { sessionId },
- })
+ });
- return toServerSentEventsResponse(stream)
+ return toServerSentEventsResponse(stream);
}
```
-## Tools
+On the client, keep the id from the custom event and send it with each request:
-Two kinds of tools flow through this adapter:
+```tsx
+import { useState } from "react";
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
+
+export function useCodingAgent() {
+ const [sessionId, setSessionId] = useState(undefined);
+
+ return useChat({
+ connection: fetchServerSentEvents("/api/agent", () => ({
+ body: { sessionId },
+ })),
+ threadId: "support-chat",
+ onCustomEvent: (name, value) => {
+ if (
+ name === "grok-build.session-id" &&
+ typeof value === "object" &&
+ value !== null &&
+ "sessionId" in value &&
+ typeof value.sessionId === "string"
+ ) {
+ setSessionId(value.sessionId);
+ }
+ },
+ });
+}
+```
+
+On a resumed session, the adapter sends only the latest user message. The agent already has the rest.
+
+## Give the agent your own tools
-1. **Built-in harness tools** are executed by Grok Build itself (shell, file
- edits, search) and stream back as tool-call events with results already
- attached. Your code never executes them.
-2. **Your TanStack tools** are bridged *into* the harness over an authenticated
- MCP tool-proxy: define them with `toolDefinition().server()` and pass them to
- `chat({ tools })`. Tool-call events come back under the names you registered.
- Because the harness runs in a sandbox, see [Sandbox tools](../sandbox/tools)
- for how the bridge reaches your host across providers (local/Docker vs cloud).
+The agent has its own tools for files and the shell. It cannot reach your database or your secrets. Pass your server tools to `chat()`, and the adapter bridges them into the sandbox over an MCP tool proxy that checks a token:
-**Client-side and approval-gated tools are not supported** — the harness runs
-tools inside a live process and can't pause across an HTTP round-trip. A tool
-without a server `execute()` (or marked `needsApproval`) fails fast; run those
-with a regular provider adapter.
+```ts group=tools
+import { z } from "zod";
+import { chat, toolDefinition } from "@tanstack/ai";
+import { grokBuildText } from "@tanstack/ai-grok-build";
+import { withSandbox } from "@tanstack/ai-sandbox";
+import { sandbox } from "./sandbox";
+
+const lookupTicket = toolDefinition({
+ name: "lookup_ticket",
+ description: "Look up an issue ticket by id",
+ inputSchema: z.object({ ticketId: z.string() }),
+}).server(async ({ ticketId }) => {
+ return { ticketId, status: "open", title: "Crash on startup" };
+});
+
+const stream = chat({
+ adapter: grokBuildText("grok-build"),
+ messages: [{ role: "user", content: "What's the status of ticket T-123?" }],
+ threadId: "thread-1",
+ tools: [lookupTicket],
+ middleware: [withSandbox(sandbox)],
+});
+```
+
+- Each tool runs on your server, with its closures and secrets. The result goes back into the sandbox.
+- The tool-call events use the names you gave.
+- Only server tools work here. A client tool has no server `execute()`, so the agent cannot run it.
+
+For how the bridge reaches your server from each provider (local, Docker, or cloud), see [Sandbox Tools](../sandbox/tools).
## Tool choice
-You want Grok Build to see just one of your tools, or none of them. Pass
-`toolChoice` to `chat()`. It limits only the tools that the adapter bridges
-into Grok Build. The built-in Grok Build tools always stay on, and Grok Build
-decides when it calls a tool. The rules are the same on both protocols.
+You want Grok Build to see only one of your tools, or none of them. Pass `toolChoice` to `chat()`:
+
+```ts group=tools
+const onlyLookup = chat({
+ adapter: grokBuildText("grok-build"),
+ messages: [{ role: "user", content: "What's the status of ticket T-123?" }],
+ threadId: "thread-1",
+ tools: [lookupTicket],
+ toolChoice: { type: "tool", name: "lookup_ticket" },
+ middleware: [withSandbox(sandbox)],
+});
+```
+
+`toolChoice` limits only the tools that the adapter bridges into Grok Build. The built-in Grok Build tools always stay on, and Grok Build decides when it calls a tool. The rules are the same on both protocols.
| Value | What the adapter does |
| --- | --- |
@@ -204,96 +290,134 @@ decides when it calls a tool. The rules are the same on both protocols.
| `{ type: 'tool', name }` | Bridges only the named tool. Grok Build does not have to call it. |
| `'required'` | Bridges all of your tools. Grok Build does not have to call one. |
-Each value except `'auto'` logs a warning the first time an adapter instance
-gets it.
+Each value except `'auto'` logs a warning the first time an adapter instance gets it.
-For the values on other adapters, see
-[Choose when the model calls a tool](../tools/tools#choose-when-the-model-calls-a-tool).
+For the values on other adapters, see [Choose when the model calls a tool](../tools/tools#choose-when-the-model-calls-a-tool).
-## Durable runs
+## Structured output
-A durable sandbox run (you pass `runs` and `durability` to `withSandbox`)
-journals the agent output so a later request can take the run over. ACP does
-not journal. When durability is wired and you do not set `protocol`, the
-adapter uses `'streaming-json'`.
-
-Set `protocol: 'acp'` only when you want ACP on that call. A fresh ACP run
-with durability wired logs a warning. An attach against ACP throws
-`DurableAttachNotSupportedError`.
-
-## Headless NDJSON flags
-
-On the `'streaming-json'` path with auto-approve, the adapter adds
-`--always-approve --no-plan --no-auto-update`. Those flags keep Plan Mode and
-the CLI update check from blocking a headless run.
-
-## Structured Output
-
-Pass `outputSchema` on `chat()`. Grok Build has no native schema flag. The adapter adds the JSON Schema to the prompt (ACP and `streaming-json`) and parses the last assistant text. Tool activity still streams. The object arrives as `structured-output.complete`.
+You want a typed report after the agent reads the repo. Pass `outputSchema` on the same `chat()` call:
```ts
-import { chat } from "@tanstack/ai"
-import { grokBuildText } from "@tanstack/ai-grok-build"
-import { defineSandbox, withSandbox } from "@tanstack/ai-sandbox"
-import { dockerSandbox } from "@tanstack/ai-sandbox-docker"
-import { z } from "zod"
+import { chat } from "@tanstack/ai";
+import { grokBuildText } from "@tanstack/ai-grok-build";
+import { withSandbox } from "@tanstack/ai-sandbox";
+import { z } from "zod";
+import { sandbox } from "./sandbox";
const Report = z.object({
summary: z.string(),
filesChanged: z.array(z.string()),
-})
-
-const sandbox = defineSandbox({
- id: "repo-report",
- provider: dockerSandbox({ image: "node:22" }),
-})
+});
const report = await chat({
adapter: grokBuildText("grok-build"),
messages: [{ role: "user", content: "Review this repo." }],
+ threadId: "thread-1",
outputSchema: Report,
middleware: [withSandbox(sandbox)],
-})
+});
-report.summary
+console.log(report.summary);
```
-This path parses JSON from the last assistant message. If extract-only is the job, use `@tanstack/ai-grok`.
-
On the client, pass the same schema to `useChat` and read `final`. `partial` stays empty until the end.
```tsx
-import { fetchServerSentEvents, useChat } from "@tanstack/ai-react"
-import { z } from "zod"
+import { fetchServerSentEvents, useChat } from "@tanstack/ai-react";
+import { z } from "zod";
const Report = z.object({
summary: z.string(),
filesChanged: z.array(z.string()),
-})
+});
-function ReportView() {
+export function ReportView() {
const { final, isLoading } = useChat({
connection: fetchServerSentEvents("/api/repo-report"),
+ threadId: "support-chat",
outputSchema: Report,
- })
+ });
- if (isLoading) return
The agent is inspecting the repo.
- if (!final) return null
- return
{final.summary}
+ if (isLoading) return
The agent is inspecting the repo.
;
+ if (!final) return null;
+ return
{final.summary}
;
}
```
-Full walkthrough, including the client: [Harness Agents](../structured-outputs/harnesses).
+- Grok Build has no schema flag. The adapter adds the JSON Schema to the prompt, on both protocols.
+- The adapter parses the last assistant text. Tool activity still streams.
+- The object arrives as `structured-output.complete`.
+
+Do you only need JSON from a prompt, with no repo? Use `@tanstack/ai-grok`. For the full walkthrough, see [Harness Agents](../structured-outputs/harnesses).
+
+## Models and options
+
+### Models
+
+| Model id | Notes |
+| --- | --- |
+| `grok-build` | The short alias. With a grok.com browser login, the CLI lists the model under this name. |
+| `grok-build-0.1` | The full id. The CLI lists the model under this name when it signs in with `XAI_API_KEY`. |
+| `composer-2.5` | Also runs through the Grok Build harness. |
+
+The adapter changes `grok-build` to `grok-build-0.1` for the CLI, so the same code works with both ways to sign in. The package exports the list as `GROK_BUILD_MODELS`. The adapter also takes any other xAI model id as a string.
+
+### Adapter options
+
+Pass these as the second argument of `grokBuildText`:
+
+| Option | What it does |
+| --- | --- |
+| `cwd` | The working directory inside the sandbox. Default `/workspace`. |
+| `authMode` | `'api-key'` (default) signs in with `XAI_API_KEY`. `'host'` uses `grok login`. See [Harness Auth](../sandbox/auth). |
+| `authMethodId` | An explicit ACP auth method. It wins over `authMode`. |
+| `protocol` | `'acp'` (default) or `'streaming-json'`. See [Protocols and durable runs](#protocols-and-durable-runs). |
+| `transport` | The ACP transport: `'auto'` (default), `'stdio'`, or `'websocket'`. |
+| `acpPort` | The port of `grok agent serve` in the sandbox, for the WebSocket transport. |
+| `permissionMode` | The ACP permission policy: `'default'`, `'acceptEdits'`, or `'bypassPermissions'` (default). |
+| `emitDiff` | Send the `file.changed` event with the `git diff` after the run. Default `true`. |
+| `grokExecutable` | The path or name of the `grok` executable in the sandbox. Default `grok`. |
+| `env` | More environment variables for the `grok` process. |
+| `extraArgs` | More raw CLI flags. The adapter adds them as they are. |
+
+### Per-call options
+
+Pass these in `modelOptions` on `chat()`. They override the adapter options for one call:
+
+- `sessionId`: resume a session. See [Resume a session](#resume-a-session).
+- `maxTurns`: the maximum number of agent turns for this call.
+- `cwd`, `protocol`, `transport`, `acpPort`, `permissionMode`.
+- `authMode` and `authMethodId`.
+
+## Advanced
+
+### Protocols and durable runs
+
+The adapter talks to `grok` over one of two protocols:
+
+- `'acp'` (default): the Agent Client Protocol.
+- `'streaming-json'`: the headless NDJSON output of the CLI.
+
+A durable sandbox run (you pass `runs` and `durability` to `withSandbox`) writes the agent output to a journal, so a later request can take the run over. ACP does not write a journal. So when durability is wired and you do not set `protocol`, the adapter uses `'streaming-json'`.
+
+Set `protocol: 'acp'` only when you want ACP on that call:
+
+- A new ACP run with durability wired logs a warning.
+- An attach to an ACP run throws `DurableAttachNotSupportedError`.
+
+See [Durable Runs](../sandbox/durable-runs) and [The Run Journal](../sandbox/journal).
+
+### Headless NDJSON flags
+
+On the `'streaming-json'` path with auto-approve, the adapter adds `--always-approve --no-plan --no-auto-update`. These flags stop Plan Mode and the CLI update check from blocking a headless run.
-## Limitations
+### Limitations
-- **Requires a sandbox.** Always run it under `withSandbox(...)`; see the
- [Sandboxes overview](../sandbox/overview).
-- **Server-only (Node).** The harness spawns the `grok` CLI in a sandbox.
-- **The harness owns the agent loop.** TanStack's agent-loop strategies and
- per-iteration middleware don't apply inside a harness turn.
-- **No sampling controls.** `temperature`-style options don't exist here.
-- **Cold starts.** Each call runs a full harness turn; expect higher first-token
- latency than HTTP adapters.
+- **A sandbox is required.** Every call needs `withSandbox(...)`.
+- **The agent owns the loop.** TanStack agent-loop strategies and per-iteration middleware do not apply inside a turn.
+- **No sampling options.** Options such as `temperature` do not exist here.
+- **Cold starts.** Each call runs a full CLI turn, so the first token arrives later than with an HTTP adapter.
+- **No `wrapFetch`.** This adapter ignores [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call), because the `grok` process sends the model requests.
-This adapter ignores [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call), because the harness process sends the model requests.
+For any other ACP agent, use the [ACP-Compatible Harness](./acp-compatible).
diff --git a/docs/adapters/grok.md b/docs/adapters/grok.md
index 07c00a9723..c66009af7f 100644
--- a/docs/adapters/grok.md
+++ b/docs/adapters/grok.md
@@ -2,7 +2,7 @@
title: Grok (xAI)
id: grok-adapter
order: 5
-description: "Use xAI Grok models with TanStack AI — Grok 4.3, Grok Build 0.1, Grok Imagine image generation, and Grok Imagine video generation via @tanstack/ai-grok."
+description: "Use xAI Grok models in TanStack AI with @tanstack/ai-grok: Grok chat on the Responses API, Grok Imagine images and video, speech, transcription, and realtime voice."
keywords:
- tanstack ai
- grok
@@ -15,9 +15,9 @@ keywords:
- adapter
---
-The Grok text and summarization adapters provide access to xAI's Responses API for `grok-4.3` and `grok-build-0.1`, plus Grok Imagine image generation and Grok Imagine video generation.
+The Grok adapter calls xAI chat models on the Responses API. It also calls Grok Imagine for images and video, plus speech, transcription, and realtime voice.
-## Installation
+## 1. Install
@@ -32,182 +32,253 @@ octane: @tanstack/ai-grok
-## Basic Usage
+## 2. Set the API key
-```typescript
-import { chat } from "@tanstack/ai";
-import { grokText } from "@tanstack/ai-grok";
+Put the key in the environment of your server:
-const stream = chat({
- adapter: grokText("grok-build-0.1"),
- messages: [{ role: "user", content: "Hello!" }],
-});
+```bash
+XAI_API_KEY=xai-...
```
-## Basic Usage - Custom API Key
+`grokText` and the other short factories read `XAI_API_KEY`. To pass the key in code, use the `create*` factory of the same adapter:
```typescript
-import { chat } from "@tanstack/ai";
import { createGrokText } from "@tanstack/ai-grok";
-const adapter = createGrokText("grok-build-0.1", process.env.XAI_API_KEY!);
-
-const stream = chat({
- adapter,
- messages: [{ role: "user", content: "Hello!" }],
-});
+const adapter = createGrokText("grok-4.7", process.env.XAI_API_KEY!);
```
-## Configuration
+## 3. Stream a chat from the server
```typescript
-import { createGrokText, type GrokTextConfig } from "@tanstack/ai-grok";
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
+import { grokText } from "@tanstack/ai-grok";
-const config: Omit = {
- baseURL: "https://api.x.ai/v1", // Optional, this is the default
-};
+export async function POST(request: Request) {
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
-const adapter = createGrokText("grok-build-0.1", process.env.XAI_API_KEY!, config);
+ const stream = chat({
+ adapter: grokText("grok-4.7"),
+ messages,
+ threadId,
+ runId,
+ });
+
+ return toServerSentEventsResponse(stream);
+}
```
-Need a header on each request, or a log of each model call? The chat adapters on this page support [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call).
+## 4. Render the chat
-## Grok on Vertex
+The client is the same for every provider. Point `useChat` at your route:
-Use `@tanstack/ai-grok/vertex` when Grok must run on Vertex AI. That path
-uses Google Cloud credentials and Vertex regional or global endpoints.
+```tsx
+import { useState } from "react";
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
-
+export function Chat() {
+ const [input, setInput] = useState("");
+ const { messages, sendMessage } = useChat({
+ connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
+ });
-react: @tanstack/ai-grok google-auth-library
-vue: @tanstack/ai-grok google-auth-library
-solid: @tanstack/ai-grok google-auth-library
-svelte: @tanstack/ai-grok google-auth-library
-preact: @tanstack/ai-grok google-auth-library
-angular: @tanstack/ai-grok google-auth-library
-vanilla: @tanstack/ai-grok google-auth-library
-octane: @tanstack/ai-grok google-auth-library
+ return (
+
+ );
+}
+```
-
+For Vue, Solid, Svelte, Angular, and the other frameworks, see [Quick Start](../getting-started/quick-start#3-render-the-chat).
+
+You now have a streaming Grok chat. [Tools](../tools/tools) and [Structured Outputs](../structured-outputs/overview) work the same as with every adapter.
+
+## Images
```typescript
-import { chat } from "@tanstack/ai";
-import { grokVertexText } from "@tanstack/ai-grok/vertex";
+import { generateImage } from "@tanstack/ai";
+import { grokImage } from "@tanstack/ai-grok";
-const stream = chat({
- adapter: grokVertexText("grok-4.3", {
- project: "my-project",
- location: "global",
- }),
- messages: [{ role: "user", content: "Hello!" }],
+const result = await generateImage({
+ adapter: grokImage("grok-imagine-image-2.0"),
+ prompt: "A futuristic cityscape at sunset",
+ threadId: "thread-1",
+ size: "16:9_2k",
+ modelOptions: { quality: "medium" },
});
```
-`project` and `location` use the same names as `@tanstack/ai-vertex`. If you
-omit `location`, the factory uses `global`.
+- The image models (`grok-imagine-image`, `grok-imagine-image-2.0`, `grok-imagine-image-quality`) take an aspect ratio as the size. `size` takes an `aspectRatio_resolution` template like `"16:9_2k"`. The `_2k` part is optional.
+- `grok-imagine-image-2.0` is the model that xAI recommends. It adds a `quality` option (`'low'` or `'medium'`, default `'medium'`).
+- To change an existing image, see [Image editing](#image-editing).
-`grokVertexText` accepts only the Grok chat models that Vertex lists:
+## Video
-- `grok-4.3`
-- `grok-4.20-reasoning`
-- `grok-4.20-non-reasoning`
-- `grok-4.1-fast-reasoning`
-- `grok-4.1-fast-non-reasoning`
+Grok Imagine makes short clips (1 to 15 seconds, with audio). Video generation is experimental. You start a job, then poll it:
-xAI API models such as `grok-4.6` and `grok-build-0.1` are not on Vertex.
+```typescript
+import { generateVideo, getVideoJobStatus } from "@tanstack/ai";
+import { grokVideo } from "@tanstack/ai-grok";
-The adapter sends the Vertex model id `xai/grok-4.3`. Install
-`google-auth-library` for Application Default Credentials, or pass
-`authClient` or `getAccessToken`.
+const adapter = grokVideo("grok-imagine-video-1.5");
-Use `grokVertexSummarize` from the same entry when you need summarize on
-Vertex.
+// 1. Create the job
+const { jobId } = await generateVideo({
+ adapter,
+ prompt: "A red panda balancing on a bamboo stalk in the rain",
+ threadId: "thread-1",
+ size: "16:9_720p", // "aspectRatio" or "aspectRatio_resolution"
+ duration: 5, // integer seconds, 1 to 15
+});
-Gemini on Vertex lives in [`@tanstack/ai-vertex`](./vertex).
+// 2. Poll until complete, then read the video URL
+let status = await getVideoJobStatus({ adapter, jobId });
+while (status.status !== "completed" && status.status !== "failed") {
+ await new Promise((r) => setTimeout(r, 5000));
+ status = await getVideoJobStatus({ adapter, jobId });
+}
-## Example: Chat Completion
+console.log(status.url); // hosted .mp4 URL
+```
-```typescript
-import { chat, toServerSentEventsResponse } from "@tanstack/ai";
-import { grokText } from "@tanstack/ai-grok";
+- `grok-imagine-video-1.5` is the default that xAI recommends. It does text-to-video (with native 1080p), image-to-video, and reference-to-video.
+- `grok-imagine-video` (v1.0) also edits and extends a source video.
-export async function POST(request: Request) {
- const { messages } = await request.json();
+Image-to-video, references, edits, and sizes are in [Video options](#video-options). For the full flow and the `useGenerateVideo` hook, see [Video Generation](../media/video-generation).
- const stream = chat({
- adapter: grokText("grok-build-0.1"),
- messages,
- });
+## Speech
- return toServerSentEventsResponse(stream);
-}
+```typescript
+import { generateSpeech } from "@tanstack/ai";
+import { grokSpeech } from "@tanstack/ai-grok";
+
+const result = await generateSpeech({
+ adapter: grokSpeech("grok-tts"),
+ text: "Hello from Grok!",
+ threadId: "thread-1",
+ voice: "default",
+ format: "mp3",
+});
+
+console.log(result.audio); // Base64-encoded audio
```
-## Example: With Tools
+## Transcription
```typescript
-import { chat, toServerSentEventsResponse, toolDefinition } from "@tanstack/ai";
-import { grokText } from "@tanstack/ai-grok";
-import { z } from "zod";
+import { generateTranscription } from "@tanstack/ai";
+import { grokTranscription } from "@tanstack/ai-grok";
+import { audioFile } from "./audio";
-const getWeatherDef = toolDefinition({
- name: "get_weather",
- description: "Get the current weather",
- inputSchema: z.object({
- location: z.string(),
- }),
+const result = await generateTranscription({
+ adapter: grokTranscription("grok-stt"),
+ audio: audioFile,
+ threadId: "thread-1",
});
-const getWeather = getWeatherDef.server(async ({ location }) => {
- // Fetch weather data
- return { temperature: 72, conditions: "sunny" };
-});
+console.log(result.text);
+```
-export async function POST(request: Request) {
- const { messages } = await request.json();
+## Summarization
- const stream = chat({
- adapter: grokText("grok-build-0.1"),
- messages,
- tools: [getWeather],
- });
+
- return toServerSentEventsResponse(stream);
-}
+```typescript ignore
+import { summarize } from "@tanstack/ai";
+import { grokSummarize } from "@tanstack/ai-grok";
+
+const result = await summarize({
+ adapter: grokSummarize("grok-4.3"),
+ text: "Your long text to summarize...",
+ threadId: "thread-1",
+ maxLength: 100,
+ style: "concise", // "concise" | "bullet-points" | "paragraph"
+});
+
+console.log(result.summary);
```
-## Model Options
+## Realtime voice and files
+
+- **Realtime voice**: `grokRealtime` and the token issuer `grokRealtimeToken` run a live voice chat. The default model is `grok-voice-think-fast-2.0`. `grok-voice-latest` always points to the newest model. See [Realtime Voice Chat](../media/realtime-chat).
+- **Files**: `grokFiles` uploads media to the xAI Files API. See [Files API](../advanced/files-api).
+
+## Provider tools
-Grok supports xAI Responses API options. Sampling parameters live here too — `temperature`, `top_p`, and `max_output_tokens` — rather than as root-level props on `chat()`:
+xAI runs some tools itself. Import them from `@tanstack/ai-grok/tools` and add them to `tools`:
```typescript
-import { chat, toServerSentEventsResponse } from "@tanstack/ai";
+import { chat } from "@tanstack/ai";
import { grokText } from "@tanstack/ai-grok";
+import { grokWebSearchTool } from "@tanstack/ai-grok/tools";
-export async function POST(request: Request) {
- const { messages } = await request.json();
+const stream = chat({
+ adapter: grokText("grok-4.3"),
+ messages: [{ role: "user", content: "What's new in AI this week?" }],
+ threadId: "thread-1",
+ tools: [grokWebSearchTool()],
+});
+```
- const stream = chat({
- adapter: grokText("grok-build-0.1"),
- messages,
- modelOptions: {
- temperature: 0.7,
- top_p: 0.9,
- max_output_tokens: 1024,
- store: false,
- include: ["reasoning.encrypted_content"],
- },
- });
+- `grokWebSearchTool`: search the web. `filters` takes up to 5 `allowed_domains` or up to 5 `excluded_domains`, not both.
+- `grokXSearchTool`: search posts on X. Takes up to 20 `allowed_x_handles` or up to 20 `excluded_x_handles`, not both.
+- `grokFileSearchTool`: search your xAI file collections with `vector_store_ids`. `max_num_results` is 1 to 50.
+- `grokMCPTool`: connect a remote MCP server with `server_url` and `server_label`.
- return toServerSentEventsResponse(stream);
-}
+**Supported models:** `grok-4.3` and `grok-build-0.1`. For your own tools, use `toolDefinition()`. See [Tools](../tools/tools.md).
+
+## Models and options
+
+### Model options
+
+Provider options go in `modelOptions`, with the names of the xAI Responses API. This includes the sampling options `temperature`, `top_p`, and `max_output_tokens`:
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { grokText } from "@tanstack/ai-grok";
+
+const stream = chat({
+ adapter: grokText("grok-4.7"),
+ messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
+ modelOptions: {
+ temperature: 0.7,
+ top_p: 0.9,
+ max_output_tokens: 1024,
+ store: false,
+ include: ["reasoning.encrypted_content"],
+ },
+});
```
-> If you previously passed `temperature` / `topP` / `maxTokens` at the root of `chat()`, see [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options).
+- Requests use `store: false` by default, and include the encrypted reasoning with `include: ["reasoning.encrypted_content"]`. You can change both in `modelOptions`.
+- Sampling options do not go at the root of `chat()`. See [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options).
-## Reasoning
+### Reasoning
-Want Grok to think harder? Set `reasoning` on `chat()`. It goes out as `reasoning.effort`, with a summary so the thinking text streams:
+Want Grok to think harder? Set `reasoning` on `chat()`. It goes out as `reasoning.effort`, with a summary so that the thinking text streams:
```typescript
import { chat } from "@tanstack/ai";
@@ -216,6 +287,7 @@ import { grokText } from "@tanstack/ai-grok";
const stream = chat({
adapter: grokText("grok-4.7"),
messages: [{ role: "user", content: "Plan a database migration." }],
+ threadId: "thread-1",
reasoning: "xhigh",
});
```
@@ -226,60 +298,85 @@ const stream = chat({
| `grok-4.5` | `low`, `medium`, `high` |
| `grok-4.3` | `off`, `low`, `medium`, `high` |
-`grok-build-0.1` and the `non-reasoning` models take no `reasoning`. `grok-4.20-reasoning` and `grok-4.1-fast-reasoning` take no `reasoning` either: they always think, and have no levels.
+- `grok-build-0.1` and the `non-reasoning` models take no `reasoning`.
+- `grok-4.20-reasoning` and `grok-4.1-fast-reasoning` take no `reasoning` either. They always think, and have no levels.
-## Summarization
+### Models
-Summarize long text content:
+| Activity | Factory | Models |
+| --- | --- | --- |
+| Chat | `grokText` | `grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-build-0.1` |
+| Chat on Vertex | `grokVertexText` | `grok-4.3`, `grok-4.20-reasoning`, `grok-4.20-non-reasoning`, `grok-4.1-fast-reasoning`, `grok-4.1-fast-non-reasoning` |
+| Images | `grokImage` | `grok-imagine-image-2.0`, `grok-imagine-image`, `grok-imagine-image-quality` |
+| Video | `grokVideo` | `grok-imagine-video-1.5`, `grok-imagine-video` |
+| Speech | `grokSpeech` | `grok-tts` |
+| Transcription | `grokTranscription` | `grok-stt` |
+| Realtime voice | `grokRealtime` | `grok-voice-think-fast-2.0`, `grok-voice-latest`, `grok-voice-fast-1.0` |
-
+`adapter.inputModalities` lists the input kinds of the selected model. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts).
-```typescript ignore
-import { summarize } from "@tanstack/ai";
-import { grokSummarize } from "@tanstack/ai-grok";
+## Advanced
-const result = await summarize({
- adapter: grokSummarize("grok-4.3"),
- text: "Your long text to summarize...",
- maxLength: 100,
- style: "concise", // "concise" | "bullet-points" | "paragraph"
-});
+### Configuration
-console.log(result.summary);
+```typescript
+import { createGrokText, type GrokTextConfig } from "@tanstack/ai-grok";
+
+const config: Omit = {
+ baseURL: "https://api.x.ai/v1", // Optional, this is the default
+};
+
+const adapter = createGrokText("grok-4.7", process.env.XAI_API_KEY!, config);
```
-## Image Generation
+- Need a header on each request, or a log of each model call? The chat adapters of this package support [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call).
+- The text and summarize adapters use the xAI **Responses API** (`/v1/responses`). They support streaming text, reasoning events, structured output through `text.format`, and function tools.
+
+### Grok on Vertex
-Generate images with a Grok Imagine model.
+Use `@tanstack/ai-grok/vertex` when Grok must run on Vertex AI. That path uses Google Cloud credentials and the regional or global endpoints of Vertex.
-The image models (`grok-imagine-image`, `grok-imagine-image-2.0`,
-`grok-imagine-image-quality`) are aspect-ratio sized — `size` takes an
-`aspectRatio_resolution` template like `"16:9_2k"` (the `_2k` suffix is
-optional). `grok-imagine-image-2.0` is xAI's recommended model and adds a
-2.0-only `quality` provider option (`'low' | 'medium'`, default `'medium'`):
+
+
+react: @tanstack/ai-grok google-auth-library
+vue: @tanstack/ai-grok google-auth-library
+solid: @tanstack/ai-grok google-auth-library
+svelte: @tanstack/ai-grok google-auth-library
+preact: @tanstack/ai-grok google-auth-library
+angular: @tanstack/ai-grok google-auth-library
+vanilla: @tanstack/ai-grok google-auth-library
+octane: @tanstack/ai-grok google-auth-library
+
+
```typescript
-import { generateImage } from "@tanstack/ai";
-import { grokImage } from "@tanstack/ai-grok";
+import { chat } from "@tanstack/ai";
+import { grokVertexText } from "@tanstack/ai-grok/vertex";
-const result = await generateImage({
- adapter: grokImage("grok-imagine-image-2.0"),
- prompt: "A futuristic cityscape at sunset",
- size: "16:9_2k",
- modelOptions: { quality: "medium" },
+const stream = chat({
+ adapter: grokVertexText("grok-4.3", {
+ project: "my-project",
+ location: "global",
+ }),
+ messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
});
```
-### Image Editing (image-to-image)
+- `project` and `location` use the same names as `@tanstack/ai-vertex`. If you omit `location`, the factory uses `global`.
+- `grokVertexText` accepts only the Grok chat models that Vertex lists. See the [model table](#models). xAI API models such as `grok-4.6` and `grok-build-0.1` are not on Vertex.
+- The adapter sends the Vertex model id `xai/grok-4.3`.
+- Install `google-auth-library` for Application Default Credentials, or pass `authClient` or `getAccessToken`.
+- For summarize on Vertex, use `grokVertexSummarize` from the same entry.
+
+Gemini on Vertex is in [`@tanstack/ai-vertex`](./vertex).
+
+### Image editing
-The grok-imagine models accept image prompt parts for image-conditioned
-generation via xAI's `/v1/images/edits` endpoint — up to 3 source images,
-addressed by xAI in the order they appear in the prompt. Per xAI's docs
-there is no in-prompt referencing syntax; write the prompt naturally and
-your text is sent verbatim:
+The grok-imagine models take image prompt parts. The adapter then calls the `/v1/images/edits` endpoint of xAI.
+
+- You can send up to 3 source images. xAI reads them in the order of the prompt.
+- xAI has no syntax to point to an image in the prompt. Write the prompt in plain words. Your text goes out without changes.
```typescript
import { generateImage } from "@tanstack/ai";
@@ -301,48 +398,25 @@ const result = await generateImage({
source: { type: "url", value: "https://example.com/style.png" },
},
],
+ threadId: "thread-1",
});
```
-URL sources are fetched by xAI's servers, so they must be publicly
-reachable; use a `data` source for private images.
-
-## Video Generation (Experimental)
-
-Generate short video clips (1–15 seconds, with audio) with the Grok Imagine video models via xAI's asynchronous jobs/polling API.
-
-Available models:
+The servers of xAI fetch URL sources, so the URLs must be public. For a private image, use a `data` source.
-- `grok-imagine-video` (v1.0) — text-to-video, image-to-video, and source-video edit / extend, $0.05 per second of video.
-- `grok-imagine-video-1.5` — xAI's recommended default, $0.08 per second of video. Supports text-to-video (with native 1080p), image-to-video, and reference-to-video. It does not accept a source video.
+### Video options
-Text-to-video:
+#### Sizes
-```typescript
-import { generateVideo, getVideoJobStatus } from "@tanstack/ai";
-import { grokVideo } from "@tanstack/ai-grok";
+The video `size` takes an `aspectRatio_resolution` template. The resolution part is optional.
-const adapter = grokVideo("grok-imagine-video-1.5");
+- Aspect ratios: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `3:2`, and `2:3`.
+- Resolutions: `480p` and `720p`. `1080p` (for example `"9:16_1080p"`) works only on `grok-imagine-video-1.5` for text-to-video and image-to-video.
+- Price: `grok-imagine-video` costs $0.05 per second of video. `grok-imagine-video-1.5` costs $0.08 per second.
-// 1. Create the job
-const { jobId } = await generateVideo({
- adapter,
- prompt: "A red panda balancing on a bamboo stalk in the rain",
- size: "16:9_720p", // "aspectRatio" or "aspectRatio_resolution"
- duration: 5, // integer seconds, 1–15
-});
+#### Image-to-video
-// 2. Poll until complete, then read the video URL
-let status = await getVideoJobStatus({ adapter, jobId });
-while (status.status !== "completed" && status.status !== "failed") {
- await new Promise((r) => setTimeout(r, 5000));
- status = await getVideoJobStatus({ adapter, jobId });
-}
-
-console.log(status.url); // hosted .mp4 URL
-```
-
-For image-to-video, include an `image` prompt part as the starting frame and describe the desired motion in the text part. URL sources are fetched by xAI's servers (so they must be publicly reachable); use a `data` source for a base64 starting frame:
+Add an `image` prompt part as the first frame. Describe the motion in the text part. The servers of xAI fetch URL sources, so they must be public. For a base64 first frame, use a `data` source.
```typescript
import { generateVideo } from "@tanstack/ai";
@@ -360,16 +434,20 @@ const { jobId } = await generateVideo({
source: { type: "url", value: "https://example.com/waterfall-still.png" },
},
],
+ threadId: "thread-1",
size: "16:9_720p",
duration: 10,
});
```
-Like the Grok Imagine image models, sizing is aspect-ratio based: the `size` option takes an `aspectRatio_resolution` template. Supported aspect ratios are `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `3:2`, and `2:3`; supported resolutions are `480p`, `720p`, and (on `grok-imagine-video-1.5` text-to-video / image-to-video only) `1080p` (e.g. `"9:16_1080p"`). The resolution suffix is optional.
+#### Reference-to-video
-### Reference-to-Video
+On `grok-imagine-video-1.5`, image parts with `metadata.role: 'reference'` (or `'character'`) become `reference_images`. They guide subjects and style, but do not lock the first frame. The prompt text names them ``, ``, and so on, in request order.
-On `grok-imagine-video-1.5`, image prompt parts with `metadata.role: 'reference'` (or `'character'`) become `reference_images` — they guide subjects and style without locking the first frame, and are addressed from the prompt text as ``, ``, … in request order. Preset TTS voices (up to 3) can be referenced for generated speech via `modelOptions.reference_audios`, addressed as ``, ``, ``. Reference-to-video output is capped at 720p. A starting-frame image can be combined with reference inputs on 1.5: the start frame pins the opening frame while the references steer subjects, style, and voices. Reference inputs are a 1.5-only feature, so the adapter rejects them on `grok-imagine-video`:
+- `modelOptions.reference_audios` takes up to 3 preset TTS voices for generated speech. The prompt names them ``, ``, ``.
+- You can add a first-frame image to the references. The first frame fixes the opening frame, and the references guide subjects, style, and voices.
+- The output of reference-to-video is at most 720p.
+- References work only on 1.5. The adapter rejects them on `grok-imagine-video`.
```typescript
import { generateVideo } from "@tanstack/ai";
@@ -393,6 +471,7 @@ const { jobId } = await generateVideo({
metadata: { role: "reference" },
},
],
+ threadId: "thread-1",
size: "16:9_720p",
modelOptions: {
reference_audios: [{ voice_id: "eve" }],
@@ -400,12 +479,12 @@ const { jobId } = await generateVideo({
});
```
-### Video Editing and Extension
+#### Edit and extend a video
-`grok-imagine-video` (v1.0) can rewrite or continue an existing clip. `grok-imagine-video-1.5` has no video input — the adapter rejects a source-video part or `mode` on that model. Pass the source clip as a `video` prompt part and pick the mode with `modelOptions.mode`:
+`grok-imagine-video` (v1.0) can change or continue a clip. `grok-imagine-video-1.5` takes no video input, so the adapter rejects a source-video part or `mode` on that model. Pass the source clip as a `video` prompt part and pick the mode with `modelOptions.mode`:
-- `mode: 'edit'` posts to `/v1/videos/edits` — modifies only what the prompt asks for, keeping the rest of the clip intact. Duration, aspect ratio, and resolution are inherited from the source (capped at 720p), so the adapter rejects `size`, `aspect_ratio`, `resolution`, and `duration` in this mode rather than sending fields the API ignores.
-- `mode: 'extend'` posts to `/v1/videos/extensions` — continues the clip. `duration` is the length of the **added tail**, not the total: extending a 10-second clip with `duration: 5` yields 15 seconds. Output geometry is still inherited from the source, so `size` / `aspect_ratio` / `resolution` are rejected here too.
+- `mode: 'edit'` posts to `/v1/videos/edits`. It changes only what the prompt asks for. The clip keeps the duration, aspect ratio, and resolution of the source (at most 720p). So the adapter rejects `size`, `aspect_ratio`, `resolution`, and `duration` in this mode.
+- `mode: 'extend'` posts to `/v1/videos/extensions`. It continues the clip. `duration` is the length of the **added part**, not the total: extending a 10-second clip with `duration: 5` gives 15 seconds. The size still comes from the source, so the adapter rejects `size`, `aspect_ratio`, and `resolution`.
```typescript
import { generateVideo } from "@tanstack/ai";
@@ -423,6 +502,7 @@ const edit = await generateVideo({
source: { type: "url", value: "https://example.com/clip.mp4" },
},
],
+ threadId: "thread-1",
modelOptions: { mode: "edit" },
});
@@ -436,142 +516,33 @@ const extension = await generateVideo({
source: { type: "url", value: "https://example.com/clip.mp4" },
},
],
+ threadId: "thread-1",
duration: 5, // added seconds, not the total
modelOptions: { mode: "extend" },
});
```
-Both return the usual `{ jobId }` and are polled like any other Grok video job.
-
-When the job completes, the adapter reports usage on the result: `usage.billed` carries the billed seconds of video (`{ quantity, unit: 'seconds' }`) and `usage.cost` the exact cost in USD, both as returned by the xAI API.
-
-See [Video Generation](../media/video-generation) for the full jobs/polling flow, streaming mode, and the `useGenerateVideo` hook.
-
-## Text-to-Speech
-
-Generate speech with Grok TTS:
-
-```typescript
-import { generateSpeech } from "@tanstack/ai";
-import { grokSpeech } from "@tanstack/ai-grok";
-
-const result = await generateSpeech({
- adapter: grokSpeech("grok-tts"),
- text: "Hello from Grok!",
- voice: "default",
- format: "mp3",
-});
-
-console.log(result.audio); // Base64-encoded audio
-```
-
-## Transcription
-
-Transcribe audio with Grok STT:
-
-```typescript
-import { generateTranscription } from "@tanstack/ai";
-import { grokTranscription } from "@tanstack/ai-grok";
-import { audioFile } from "./audio";
-
-const result = await generateTranscription({
- adapter: grokTranscription("grok-stt"),
- audio: audioFile,
-});
-
-console.log(result.text);
-```
-
-## Realtime Voice
-
-Grok also exposes a Realtime voice adapter (`grokRealtime`) and a token issuer (`grokRealtimeToken`) for low-latency voice conversations. The default model is `grok-voice-think-fast-2.0` (xAI's current recommended speech-to-speech model); `grok-voice-latest` always points at the newest model. The 1.0 ids remain accepted for compatibility, but xAI has deprecated `grok-voice-think-fast-1.0`. See [Realtime Voice Chat](../media/realtime-chat) for the end-to-end flow.
-
-## Environment Variables
-
-Set your API key in environment variables:
-
-```bash
-XAI_API_KEY=xai-...
-```
-
-## Implementation Notes
-
-### Responses API
-
-The Grok text and summarize adapters use xAI's **Responses API** (`/v1/responses`). Requests default to `store: false` and include encrypted reasoning content with `include: ["reasoning.encrypted_content"]`; both can be overridden through `modelOptions`.
-
-The shared Responses implementation supports streaming text, reasoning events, structured output via `text.format`, and user-defined function tools.
-
-## API Reference
-
-### `grokText(model, config?)`
-
-Creates a Grok text adapter using environment variables.
-
-`adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts).
-
-**Parameters:**
-
-- `model` - The model name (`'grok-4.3'` or `'grok-build-0.1'`)
-- `config.baseURL?` - Custom base URL (optional)
-
-**Returns:** A Grok text adapter instance.
-
-### `createGrokText(model, apiKey, config?)`
-
-Creates a Grok text adapter with an explicit API key.
-
-**Parameters:**
+Both return the usual `{ jobId }`. Poll them like any other Grok video job.
-- `model` - The model name
-- `apiKey` - Your xAI API key
-- `config.baseURL?` - Custom base URL (optional)
+When the job is complete, the result has the usage from the xAI API:
-**Returns:** A Grok text adapter instance.
+- `usage.billed`: the billed seconds of video (`{ quantity, unit: 'seconds' }`).
+- `usage.cost`: the exact cost in USD.
-### `grokSummarize(model, config?)`
+### API reference
-Creates a Grok summarization adapter using environment variables.
+Each factory pair has the same shape. The short factory reads `XAI_API_KEY` from the environment. The `create*` factory takes an explicit API key. Both take `model` as the first argument.
-**Returns:** A Grok summarize adapter instance.
-
-### `createGrokSummarize(model, apiKey, config?)`
-
-Creates a Grok summarization adapter with an explicit API key.
-
-**Returns:** A Grok summarize adapter instance.
-
-### `grokImage(model, config?)` / `createGrokImage(model, apiKey, config?)`
-
-Creates a Grok image generation adapter.
-
-### `grokVideo(model, config?)` / `createGrokVideo(model, apiKey, config?)`
-
-Creates a Grok video generation adapter (experimental) for the Grok Imagine video models (`'grok-imagine-video'`, `'grok-imagine-video-1.5'`).
-
-### `grokSpeech(model, config?)` / `createGrokSpeech(model, apiKey, config?)`
-
-Creates a Grok text-to-speech adapter.
-
-### `grokTranscription(model, config?)` / `createGrokTranscription(model, apiKey, config?)`
-
-Creates a Grok speech-to-text adapter.
-
-### `grokRealtime(...)` / `grokRealtimeToken(...)`
-
-Realtime voice adapter and token issuer. See [Realtime Voice Chat](../media/realtime-chat) for usage.
-
-## Next Steps
-
-- [Getting Started](../getting-started/quick-start) - Learn the basics
-- [Tools Guide](../tools/tools) - Learn about tools
-- [Other Adapters](./openai) - Explore other providers
-
-## Provider Tools
-
-Grok does not currently expose provider-specific tool factories.
-Define your own tools with `toolDefinition()` from `@tanstack/ai`.
-
-See [Tools](../tools/tools.md) for the general tool-definition flow, or
-[Provider Tools](../tools/provider-tools.md) for other providers'
-native-tool offerings.
+| Factories | Creates |
+| --- | --- |
+| `grokText(model, config?)` / `createGrokText(model, apiKey, config?)` | Text adapter |
+| `grokSummarize(model, config?)` / `createGrokSummarize(model, apiKey, config?)` | Summarization adapter |
+| `grokImage(model, config?)` / `createGrokImage(model, apiKey, config?)` | Image adapter |
+| `grokVideo(model, config?)` / `createGrokVideo(model, apiKey, config?)` | Video adapter for the Grok Imagine video models. Experimental |
+| `grokSpeech(model, config?)` / `createGrokSpeech(model, apiKey, config?)` | Text-to-speech adapter |
+| `grokTranscription(model, config?)` / `createGrokTranscription(model, apiKey, config?)` | Speech-to-text adapter |
+| `grokFiles` / `createGrokFiles` | Files API adapter. See [Files API](../advanced/files-api) |
+| `grokRealtime(...)` / `grokRealtimeToken(...)` | Realtime voice adapter and token issuer. See [Realtime Voice Chat](../media/realtime-chat) |
+| `grokVertexText` / `grokVertexSummarize` | Text and summarize adapters on Vertex. Import from `@tanstack/ai-grok/vertex` |
+
+`config.baseURL` sets a custom base URL. `adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know.
diff --git a/docs/adapters/groq.md b/docs/adapters/groq.md
index e450ddd895..ecb1586dfb 100644
--- a/docs/adapters/groq.md
+++ b/docs/adapters/groq.md
@@ -2,7 +2,7 @@
title: Groq
id: groq-adapter
order: 6
-description: "Use Groq's fast inference API with TanStack AI for low-latency LLM responses and Whisper transcription — Llama and other open-weight models via @tanstack/ai-groq."
+description: "Use the fast Groq inference API in TanStack AI with @tanstack/ai-groq: low-latency chat with Llama, gpt-oss, Qwen, and Kimi, plus Whisper transcription and Orpheus speech."
keywords:
- tanstack ai
- groq
@@ -15,9 +15,9 @@ keywords:
- transcription
---
-The Groq adapter provides access to Groq's fast inference API, featuring the world's fastest LLM inference and Whisper-based audio transcription.
+The Groq adapter calls Llama, gpt-oss, Qwen, and Kimi with low latency. It also calls Whisper for transcription and Orpheus for speech.
-## Installation
+## 1. Install
@@ -32,100 +32,89 @@ octane: @tanstack/ai-groq
-## Basic Usage
+## 2. Set the API key
-```typescript
-import { chat } from "@tanstack/ai";
-import { groqText } from "@tanstack/ai-groq";
+Put the key in the environment of your server:
-const stream = chat({
- adapter: groqText("llama-3.3-70b-versatile"),
- messages: [{ role: "user", content: "Hello!" }],
-});
+```bash
+GROQ_API_KEY=gsk_...
```
-## Basic Usage - Custom API Key
+`groqText` and the other short factories read `GROQ_API_KEY`. To pass the key in code, use the `create*` factory of the same adapter:
```typescript
-import { chat } from "@tanstack/ai";
import { createGroqText } from "@tanstack/ai-groq";
-const adapter = createGroqText("llama-3.3-70b-versatile", process.env.GROQ_API_KEY!, {
- // ... your config options
-});
-
-const stream = chat({
- adapter,
- messages: [{ role: "user", content: "Hello!" }],
-});
-```
-
-## Configuration
-
-```typescript
-import { createGroqText, type GroqTextConfig } from "@tanstack/ai-groq";
-
-const config: Omit = {
- baseURL: "https://api.groq.com/openai/v1", // Optional, for custom endpoints
-};
-
-const adapter = createGroqText("llama-3.3-70b-versatile", process.env.GROQ_API_KEY!, config);
+const adapter = createGroqText("openai/gpt-oss-120b", process.env.GROQ_API_KEY!);
```
-Need a header on each request, or a log of each model call? This adapter supports [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call).
-
-## Example: Chat Completion
+## 3. Stream a chat from the server
```typescript
-import { chat, toServerSentEventsResponse } from "@tanstack/ai";
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
import { groqText } from "@tanstack/ai-groq";
export async function POST(request: Request) {
- const { messages } = await request.json();
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
const stream = chat({
- adapter: groqText("llama-3.3-70b-versatile"),
+ adapter: groqText("openai/gpt-oss-120b"),
messages,
+ threadId,
+ runId,
});
return toServerSentEventsResponse(stream);
}
```
-## Example: With Tools
+## 4. Render the chat
-```typescript
-import { chat, toolDefinition, type ModelMessage } from "@tanstack/ai";
-import { groqText } from "@tanstack/ai-groq";
-import { z } from "zod";
-
-const searchDatabaseDef = toolDefinition({
- name: "search_database",
- description: "Search the database",
- inputSchema: z.object({
- query: z.string(),
- }),
-});
+The client is the same for every provider. Point `useChat` at your route:
-const searchDatabase = searchDatabaseDef.server(async ({ query }) => {
- // Search database
- return { results: [] };
-});
+```tsx
+import { useState } from "react";
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
-const messages: Array = [{ role: "user", content: "Search for something" }];
+export function Chat() {
+ const [input, setInput] = useState("");
+ const { messages, sendMessage } = useChat({
+ connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
+ });
-const stream = chat({
- adapter: groqText("llama-3.3-70b-versatile"),
- messages,
- tools: [searchDatabase],
-});
+ return (
+
+ );
+}
```
-If Groq rejects a generated tool call with `tool_use_failed` and includes a reconstructable tool call in `failed_generation`, the adapter returns the provider error as that tool's result without executing the call. The agent loop can then repair the call on its next iteration. Other provider errors remain terminal run errors.
+For Vue, Solid, Svelte, Angular, and the other frameworks, see [Quick Start](../getting-started/quick-start#3-render-the-chat).
+
+You now have a streaming chat on Groq. [Tools](../tools/tools) and [Structured Outputs](../structured-outputs/overview) work the same as with every adapter. For the Groq details of both, see [Tool calls that Groq rejects](#tool-calls-that-groq-rejects) and [Structured output](#structured-output).
## Transcription
-Groq exposes Whisper-based speech-to-text via `groqTranscription()` and the `generateTranscription()` activity. The `audio` input accepts a `File`, `Blob`, `ArrayBuffer`, base64 string, data URL, or an `https://` URL (forwarded directly to Groq without re-uploading).
+`groqTranscription` turns speech into text with Whisper:
```typescript
import { generateTranscription } from "@tanstack/ai";
@@ -135,67 +124,51 @@ const result = await generateTranscription({
adapter: groqTranscription("whisper-large-v3-turbo"),
audio: "https://example.com/recording.mp3",
language: "en",
+ threadId: "thread-1",
});
console.log(result.text);
-// verbose_json (the default) populates language, duration, and timestamped segments
+// verbose_json (the default) fills language, duration, and timestamped segments
for (const segment of result.segments ?? []) {
- console.log(`[${segment.start}s → ${segment.end}s] ${segment.text}`);
+ console.log(`[${segment.start}s to ${segment.end}s] ${segment.text}`);
}
```
-Supported models: `whisper-large-v3-turbo`, `whisper-large-v3`. Supported `responseFormat` values: `json`, `text`, `verbose_json` (default). `srt` and `vtt` are not supported by Groq.
+- `audio` takes a `File`, a `Blob`, an `ArrayBuffer`, a base64 string, a data URL, or an `https://` URL. The adapter sends a URL to Groq as it is, with no new upload.
+- Models: `whisper-large-v3-turbo`, `whisper-large-v3`.
+- `responseFormat` takes `json`, `text`, or `verbose_json` (the default). Groq does not support `srt` or `vtt`.
-See [Transcription](../media/transcription) for the full API.
+See [Transcription](../media/transcription).
-## Model Options
+## Speech
-Groq supports various provider-specific options. Sampling parameters live here too — `temperature`, `top_p`, and `max_completion_tokens` (Groq's token-limit key) — rather than as root-level props on `chat()`:
+`groqSpeech` turns text into speech with the Orpheus models:
```typescript
-import { chat } from "@tanstack/ai";
-import { groqText } from "@tanstack/ai-groq";
-
-const stream = chat({
- adapter: groqText("llama-3.3-70b-versatile"),
- messages: [{ role: "user", content: "Hello!" }],
- modelOptions: {
- temperature: 0.7,
- max_completion_tokens: 1024,
- top_p: 0.9,
- },
+import { generateSpeech } from "@tanstack/ai";
+import { groqSpeech } from "@tanstack/ai-groq";
+
+const result = await generateSpeech({
+ adapter: groqSpeech("canopylabs/orpheus-v1-english"),
+ text: "Hello from Groq!",
+ threadId: "thread-1",
+ voice: "autumn",
+ format: "wav",
});
-```
-
-> If you previously passed `temperature` / `topP` / `maxTokens` at the root of `chat()`, see [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options).
-
-### Reasoning
-
-Set how hard a reasoning model thinks with `reasoning` on `chat()`:
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { groqText } from "@tanstack/ai-groq";
-const stream = chat({
- adapter: groqText("openai/gpt-oss-120b"),
- messages: [{ role: "user", content: "Hello!" }],
- reasoning: "medium",
-});
+console.log(result.audio); // Base64-encoded audio
```
-It goes out as `reasoning_effort`:
-
-- `openai/gpt-oss-120b`, `openai/gpt-oss-20b`, `openai/gpt-oss-safeguard-20b`: `low`, `medium`, `high`.
-- `qwen/qwen3-32b`: `off` (sent as `none`) and `high` (sent as `default`).
+- Models: `canopylabs/orpheus-v1-english` and `canopylabs/orpheus-arabic-saudi`.
+- English voices: `autumn` (the default), `diana`, `hannah`, `austin`, `daniel`, `troy`.
+- Arabic voices: `fahad`, `sultan`, `lulwa`, `noura`.
+- Formats: `flac`, `mp3`, `mulaw`, `ogg`, and `wav` (the default). `modelOptions.sample_rate` sets the sample rate.
-To pick how the thinking text comes back, set `reasoning_format` in `modelOptions`.
+See [Text-to-Speech](../media/text-to-speech).
## Summarization
-Summarize long text content:
-
```typescript
import { summarize } from "@tanstack/ai";
import { groqSummarize } from "@tanstack/ai-groq";
@@ -203,6 +176,7 @@ import { groqSummarize } from "@tanstack/ai-groq";
const result = await summarize({
adapter: groqSummarize("llama-3.3-70b-versatile"),
text: "Your long text to summarize...",
+ threadId: "thread-1",
maxLength: 100,
style: "concise", // "concise" | "bullet-points" | "paragraph"
});
@@ -210,107 +184,103 @@ const result = await summarize({
console.log(result.summary);
```
-## Supported Models
-
-Groq offers a diverse selection of models from multiple providers:
-
-### Meta Llama
+## No image generation
-- `llama-3.3-70b-versatile` - Fast, capable model with 128K context
-- `llama-3.1-8b-instant` - Fast, cost-effective model
-- `meta-llama/llama-4-maverick-17b-128e-instruct` - Latest Llama 4 with vision support
-- `meta-llama/llama-4-scout-17b-16e-instruct` - Efficient Llama 4 model
+Groq does not make images. For images, use [OpenAI](./openai) or [Gemini](./gemini). Groq has no provider tools of its own. Define your tools with `toolDefinition()`.
-### Security Models
+## Models and options
-- `meta-llama/llama-guard-4-12b` - Content moderation
-- `meta-llama/llama-prompt-guard-2-86m` - Prompt injection detection
-- `meta-llama/llama-prompt-guard-2-22m` - Lightweight prompt guard
+### Model options
-### OpenAI GPT-OSS Models
+Provider options go in `modelOptions`. This includes the sampling options `temperature`, `top_p`, and `max_completion_tokens` (the token limit of Groq):
-- `openai/gpt-oss-120b` - Large OSS model with reasoning support
-- `openai/gpt-oss-20b` - Efficient OSS model
-- `openai/gpt-oss-safeguard-20b` - Safety-tuned OSS model
-
-### Other Providers
-
-- `moonshotai/kimi-k2-instruct-0905` - Kimi K2 with 256K context
-- `qwen/qwen3-32b` - Qwen 3 with reasoning support
-
-## Environment Variables
-
-Set your API key in environment variables:
+```typescript
+import { chat } from "@tanstack/ai";
+import { groqText } from "@tanstack/ai-groq";
-```bash
-GROQ_API_KEY=gsk_...
+const stream = chat({
+ adapter: groqText("llama-3.3-70b-versatile"),
+ messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
+ modelOptions: {
+ temperature: 0.7,
+ max_completion_tokens: 1024,
+ top_p: 0.9,
+ },
+});
```
-## API Reference
+Sampling options do not go at the root of `chat()`. See [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options).
-### `groqText(model, config?)`
+### Reasoning
-Creates a Groq chat adapter using environment variables.
+Set how hard a reasoning model thinks with `reasoning` on `chat()`:
-`adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts).
+```typescript
+import { chat } from "@tanstack/ai";
+import { groqText } from "@tanstack/ai-groq";
-**Parameters:**
+const stream = chat({
+ adapter: groqText("openai/gpt-oss-120b"),
+ messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
+ reasoning: "medium",
+});
+```
-- `model` - The model name (e.g., `llama-3.3-70b-versatile`)
-- `config` (optional) - Optional configuration object. Supports the same options as `createGroqText` except `apiKey`, which is auto-detected from `GROQ_API_KEY` environment variable. Common options:
- - `baseURL` - Custom base URL for API requests (optional)
+It goes out as `reasoning_effort`:
-**Returns:** A Groq chat adapter instance.
+- `openai/gpt-oss-120b`, `openai/gpt-oss-20b`, `openai/gpt-oss-safeguard-20b`: `low`, `medium`, `high`.
+- `qwen/qwen3-32b`: `off` (sent as `none`) and `high` (sent as `default`).
-### `createGroqText(model, apiKey, config?)`
+To pick how the thinking text comes back, set `reasoning_format` in `modelOptions`.
-Creates a Groq chat adapter with an explicit API key.
+### Models
-**Parameters:**
+The package exports the full lists as `GROQ_CHAT_MODELS`, `GROQ_TRANSCRIPTION_MODELS`, and `GROQ_TTS_MODELS`.
-- `model` - The model name (e.g., `llama-3.3-70b-versatile`)
-- `apiKey` - Your Groq API key
-- `config` (optional) - Optional configuration object:
- - `baseURL` - Custom base URL for API requests (optional)
+| Group | Models |
+| --- | --- |
+| OpenAI gpt-oss | `openai/gpt-oss-120b` (reasoning), `openai/gpt-oss-20b`, `openai/gpt-oss-safeguard-20b` (safety-tuned) |
+| Meta Llama | `meta-llama/llama-4-maverick-17b-128e-instruct` (vision), `meta-llama/llama-4-scout-17b-16e-instruct`, `llama-3.3-70b-versatile` (128K context), `llama-3.1-8b-instant` |
+| Other | `qwen/qwen3-32b` (reasoning), `moonshotai/kimi-k2-instruct-0905` (256K context) |
+| Safety | `meta-llama/llama-guard-4-12b` (content moderation), `meta-llama/llama-prompt-guard-2-86m` and `meta-llama/llama-prompt-guard-2-22m` (prompt injection detection) |
-**Returns:** A Groq chat adapter instance.
+`adapter.inputModalities` lists the input kinds of the selected model. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts).
-### `groqSummarize(model, config?)`
+## Advanced
-Creates a Groq summarization adapter using environment variables.
+### Configuration
-**Returns:** A Groq summarize adapter instance.
+```typescript
+import { createGroqText, type GroqTextConfig } from "@tanstack/ai-groq";
-### `createGroqSummarize(model, apiKey, config?)`
+const config: Omit = {
+ baseURL: "https://api.groq.com/openai/v1", // Optional, for custom endpoints
+};
-Creates a Groq summarization adapter with an explicit API key.
+const adapter = createGroqText("openai/gpt-oss-120b", process.env.GROQ_API_KEY!, config);
+```
-**Returns:** A Groq summarize adapter instance.
+Need a header on each request, or a log of each model call? This adapter supports [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call).
-### `groqTranscription(model, config?)` / `createGroqTranscription(model, apiKey, config?)`
+### Tool calls that Groq rejects
-Creates a Groq transcription (speech-to-text) adapter. The short form reads `GROQ_API_KEY` from the environment; the `create*` form takes an explicit API key. Supported models: `whisper-large-v3-turbo`, `whisper-large-v3`.
+Groq can reject a tool call from the model with `tool_use_failed`. If the error has a tool call in `failed_generation` that the adapter can rebuild, the adapter does not run the call. It returns the provider error as the result of that tool. The agent loop can then repair the call on its next step. Other provider errors still end the run.
-## Structured output
+### Structured output
Some Groq models, for example `llama-3.3-70b-versatile`, reject strict JSON schema. For these models, `chat({ outputSchema })` sends `json_object` and adds your schema to the system prompt. Read [When strict JSON schema is rejected](../structured-outputs/overview.md#when-strict-json-schema-is-rejected).
-## Limitations
-
-- **Text-to-Speech**: Groq does not currently expose a TTS adapter. Use OpenAI, Gemini, ElevenLabs, or fal for speech generation.
-- **Image Generation**: Groq does not support image generation. Use OpenAI, Gemini, or fal for image generation.
-
-## Next Steps
-
-- [Getting Started](../getting-started/quick-start) - Learn the basics
-- [Tools Guide](../tools/tools) - Learn about tools
-- [Other Adapters](./openai) - Explore other providers
+### API reference
-## Provider Tools
+Each factory pair has the same shape. The short factory reads `GROQ_API_KEY` from the environment. The `create*` factory takes an explicit API key. Both take `model` as the first argument.
-Groq does not currently expose provider-specific tool factories.
-Define your own tools with `toolDefinition()` from `@tanstack/ai`.
+| Factories | Creates |
+| --- | --- |
+| `groqText(model, config?)` / `createGroqText(model, apiKey, config?)` | Chat adapter |
+| `groqSummarize(model, config?)` / `createGroqSummarize(model, apiKey, config?)` | Summarization adapter |
+| `groqTranscription(model, config?)` / `createGroqTranscription(model, apiKey, config?)` | Speech-to-text adapter |
+| `groqSpeech(model, config?)` / `createGroqSpeech(model, apiKey, config?)` | Text-to-speech adapter |
-See [Tools](../tools/tools.md) for the general tool-definition flow, or
-[Provider Tools](../tools/provider-tools.md) for other providers'
-native-tool offerings.
+`config.baseURL` sets a custom base URL for the API requests. `adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know.
diff --git a/docs/adapters/llmgateway.md b/docs/adapters/llmgateway.md
index 34b3ccab1e..cf0d7eec36 100644
--- a/docs/adapters/llmgateway.md
+++ b/docs/adapters/llmgateway.md
@@ -1,7 +1,7 @@
---
title: LLM Gateway
id: llmgateway-adapter
-description: "Route chat, tool calling, and structured outputs to hundreds of LLMs from OpenAI, Anthropic, Google, Moonshot, DeepSeek, and more through LLM Gateway's single OpenAI-compatible endpoint in TanStack AI."
+description: "Reach hundreds of models from OpenAI, Anthropic, Google, Moonshot, DeepSeek, and more through one OpenAI-compatible LLM Gateway endpoint with @tanstack/ai-llmgateway. Hosted or self-hosted."
keywords:
- tanstack ai
- llm gateway
@@ -13,9 +13,9 @@ keywords:
- adapter
---
-[LLM Gateway](https://llmgateway.io) is an open-source AI gateway that routes one OpenAI-compatible endpoint to hundreds of models across many providers — with automatic provider selection, fallback, usage analytics, and cost tracking. Use the hosted gateway at `api.llmgateway.io` or point the adapter at your own self-hosted deployment.
+`llmgatewayText` calls one OpenAI-compatible endpoint that routes across providers, falls back on failure, and records usage and cost.
-## Installation
+## 1. Install
@@ -30,75 +30,111 @@ octane: @tanstack/ai-llmgateway
-## Basic Usage
+## 2. Set the API key
-```typescript
-import { chat } from "@tanstack/ai";
-import { llmGatewayText } from "@tanstack/ai-llmgateway";
+Get a key from the [LLM Gateway dashboard](https://llmgateway.io). Put it in the environment of your server:
-const stream = chat({
- adapter: llmGatewayText("gpt-5.6-terra"),
- messages: [{ role: "user", content: "Hello!" }],
-});
+```bash
+LLM_GATEWAY_API_KEY=llmgtwy_your-api-key
```
-`llmGatewayText` reads your API key from the `LLM_GATEWAY_API_KEY` environment variable. Use `createLLMGatewayText` to pass it explicitly.
-
-## Configuration
+`llmGatewayText` and `llmGatewaySummarize` read `LLM_GATEWAY_API_KEY`. To pass the key in code, use the `create*` factory of the same adapter:
```typescript
import { createLLMGatewayText } from "@tanstack/ai-llmgateway";
-const adapter = createLLMGatewayText(
- "gpt-5.6-terra",
- process.env.LLM_GATEWAY_API_KEY!,
- {
- baseURL: "https://api.llmgateway.io/v1", // Optional — set for self-hosted deployments
- },
-);
-```
-
-LLM Gateway is open source and self-hostable; point `baseURL` at your own deployment to keep the same adapter surface.
-
-Need a header on each request, or a log of each model call? This adapter supports [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call).
-
-## Available Models
-
-Any model listed at [llmgateway.io/models](https://llmgateway.io/models) works — pass its id as the model name. A bare model id lets the gateway route to the best available provider; prefix it with `provider/` to pin routing to a specific provider:
-
-```text
-model: "gpt-5.6-terra" // gateway picks the provider
-model: "claude-sonnet-5" // gateway picks the provider
-model: "moonshot/kimi-k3" // always routed to Moonshot
-model: "fireworks/kimi-k3" // always routed to Fireworks
+const adapter = createLLMGatewayText("gpt-5.6-terra", process.env.LLM_GATEWAY_API_KEY!);
```
-A curated set of flagship models (see `LLMGATEWAY_CHAT_MODELS`) additionally carries per-model type metadata — input modalities and provider options — with editor autocomplete. Uncurated ids still work and fall back to text-only input with the generic options.
-
-`adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts).
-
-## Example: Chat Completion
+## 3. Stream a chat from the server
```typescript
-import { chat, toServerSentEventsResponse } from "@tanstack/ai";
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
import { llmGatewayText } from "@tanstack/ai-llmgateway";
export async function POST(request: Request) {
- const { messages } = await request.json();
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
const stream = chat({
adapter: llmGatewayText("gpt-5.6-terra"),
messages,
+ threadId,
+ runId,
});
return toServerSentEventsResponse(stream);
}
```
-## Example: With Tools
+## 4. Render the chat
+
+The client is the same for every provider. Point `useChat` at your route:
+
+```tsx
+import { useState } from "react";
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
+
+export function Chat() {
+ const [input, setInput] = useState("");
+ const { messages, sendMessage } = useChat({
+ connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
+ });
+
+ return (
+
+ );
+}
+```
+
+For Vue, Solid, Svelte, Angular, and the other frameworks, see [Quick Start](../getting-started/quick-start#3-render-the-chat).
+
+You now have a streaming chat through LLM Gateway. To try another model, change the id, for example to `claude-opus-5` or `kimi-k3`.
+
+## Pick a model and a provider
+
+Any model on [llmgateway.io/models](https://llmgateway.io/models) works. Pass its id as the model name:
+
+- A bare id lets the gateway pick the best provider that is available.
+- A `provider/` prefix always sends the request to that provider.
+
+```text
+model: "gpt-5.6-terra" // the gateway picks the provider
+model: "claude-sonnet-5" // the gateway picks the provider
+model: "moonshot/kimi-k3" // always sent to Moonshot
+model: "fireworks/kimi-k3" // always sent to Fireworks
+```
+
+## Tools
+
+Tools work the same as with every adapter:
```typescript
-import { chat, toServerSentEventsResponse, toolDefinition } from "@tanstack/ai";
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+ toolDefinition,
+} from "@tanstack/ai";
import { llmGatewayText } from "@tanstack/ai-llmgateway";
import { z } from "zod";
@@ -111,15 +147,17 @@ const getWeatherDef = toolDefinition({
});
const getWeather = getWeatherDef.server(async ({ location }) => {
- return { temperature: 72, conditions: "sunny" };
+ return { location, temperature: 72, conditions: "sunny" };
});
export async function POST(request: Request) {
- const { messages } = await request.json();
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
const stream = chat({
adapter: llmGatewayText("gpt-5.6-terra"),
messages,
+ threadId,
+ runId,
tools: [getWeather],
});
@@ -127,9 +165,29 @@ export async function POST(request: Request) {
}
```
-## Model Options
+See [Tools](../tools/tools).
+
+## Summarization
+
+```typescript
+import { summarize } from "@tanstack/ai";
+import { llmGatewaySummarize } from "@tanstack/ai-llmgateway";
+
+const result = await summarize({
+ adapter: llmGatewaySummarize("gpt-5.4-mini"),
+ text: "Long article text...",
+ threadId: "thread-1",
+ stream: false,
+});
+
+console.log(result.summary);
+```
+
+## Models and options
+
+### Model options
-The gateway accepts the standard Chat Completions parameters and forwards them to the routed provider (parameters a provider doesn't support are stripped server-side). Sampling parameters live in `modelOptions`:
+The gateway takes the standard Chat Completions parameters and sends them to the provider that it picks. The gateway removes the parameters that a provider does not support. Sampling options go in `modelOptions`:
```typescript
import { chat } from "@tanstack/ai";
@@ -138,46 +196,72 @@ import { llmGatewayText } from "@tanstack/ai-llmgateway";
const stream = chat({
adapter: llmGatewayText("kimi-k3"),
messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
modelOptions: {
temperature: 0.7,
max_completion_tokens: 4096,
},
- reasoning: "high",
});
```
### Reasoning
-`reasoning` goes out as `reasoning_effort`. The types take the levels of each model, and no `budgetTokens`:
+Set `reasoning` on `chat()`:
-- `kimi-k3`, `glm-5.2`, `deepseek-v4-pro`, `qwen3.7-max`: `off` to `max`.
-- `gpt-5.6-terra`: `off`, `low` to `max`. `gpt-5.5`, `gpt-5.4-mini`: `off`, `low` to `xhigh`.
-- `claude-opus-5`, `claude-sonnet-5`, `claude-haiku-4-5`, `minimax-m2.5`: `low` to `max`.
-- `gemini-pro-latest`, `grok-4-5`: `low` to `high`. `gemini-3.6-flash`: `minimal` to `high`.
+```typescript
+import { chat } from "@tanstack/ai";
+import { llmGatewayText } from "@tanstack/ai-llmgateway";
-Reasoning models stream their thinking as `reasoning_content` deltas, which the adapter surfaces as AG-UI `REASONING_*` events.
+const stream = chat({
+ adapter: llmGatewayText("kimi-k3"),
+ messages: [{ role: "user", content: "Plan a database migration." }],
+ threadId: "thread-1",
+ reasoning: "high",
+});
+```
-## Summarization
+It goes out as `reasoning_effort`. The types take the levels of each model, and no `budgetTokens`:
+
+| Model | Levels |
+| --- | --- |
+| `kimi-k3`, `glm-5.2`, `deepseek-v4-pro`, `qwen3.7-max` | `off` to `max` |
+| `gpt-5.6-terra` | `off`, `low` to `max` |
+| `gpt-5.5`, `gpt-5.4-mini` | `off`, `low` to `xhigh` |
+| `claude-opus-5`, `claude-sonnet-5`, `claude-haiku-4-5`, `minimax-m2.5` | `low` to `max` |
+| `gemini-pro-latest`, `grok-4-5` | `low` to `high` |
+| `gemini-3.6-flash` | `minimal` to `high` |
+
+Reasoning models stream their thinking as `reasoning_content` deltas. The adapter turns them into AG-UI `REASONING_*` events. See [Reasoning](../chat/reasoning).
+
+### Models
+
+`LLMGATEWAY_CHAT_MODELS` is a curated set of flagship models. These models have type data for their input kinds and provider options, with autocomplete in the editor. Other ids still work. They get text-only input and the generic options.
+
+`adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts).
+
+## Advanced
+
+### Self-hosted gateway
+
+To use your own deployment, set `baseURL`:
```typescript
-import { summarize } from "@tanstack/ai";
-import { llmGatewaySummarize } from "@tanstack/ai-llmgateway";
+import { createLLMGatewayText } from "@tanstack/ai-llmgateway";
-const result = await summarize({
- adapter: llmGatewaySummarize("gpt-5.4-mini"),
- text: "Long article text...",
- stream: false,
+const adapter = createLLMGatewayText("gpt-5.6-terra", process.env.LLM_GATEWAY_API_KEY!, {
+ baseURL: "https://api.llmgateway.io/v1", // the hosted default. Set your own URL here.
});
-
-console.log(result.summary);
```
-## Environment Variables
+The adapter works the same against a self-hosted gateway.
-Set your API key in environment variables:
+Need a header on each request, or a log of each model call? This adapter supports [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call).
-```bash
-LLM_GATEWAY_API_KEY=llmgtwy_your-api-key
-```
+### API reference
+
+| Factories | Creates |
+| --- | --- |
+| `llmGatewayText(model, config?)` / `createLLMGatewayText(model, apiKey, config?)` | Chat adapter |
+| `llmGatewaySummarize(model, config?)` / `createLLMGatewaySummarize(model, apiKey, config?)` | Summarization adapter |
-Get an API key from the [LLM Gateway dashboard](https://llmgateway.io).
+The short factory reads `LLM_GATEWAY_API_KEY`. The `create*` factory takes an explicit API key.
diff --git a/docs/adapters/lovable.md b/docs/adapters/lovable.md
index f0c09ba671..1adca2eb0e 100644
--- a/docs/adapters/lovable.md
+++ b/docs/adapters/lovable.md
@@ -1,7 +1,7 @@
---
title: Lovable AI Gateway
id: lovable-adapter
-description: "Use one Lovable API key to call Google and OpenAI models through Lovable AI Gateway. Chat, embeddings, image, video, speech, and transcription all work with @tanstack/ai-lovable."
+description: "Call Google and OpenAI models with one Lovable project key through Lovable AI Gateway and @tanstack/ai-lovable: chat, images, video, embeddings, speech, and transcription."
keywords:
- tanstack ai
- lovable
@@ -16,11 +16,9 @@ keywords:
- transcription
---
-Install `@tanstack/ai-lovable`. Then call `lovableText` with a model id such as `google/gemini-3.7-flash`.
+The Lovable adapter calls Google and OpenAI models with one project key. It covers chat, images, video, embeddings, speech, and transcription.
-Lovable AI Gateway sits in front of Google and OpenAI models. You use one project key. You do not set up a provider account.
-
-## Installation
+## 1. Install
@@ -35,262 +33,307 @@ octane: @tanstack/ai-lovable
-## Auth
+## 2. Set the API key
-Set `LOVABLE_API_KEY`. Lovable creates this key for each Cloud project.
+Lovable creates a `LOVABLE_API_KEY` for each Cloud project. Put it in the environment of your server:
```bash
-export LOVABLE_API_KEY="..."
+LOVABLE_API_KEY=your-lovable-key
```
-You can also pass the key to a `create*` factory:
+`lovableText` and the other short factories read `LOVABLE_API_KEY`. To pass the key in code, use the `create*` factory of the same adapter:
```typescript
-import { createLovableText } from "@tanstack/ai-lovable"
+import { createLovableText } from "@tanstack/ai-lovable";
-const adapter = createLovableText(
- "google/gemini-3.7-flash",
- process.env.LOVABLE_API_KEY!,
-)
+const adapter = createLovableText("google/gemini-3.7-flash", process.env.LOVABLE_API_KEY!);
```
-The adapter sends `Authorization: Bearer`, `Lovable-API-Key`, and `X-Lovable-AIG-SDK: tanstack-ai`. Calls go to `https://ai.gateway.lovable.dev/v1`.
-
-## Chat
-
-The default adapter uses the OpenAI Responses API. Model ids use the `google/` or `openai/` form.
+## 3. Stream a chat from the server
-**Server.** An endpoint that streams the reply over SSE:
+Model ids use the `google/` or `openai/` form:
```typescript
-import { chat, toServerSentEventsResponse } from "@tanstack/ai"
-import { lovableText } from "@tanstack/ai-lovable"
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
+import { lovableText } from "@tanstack/ai-lovable";
export async function POST(request: Request) {
- const { messages } = await request.json()
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
const stream = chat({
adapter: lovableText("google/gemini-3.7-flash"),
messages,
- })
+ threadId,
+ runId,
+ });
- return toServerSentEventsResponse(stream)
+ return toServerSentEventsResponse(stream);
}
```
-**Client.** The same `useChat` hook as every other provider:
+## 4. Render the chat
+
+The client is the same for every provider. Point `useChat` at your route:
```tsx
-import { useState } from "react"
-import { fetchServerSentEvents, useChat } from "@tanstack/ai-react"
+import { useState } from "react";
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
export function Chat() {
- const [input, setInput] = useState("")
-
- const { messages, sendMessage, isLoading } = useChat({
+ const [input, setInput] = useState("");
+ const { messages, sendMessage } = useChat({
connection: fetchServerSentEvents("/api/chat"),
- })
+ threadId: "support-chat",
+ });
return (
-
+
-
- )
+ setInput(event.target.value)} />
+
+ );
}
```
-## Chat Completions
-
-Pass `{ api: "chat" }` when the model must talk to Chat Completions. The default is Responses.
-
-```typescript
-import { chat } from "@tanstack/ai"
-import { lovableText } from "@tanstack/ai-lovable"
-
-const stream = chat({
- adapter: lovableText("openai/gpt-5.5", { api: "chat" }),
- messages: [{ role: "user", content: "Hello" }],
-})
-```
-
-`api: "responses"` is the same as the default. `api: "chat-completions"` is the same as `api: "chat"`.
-
-Need a header on each request, or a log of each model call? The chat adapters on this page support [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call).
-
-## Reasoning
+For Vue, Solid, Svelte, Angular, and the other frameworks, see [Quick Start](../getting-started/quick-start#3-render-the-chat).
-Set `reasoning` on `chat()`. The types take the levels of each model:
-
-```typescript
-import { chat } from "@tanstack/ai"
-import { lovableText } from "@tanstack/ai-lovable"
-
-const stream = chat({
- adapter: lovableText("openai/gpt-5.6-sol"),
- messages: [{ role: "user", content: "Plan a database migration." }],
- reasoning: "xhigh",
-})
-```
-
-- Responses (the default) sends `reasoning.effort`. Chat Completions sends `reasoning_effort`.
-- Neither takes `budgetTokens`.
-- The OpenAI and Gemini models take the same levels as on [OpenAI](./openai#reasoning) and [Gemini](./gemini#reasoning).
-
-## Summarize
-
-```typescript
-import { summarize } from "@tanstack/ai"
-import { lovableSummarize } from "@tanstack/ai-lovable"
-
-const result = await summarize({
- adapter: lovableSummarize("google/gemini-3.7-flash"),
- text: "The Fender Stratocaster is a versatile electric guitar.",
-})
-```
+You now have a streaming chat on Lovable. To use an OpenAI model, change the id, for example to `openai/gpt-5.6-sol`.
## Images
-Generate a new image, or pass image parts to edit one. OpenAI image models also accept a mask part (`metadata.role === "mask"`).
+Make a new image from a prompt:
```typescript
-import { generateImage } from "@tanstack/ai"
-import { lovableImage } from "@tanstack/ai-lovable"
+import { generateImage } from "@tanstack/ai";
+import { lovableImage } from "@tanstack/ai-lovable";
const result = await generateImage({
adapter: lovableImage("openai/gpt-image-2"),
prompt: "a red guitar on a wooden bench",
-})
+ threadId: "thread-1",
+});
```
+To edit an image, pass image parts. OpenAI image models also take a mask part (`metadata.role === "mask"`). See [Image Generation](../media/image-generation).
+
## Video
-Video jobs are async. Create a job, poll status, then fetch the MP4 URL. Clips last 4, 6, or 8 seconds. You can write `4`, `"4"`, or `"4s"` (same forms for 6 and 8).
+> [!NOTE]
+> Video generation is an [experimental feature](../media/video-generation).
-1080p and 4K clips are always 8 seconds. `"8s"` counts as 8 seconds. 4K works only on `google/veo-3.1-fast` and `google/veo-3.1`.
+Video runs as a job. `generateVideo()` starts the job and returns a `jobId`. Poll the job until it has an MP4 URL:
```typescript
-import { generateVideo } from "@tanstack/ai"
-import { lovableVideo } from "@tanstack/ai-lovable"
+import { generateVideo, getVideoJobStatus } from "@tanstack/ai";
+import { lovableVideo } from "@tanstack/ai-lovable";
+
+const adapter = lovableVideo("google/veo-3.1-lite");
const { jobId } = await generateVideo({
- adapter: lovableVideo("google/veo-3.1-lite"),
+ adapter,
prompt: "a red guitar on a wooden bench, slow camera push-in",
+ threadId: "thread-1",
duration: "4s",
size: "1280x720",
-})
-```
+});
-Then poll `getVideoJobStatus` with that `jobId`. When the job is complete, the result includes the MP4 URL.
+let status = await getVideoJobStatus({ adapter, jobId });
+while (status.status === "pending" || status.status === "processing") {
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+ status = await getVideoJobStatus({ adapter, jobId });
+}
+
+console.log(status.status === "completed" ? status.url : status.error);
+```
-Pass one image part in `prompt` to animate a still frame.
+- A clip is 4, 6, or 8 seconds. You can write `4`, `"4"`, or `"4s"`. The same forms work for 6 and 8.
+- 1080p and 4K clips are always 8 seconds.
+- 4K works only on `google/veo-3.1-fast` and `google/veo-3.1`.
+- To animate a still frame, pass one image part in `prompt`.
## Embeddings
```typescript
-import { embed } from "@tanstack/ai"
-import { lovableEmbedding } from "@tanstack/ai-lovable"
+import { embed } from "@tanstack/ai";
+import { lovableEmbedding } from "@tanstack/ai-lovable";
const result = await embed({
adapter: lovableEmbedding("google/gemini-embedding-2"),
input: "a red guitar",
-})
+});
-console.log(result.embeddings[0]?.vector)
+console.log(result.embeddings[0]?.vector);
```
## Speech
```typescript
-import { generateSpeech } from "@tanstack/ai"
-import { lovableSpeech } from "@tanstack/ai-lovable"
+import { generateSpeech } from "@tanstack/ai";
+import { lovableSpeech } from "@tanstack/ai-lovable";
const result = await generateSpeech({
adapter: lovableSpeech("openai/gpt-4o-mini-tts"),
text: "Welcome to the guitar shop.",
+ threadId: "thread-1",
voice: "nova",
-})
+});
```
+See [Text-to-Speech](../media/text-to-speech).
+
## Transcription
+`audio` takes a `File`, a `Blob`, an `ArrayBuffer`, a base64 string, or a data URL:
+
```typescript
-import { generateTranscription } from "@tanstack/ai"
-import { lovableTranscription } from "@tanstack/ai-lovable"
+import { generateTranscription } from "@tanstack/ai";
+import { lovableTranscription } from "@tanstack/ai-lovable";
-const audio = await fetch("/voice-note.mp3").then((response) => response.blob())
+const audio = await fetch("https://example.com/voice-note.mp3").then((response) =>
+ response.blob(),
+);
const result = await generateTranscription({
adapter: lovableTranscription("openai/gpt-4o-mini-transcribe"),
audio,
language: "en",
-})
+ threadId: "thread-1",
+});
-console.log(result.text)
+console.log(result.text);
```
-## Bring Your Own Key
+See [Transcription](../media/transcription).
-Users can paste a Lovable project key in the browser. Import `lovableByok` from `@tanstack/ai-lovable/byok`, not from the package main entry.
+## Summarization
```typescript
-import { createLovableText } from "@tanstack/ai-lovable"
-import { lovableByok } from "@tanstack/ai-lovable/byok"
-import { byokMissing, getByokKey } from "@tanstack/ai/byok/server"
+import { summarize } from "@tanstack/ai";
+import { lovableSummarize } from "@tanstack/ai-lovable";
-export async function POST(request: Request) {
- const apiKey = getByokKey(request, lovableByok)
- if (!apiKey) return byokMissing(lovableByok)
+const result = await summarize({
+ adapter: lovableSummarize("google/gemini-3.7-flash"),
+ text: "The Fender Stratocaster is a versatile electric guitar.",
+ threadId: "thread-1",
+});
+```
- const adapter = createLovableText("google/gemini-3.7-flash", apiKey)
- // ...
-}
+## Models and options
+
+### Reasoning
+
+Set `reasoning` on `chat()`. The types take the levels of each model:
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { lovableText } from "@tanstack/ai-lovable";
+
+const stream = chat({
+ adapter: lovableText("openai/gpt-5.6-sol"),
+ messages: [{ role: "user", content: "Plan a database migration." }],
+ threadId: "thread-1",
+ reasoning: "xhigh",
+});
```
-See [Bring Your Own Key](../advanced/byok) for the client store and a save UI.
+- Responses (the default) sends `reasoning.effort`. Chat Completions sends `reasoning_effort`.
+- Neither takes `budgetTokens`.
+- The OpenAI and Gemini models take the same levels as on [OpenAI](./openai#reasoning) and [Gemini](./gemini#reasoning).
-## Models
+### Models
Pass a model id from the curated lists. TypeScript rejects an id that is not on a list.
-Chat:
+| Activity | Default model | List |
+| --- | --- | --- |
+| Chat | `google/gemini-3.7-flash` | `LOVABLE_CHAT_MODELS` |
+| Images | `openai/gpt-image-2` | `LOVABLE_IMAGE_MODELS` |
+| Video | `google/veo-3.1-lite` | `LOVABLE_VIDEO_MODELS` |
+| Embeddings | `google/gemini-embedding-2` | `LOVABLE_EMBEDDING_MODELS` |
+| Speech | `openai/gpt-4o-mini-tts` | `LOVABLE_TTS_MODELS` |
+| Transcription | `openai/gpt-4o-mini-transcribe` | `LOVABLE_TRANSCRIPTION_MODELS` |
+
+For chat, `openai/gpt-5.5` is a fast OpenAI choice.
-- Default: `google/gemini-3.7-flash`
-- Fast OpenAI: `openai/gpt-5.5`
+## Advanced
-Image, video, embeddings, and speech:
+### Chat Completions
+
+The default text adapter uses the OpenAI Responses API. If a model must use Chat Completions, pass `{ api: "chat" }`:
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { lovableText } from "@tanstack/ai-lovable";
+
+const stream = chat({
+ adapter: lovableText("openai/gpt-5.5", { api: "chat" }),
+ messages: [{ role: "user", content: "Hello" }],
+ threadId: "thread-1",
+});
+```
+
+- `api: "responses"` is the same as the default.
+- `api: "chat-completions"` is the same as `api: "chat"`.
+
+Need a header on each request, or a log of each model call? The chat adapters on this page support [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call).
+
+### Bring Your Own Key
+
+Users can paste a Lovable project key in the browser. Import `lovableByok` from `@tanstack/ai-lovable/byok`. The package main entry does not export it.
+
+```typescript
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
+import { byokMissing, getByokKey } from "@tanstack/ai/byok/server";
+import { createLovableText } from "@tanstack/ai-lovable";
+import { lovableByok } from "@tanstack/ai-lovable/byok";
+
+export async function POST(request: Request) {
+ const apiKey = getByokKey(request, lovableByok);
+ if (!apiKey) return byokMissing(lovableByok);
+
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
+
+ const stream = chat({
+ adapter: createLovableText("google/gemini-3.7-flash", apiKey),
+ messages,
+ threadId,
+ runId,
+ });
+
+ return toServerSentEventsResponse(stream);
+}
+```
+
+See [Bring Your Own Key](../advanced/byok) for the client store and a save UI.
-- Image default: `openai/gpt-image-2`
-- Video default: `google/veo-3.1-lite`
-- Embedding default: `google/gemini-embedding-2`
-- Speech default: `openai/gpt-4o-mini-tts`
-- Transcription default: `openai/gpt-4o-mini-transcribe`
+### Requests
-The curated lists are `LOVABLE_CHAT_MODELS`, `LOVABLE_IMAGE_MODELS`, `LOVABLE_VIDEO_MODELS`, `LOVABLE_EMBEDDING_MODELS`, `LOVABLE_TTS_MODELS`, and `LOVABLE_TRANSCRIPTION_MODELS`.
+- Calls go to `https://ai.gateway.lovable.dev/v1`.
+- The adapter sends `Authorization: Bearer`, `Lovable-API-Key`, and `X-Lovable-AIG-SDK: tanstack-ai`.
-## Errors
+### Errors
-- `429 Too Many Requests`: the workspace hit its request-per-minute limit.
-- `402 Payment Required`: the workspace is out of credits.
+- `429 Too Many Requests`: the workspace hit its limit of requests per minute.
+- `402 Payment Required`: the workspace has no credits left.
See [Lovable AI features](https://docs.lovable.dev/features/ai) for models, credits, and rate limits.
diff --git a/docs/adapters/mistral.md b/docs/adapters/mistral.md
index 87f87057a9..088ee8c7f0 100644
--- a/docs/adapters/mistral.md
+++ b/docs/adapters/mistral.md
@@ -2,7 +2,7 @@
title: Mistral
id: mistral-adapter
order: 7
-description: "Use Mistral models with TanStack AI — Mistral Large, Mistral Medium, Pixtral vision models, Magistral reasoning models, and Codestral via @tanstack/ai-mistral."
+description: "Use Mistral models in TanStack AI with @tanstack/ai-mistral: Mistral Large and Medium, Pixtral vision, Magistral reasoning, Codestral, and Mistral embeddings."
keywords:
- tanstack ai
- mistral
@@ -14,9 +14,9 @@ keywords:
- llm
---
-The Mistral adapter provides access to Mistral's chat models, including Mistral Large, the multimodal Pixtral family, the Magistral reasoning models, and the Codestral code-specialized model.
+The Mistral adapter calls Mistral Large, Pixtral, Magistral, and Codestral. It also creates embeddings.
-## Installation
+## 1. Install
@@ -31,172 +31,89 @@ octane: @tanstack/ai-mistral
-## Basic Usage
+## 2. Set the API key
-```typescript
-import { chat } from "@tanstack/ai";
-import { mistralText } from "@tanstack/ai-mistral";
+Get a key from the [Mistral Console](https://console.mistral.ai/). Put it in the environment of your server:
-const stream = chat({
- adapter: mistralText("mistral-large-latest"),
- messages: [{ role: "user", content: "Hello!" }],
-});
+```bash
+MISTRAL_API_KEY=...
```
-## Basic Usage - Custom API Key
+`mistralText` and `mistralEmbedding` read `MISTRAL_API_KEY`. To pass the key in code, use `createMistralText`:
```typescript
-import { chat } from "@tanstack/ai";
import { createMistralText } from "@tanstack/ai-mistral";
-const adapter = createMistralText(
- "mistral-large-latest",
- process.env.MISTRAL_API_KEY!,
-);
-
-const stream = chat({
- adapter,
- messages: [{ role: "user", content: "Hello!" }],
-});
+const adapter = createMistralText("mistral-large-latest", process.env.MISTRAL_API_KEY!);
```
-## Configuration
+## 3. Stream a chat from the server
```typescript
import {
- createMistralText,
- type MistralTextConfig,
-} from "@tanstack/ai-mistral";
-
-const config: Omit = {
- baseURL: "https://api.mistral.ai", // Optional, this is the default
- defaultHeaders: {
- "X-Custom-Header": "value",
- },
-};
-
-const adapter = createMistralText(
- "mistral-large-latest",
- process.env.MISTRAL_API_KEY!,
- config,
-);
-```
-
-## Behind a proxy
-
-Route every request through a gateway, such as Cloudflare AI Gateway or a corporate proxy, with `baseURL` and `defaultHeaders`. These two option names are the same on every TanStack AI adapter, so one gateway config works for all of them.
-
-```typescript
-import { createMistralText } from "@tanstack/ai-mistral";
-
-const adapter = createMistralText("mistral-large-latest", process.env.MISTRAL_API_KEY!, {
- baseURL: "https://gateway.example.com/mistral",
- defaultHeaders: { "cf-aig-authorization": `Bearer ${process.env.GATEWAY_TOKEN}` },
-});
-```
-
-`serverURL` is an alias of `baseURL`. If you set both, `baseURL` wins.
-
-Need more than a fixed header, such as a log of each model call? This adapter supports [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call).
-
-## Mistral on Vertex
-
-Use `@tanstack/ai-mistral/vertex` when Mistral must run on Vertex AI. That
-path uses Google Cloud credentials and the publisher `rawPredict` endpoint.
-
-Mistral on Vertex is regional only. Use `us-central1` or `europe-west4`.
-
-
-
-react: @tanstack/ai-mistral google-auth-library
-vue: @tanstack/ai-mistral google-auth-library
-solid: @tanstack/ai-mistral google-auth-library
-svelte: @tanstack/ai-mistral google-auth-library
-preact: @tanstack/ai-mistral google-auth-library
-angular: @tanstack/ai-mistral google-auth-library
-vanilla: @tanstack/ai-mistral google-auth-library
-octane: @tanstack/ai-mistral google-auth-library
-
-
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { mistralVertexText } from "@tanstack/ai-mistral/vertex";
-
-const stream = chat({
- adapter: mistralVertexText("mistral-medium-3", {
- project: "my-project",
- location: "europe-west4",
- }),
- messages: [{ role: "user", content: "Hello!" }],
-});
-```
-
-`project` and `location` use the same names as `@tanstack/ai-vertex`.
-`location` is required.
-
-`mistralVertexText` accepts only the Mistral chat models that Vertex lists:
-
-- `mistral-medium-3`
-- `mistral-small-2503`
-- `codestral-2`
-
-Mistral API aliases such as `mistral-large-latest` and `mistral-medium-latest`
-are not Vertex model ids. Vertex also lists `mistral-ocr-2505`, but that
-model is OCR, not chat.
-
-Install `google-auth-library` for Application Default Credentials, or pass
-`authClient` or `getAccessToken`.
-
-Gemini on Vertex lives in [`@tanstack/ai-vertex`](./vertex).
-
-## Example: Chat Completion
-
-```typescript
-import { chat, toServerSentEventsResponse } from "@tanstack/ai";
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
import { mistralText } from "@tanstack/ai-mistral";
export async function POST(request: Request) {
- const { messages } = await request.json();
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
const stream = chat({
adapter: mistralText("mistral-large-latest"),
messages,
+ threadId,
+ runId,
});
return toServerSentEventsResponse(stream);
}
```
-## Example: With Tools
+## 4. Render the chat
-```typescript
-import { chat, toolDefinition } from "@tanstack/ai";
-import { mistralText } from "@tanstack/ai-mistral";
-import { z } from "zod";
+The client is the same for every provider. Point `useChat` at your route:
-const getWeatherDef = toolDefinition({
- name: "get_weather",
- description: "Get the current weather for a location",
- inputSchema: z.object({
- location: z.string(),
- }),
-});
+```tsx
+import { useState } from "react";
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
-const getWeather = getWeatherDef.server(async ({ location }) => {
- return { temperature: 72, conditions: "sunny" };
-});
+export function Chat() {
+ const [input, setInput] = useState("");
+ const { messages, sendMessage } = useChat({
+ connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
+ });
-const stream = chat({
- adapter: mistralText("mistral-large-latest"),
- messages: [{ role: "user", content: "What's the weather in Paris?" }],
- tools: [getWeather],
-});
+ return (
+
+ );
+}
```
-## Example: Multimodal (Vision)
+For Vue, Solid, Svelte, Angular, and the other frameworks, see [Quick Start](../getting-started/quick-start#3-render-the-chat).
+
+You now have a streaming Mistral chat. [Tools](../tools/tools) work the same as with every adapter. Mistral has no provider tools of its own, so define your tools with `toolDefinition()`.
-Use a vision-capable model — `pixtral-large-latest`, `pixtral-12b-2409`, `mistral-medium-latest`, or `mistral-small-latest` — to send images alongside text:
+## Images in a message
+
+Send an image with the text to a vision model: `pixtral-large-latest`, `pixtral-12b-2409`, `mistral-medium-latest`, or `mistral-small-latest`.
```typescript
import { chat } from "@tanstack/ai";
@@ -219,10 +136,11 @@ const stream = chat({
],
},
],
+ threadId: "thread-1",
});
```
-For data-URL or base64 images, set `source.type` to `"data"` and provide `mimeType`:
+For base64 data, set `source.type` to `"data"` and add `mimeType`:
```typescript
import { chat } from "@tanstack/ai";
@@ -248,65 +166,15 @@ const stream = chat({
],
},
],
+ threadId: "thread-1",
});
```
-See [Multimodal Content](../advanced/multimodal-content) for the full content-part shape.
-
-## Example: Reasoning (Magistral)
-
-Magistral models (`magistral-medium-latest`, `magistral-small-latest`) stream their reasoning as separate events before the final answer. The adapter emits AG-UI `REASONING_*` chunks for the thinking content and `TEXT_MESSAGE_*` chunks for the answer:
-
-```typescript ignore
-// ignore: narrowing the raw AG-UI stream by `chunk.type` relies on @ag-ui/core's
-// discriminated-union `type` field, which kiira can't resolve in a source-only
-// check. The runtime behaviour is exactly as shown.
-import { chat } from "@tanstack/ai";
-import { mistralText } from "@tanstack/ai-mistral";
-
-const stream = chat({
- adapter: mistralText("magistral-medium-latest"),
- messages: [{ role: "user", content: "Why is the sky blue?" }],
-});
-
-for await (const chunk of stream) {
- if (chunk.type === "REASONING_MESSAGE_CONTENT") {
- process.stdout.write(`[thinking] ${chunk.delta}`);
- } else if (chunk.type === "TEXT_MESSAGE_CONTENT") {
- process.stdout.write(chunk.delta);
- }
-}
-```
-
-Reasoning events are always closed before any text or tool output begins, so consumers see a complete `REASONING_START → REASONING_MESSAGE_START → REASONING_MESSAGE_CONTENT* → REASONING_MESSAGE_END → REASONING_END` sequence first.
+See [Multimodal Content](../advanced/multimodal-content) for the full shape of a content part.
-See [Thinking & Reasoning](../chat/thinking-content) for the cross-provider event spec.
+## Structured output
-## Reasoning
-
-Set `reasoning` on `chat()` to turn thinking up, down, or off:
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { mistralText } from "@tanstack/ai-mistral";
-
-const stream = chat({
- adapter: mistralText("mistral-medium-latest"),
- messages: [{ role: "user", content: "Why is the sky blue?" }],
- reasoning: "high",
-});
-```
-
-What goes on the wire:
-
-- `mistral-medium-latest`, `mistral-small-latest`: `reasoning_effort`. They take `off` (sent as `none`) and `high`.
-- `magistral-medium-latest`, `magistral-small-latest`: they take `off` and `high`. `high` sends `prompt_mode: "reasoning"`. `off` sends nothing.
-
-The other models take no `reasoning`.
-
-## Example: Structured Output
-
-Generate JSON that conforms to a Zod schema using Mistral's `json_schema` response format:
+Get JSON that matches a Zod schema. The adapter uses the `json_schema` response format of Mistral:
```typescript
import { chat } from "@tanstack/ai";
@@ -324,17 +192,54 @@ const recipe = await chat({
messages: [
{ role: "user", content: "Give me a chocolate chip cookie recipe." },
],
+ threadId: "thread-1",
outputSchema: recipeSchema,
});
console.log(recipe.name); // typed as z.infer
```
-See [Structured Outputs](../chat/structured-outputs) for the full guide.
+See [Structured Outputs](../chat/structured-outputs).
+
+## Embeddings
+
+```typescript
+import { embed } from "@tanstack/ai";
+import { mistralEmbedding } from "@tanstack/ai-mistral";
+
+const result = await embed({
+ adapter: mistralEmbedding("mistral-embed"),
+ input: ["a red guitar", "a blue drum kit"],
+});
+
+console.log(result.embeddings[0]?.vector); // 1024 dimensions
+```
+
+`mistral-embed` always gives 1024 dimensions. `codestral-embed` is tuned for code, and it takes the top-level `dimensions` option:
+
+```typescript
+import { embed } from "@tanstack/ai";
+import { mistralEmbedding } from "@tanstack/ai-mistral";
+
+const result = await embed({
+ adapter: mistralEmbedding("codestral-embed"),
+ input: "function add(a, b) { return a + b }",
+ dimensions: 512,
+});
+```
+
+See the [Embeddings guide](../embeddings.md).
-## Model Options
+## What Mistral does not do
-Mistral exposes provider-specific options via `modelOptions`:
+- Image, audio, and video generation. Use [OpenAI](./openai), [Gemini](./gemini), or [fal.ai](./fal).
+- Text-to-speech and transcription. Use [OpenAI](./openai) or [ElevenLabs](./elevenlabs).
+
+## Models and options
+
+### Model options
+
+Provider options go in `modelOptions`, with the snake_case names of Mistral. This includes the sampling options `temperature`, `top_p`, and `max_tokens`:
```typescript
import { chat } from "@tanstack/ai";
@@ -343,6 +248,7 @@ import { mistralText } from "@tanstack/ai-mistral";
const stream = chat({
adapter: mistralText("mistral-large-latest"),
messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
modelOptions: {
temperature: 0.7,
top_p: 0.9,
@@ -358,133 +264,175 @@ const stream = chat({
});
```
-> All sampling parameters — including `temperature`, `top_p`, and `max_tokens` —
-> go inside `modelOptions` using Mistral's native (snake_case) names.
+Sampling options do not go at the root of `chat()`. See [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options).
-## Prompt caching
+### Reasoning
-Mistral caches the start of your requests by itself. `chat()` sends `prompt_cache_key` by default. The key is the `threadId` that you pass, so one conversation shares a cache. The key goes only to `api.mistral.ai`, on `chat()` and on structured output. With `promptCache: 'none'`, no key goes out. See [Prompt Caching](../advanced/prompt-caching).
+Set `reasoning` on `chat()` to turn thinking up, down, or off:
-Cache reads show up in `usage.promptTokensDetails.cachedTokens`.
+```typescript
+import { chat } from "@tanstack/ai";
+import { mistralText } from "@tanstack/ai-mistral";
-## Embeddings
+const stream = chat({
+ adapter: mistralText("mistral-medium-latest"),
+ messages: [{ role: "user", content: "Why is the sky blue?" }],
+ threadId: "thread-1",
+ reasoning: "high",
+});
+```
-Generate embedding vectors with mistral-embed or codestral-embed:
+What goes to Mistral:
-```typescript
-import { embed } from "@tanstack/ai";
-import { mistralEmbedding } from "@tanstack/ai-mistral";
+- `mistral-medium-latest`, `mistral-small-latest`: `reasoning_effort`. They take `off` (sent as `none`) and `high`.
+- `magistral-medium-latest`, `magistral-small-latest`: they take `off` and `high`. `high` sends `prompt_mode: "reasoning"`. `off` sends nothing.
-const result = await embed({
- adapter: mistralEmbedding("mistral-embed"),
- input: ["a red guitar", "a blue drum kit"],
-});
+The other models take no `reasoning`. The Magistral models stream their thinking before the answer. See [Magistral thinking events](#magistral-thinking-events).
-console.log(result.embeddings[0]?.vector); // 1024 dimensions
-```
+### Models
-`mistral-embed` has fixed 1024-dimension output; `codestral-embed` (tuned for code) supports the top-level `dimensions` option:
+| Group | Models |
+| --- | --- |
+| Chat | `mistral-large-latest` (flagship, 128k context), `mistral-medium-latest` (with vision), `mistral-small-latest` (fast, with vision), `ministral-8b-latest`, `ministral-3b-latest`, `open-mistral-nemo` |
+| Code | `codestral-latest` (256k context) |
+| Vision | `pixtral-large-latest`, `pixtral-12b-2409` |
+| Reasoning | `magistral-medium-latest`, `magistral-small-latest` |
+| Embeddings | `mistral-embed`, `codestral-embed` |
+| Chat on Vertex | `mistral-medium-3`, `mistral-small-2503`, `codestral-2` |
-```typescript
-import { embed } from "@tanstack/ai";
-import { mistralEmbedding } from "@tanstack/ai-mistral";
+See the [model comparison](https://docs.mistral.ai/getting-started/models/compare) of Mistral for the full details. `adapter.inputModalities` lists the input kinds of the selected model. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts).
-const result = await embed({
- adapter: mistralEmbedding("codestral-embed"),
- input: "function add(a, b) { return a + b }",
- dimensions: 512,
-});
-```
+## Advanced
-See the [Embeddings guide](../embeddings.md) for the full API.
+### Configuration
-## Environment Variables
+```typescript
+import {
+ createMistralText,
+ type MistralTextConfig,
+} from "@tanstack/ai-mistral";
-Set your API key in environment variables:
+const config: Omit = {
+ baseURL: "https://api.mistral.ai", // Optional, this is the default
+ defaultHeaders: {
+ "X-Custom-Header": "value",
+ },
+};
-```bash
-MISTRAL_API_KEY=...
+const adapter = createMistralText(
+ "mistral-large-latest",
+ process.env.MISTRAL_API_KEY!,
+ config,
+);
```
-Get a key from the [Mistral Console](https://console.mistral.ai/).
-
-## Supported Models
+### Behind a proxy
-### Chat
+Send every request through a gateway, such as Cloudflare AI Gateway or a corporate proxy, with `baseURL` and `defaultHeaders`. These two option names are the same on every TanStack AI adapter, so one gateway config works for all of them.
-- `mistral-large-latest` — Flagship general-purpose model (128k context)
-- `mistral-medium-latest` — Multimodal mid-tier model with vision
-- `mistral-small-latest` — Fast, affordable multimodal model with vision
-- `ministral-8b-latest` — 8B edge model
-- `ministral-3b-latest` — 3B edge model
-- `open-mistral-nemo` — Open 12B model
+```typescript
+import { createMistralText } from "@tanstack/ai-mistral";
-### Code
+const adapter = createMistralText("mistral-large-latest", process.env.MISTRAL_API_KEY!, {
+ baseURL: "https://gateway.example.com/mistral",
+ defaultHeaders: { "cf-aig-authorization": `Bearer ${process.env.GATEWAY_TOKEN}` },
+});
+```
-- `codestral-latest` — Code-specialized model (256k context)
+- `serverURL` is an alias of `baseURL`. If you set both, `baseURL` wins.
+- Need more than a fixed header, such as a log of each model call? This adapter supports [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call).
-### Vision
+### Prompt caching
-- `pixtral-large-latest` — Large vision model
-- `pixtral-12b-2409` — 12B vision model
+Mistral caches the start of your requests by itself.
-### Reasoning
+- `chat()` sends `prompt_cache_key` by default. The key is the `threadId` that you pass, so one conversation shares a cache.
+- The key goes only to `api.mistral.ai`, on `chat()` and on structured output.
+- With `promptCache: 'none'`, no key goes out.
+- Cache reads show up in `usage.promptTokensDetails.cachedTokens`.
-Reasoning content is streamed as `REASONING_*` events before the final answer.
+See [Prompt Caching](../advanced/prompt-caching).
-- `magistral-medium-latest` — Mid-tier reasoning model
-- `magistral-small-latest` — Small reasoning model
+### Magistral thinking events
-See [Mistral's model comparison](https://docs.mistral.ai/getting-started/models/compare) for full details.
+The Magistral models (`magistral-medium-latest`, `magistral-small-latest`) stream their thinking as separate events before the answer. The adapter sends AG-UI `REASONING_*` chunks for the thinking and `TEXT_MESSAGE_*` chunks for the answer:
-## API Reference
+```typescript ignore
+// ignore: narrowing the raw AG-UI stream by `chunk.type` relies on @ag-ui/core's
+// discriminated-union `type` field, which kiira can't resolve in a source-only
+// check. The runtime behaviour is exactly as shown.
+import { chat } from "@tanstack/ai";
+import { mistralText } from "@tanstack/ai-mistral";
-### `mistralText(model, config?)`
+const stream = chat({
+ adapter: mistralText("magistral-medium-latest"),
+ messages: [{ role: "user", content: "Why is the sky blue?" }],
+ threadId: "thread-1",
+});
-Creates a Mistral text adapter using the `MISTRAL_API_KEY` environment variable.
+for await (const chunk of stream) {
+ if (chunk.type === "REASONING_MESSAGE_CONTENT") {
+ process.stdout.write(`[thinking] ${chunk.delta}`);
+ } else if (chunk.type === "TEXT_MESSAGE_CONTENT") {
+ process.stdout.write(chunk.delta);
+ }
+}
+```
-`adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts).
+The reasoning events always close before any text or tool output starts. So you first see the full sequence: `REASONING_START`, `REASONING_MESSAGE_START`, `REASONING_MESSAGE_CONTENT` (one or more), `REASONING_MESSAGE_END`, `REASONING_END`.
-**Parameters:**
+See [Thinking & Reasoning](../chat/thinking-content) for the event spec of all providers.
-- `model` — The model name (e.g., `'mistral-large-latest'`)
-- `config.baseURL?`: custom base URL (optional). `serverURL` is an alias.
-- `config.defaultHeaders?` — Headers to attach to every request (optional)
+### Mistral on Vertex
-**Returns:** A Mistral text adapter instance.
+Use `@tanstack/ai-mistral/vertex` when Mistral must run on Vertex AI. That path uses Google Cloud credentials and the publisher `rawPredict` endpoint.
-### `createMistralText(model, apiKey, config?)`
+Mistral on Vertex is regional only. Use `us-central1` or `europe-west4`.
-Creates a Mistral text adapter with an explicit API key.
+
-**Parameters:**
+react: @tanstack/ai-mistral google-auth-library
+vue: @tanstack/ai-mistral google-auth-library
+solid: @tanstack/ai-mistral google-auth-library
+svelte: @tanstack/ai-mistral google-auth-library
+preact: @tanstack/ai-mistral google-auth-library
+angular: @tanstack/ai-mistral google-auth-library
+vanilla: @tanstack/ai-mistral google-auth-library
+octane: @tanstack/ai-mistral google-auth-library
-- `model` — The model name
-- `apiKey` — Your Mistral API key
-- `config.baseURL?`: custom base URL (optional). `serverURL` is an alias.
-- `config.defaultHeaders?` — Headers to attach to every request (optional)
+
-**Returns:** A Mistral text adapter instance.
+```typescript
+import { chat } from "@tanstack/ai";
+import { mistralVertexText } from "@tanstack/ai-mistral/vertex";
-## Limitations
+const stream = chat({
+ adapter: mistralVertexText("mistral-medium-3", {
+ project: "my-project",
+ location: "europe-west4",
+ }),
+ messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
+});
+```
-- **Embeddings**: Use the [Mistral SDK](https://github.com/mistralai/client-ts) directly for `mistral-embed`.
-- **Image / Audio / Video Generation**: Mistral does not provide these endpoints. Use OpenAI, Gemini, or fal.ai.
-- **Text-to-Speech / Transcription**: Not supported. Use OpenAI or ElevenLabs.
+- `project` and `location` use the same names as `@tanstack/ai-vertex`. `location` is required.
+- `mistralVertexText` accepts only the Mistral chat models that Vertex lists: `mistral-medium-3`, `mistral-small-2503`, and `codestral-2`.
+- Mistral API aliases such as `mistral-large-latest` and `mistral-medium-latest` are not Vertex model ids. Vertex also lists `mistral-ocr-2505`, but that model is OCR, not chat.
+- Install `google-auth-library` for Application Default Credentials, or pass `authClient` or `getAccessToken`.
-## Next Steps
+Gemini on Vertex is in [`@tanstack/ai-vertex`](./vertex).
-- [Getting Started](../getting-started/quick-start) — Learn the basics
-- [Tools Guide](../tools/tools) — Define and call tools
-- [Structured Outputs](../chat/structured-outputs) — Generate typed JSON
-- [Multimodal Content](../advanced/multimodal-content) — Send images and other modalities
-- [Other Adapters](./openai) — Explore other providers
+### API reference
-## Provider Tools
+| Factory | Creates |
+| --- | --- |
+| `mistralText(model, config?)` | Text adapter. Reads `MISTRAL_API_KEY` |
+| `createMistralText(model, apiKey, config?)` | Text adapter with an explicit API key |
+| `mistralEmbedding(model, config?)` / `createMistralEmbedding(model, apiKey, config?)` | Embedding adapter |
+| `mistralVertexText(model, config)` | Text adapter on Vertex. Import from `@tanstack/ai-mistral/vertex` |
-Mistral does not currently expose provider-specific tool factories.
-Define your own tools with `toolDefinition()` from `@tanstack/ai`.
+- `config.baseURL`: a custom base URL. Optional. `serverURL` is an alias.
+- `config.defaultHeaders`: headers for every request. Optional.
-See [Tools](../tools/tools.md) for the general tool-definition flow, or
-[Provider Tools](../tools/provider-tools.md) for other providers'
-native-tool offerings.
+`adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know.
diff --git a/docs/adapters/ollama.md b/docs/adapters/ollama.md
index 406887868a..2f8e695b6b 100644
--- a/docs/adapters/ollama.md
+++ b/docs/adapters/ollama.md
@@ -2,7 +2,7 @@
title: Ollama
id: ollama-adapter
order: 4
-description: "Run local LLMs with Ollama in TanStack AI for private, no-cost AI on your own hardware via the @tanstack/ai-ollama adapter."
+description: "Run local open models with Ollama in TanStack AI with @tanstack/ai-ollama. No API key, no API cost, and your data stays on your hardware."
keywords:
- tanstack ai
- ollama
@@ -14,9 +14,9 @@ keywords:
- adapter
---
-The Ollama adapter provides access to local models running via Ollama, allowing you to run AI models on your own infrastructure with full privacy and no API costs.
+The Ollama adapter calls Qwen, Llama, gpt-oss, and other open models on your machine. It needs no API key and it works offline.
-## Installation
+## 1. Install
@@ -31,153 +31,171 @@ octane: @tanstack/ai-ollama
-## Basic Usage
+## 2. Start Ollama and pull a model
-```typescript
-import { chat } from "@tanstack/ai";
-import { ollamaText } from "@tanstack/ai-ollama";
-
-const stream = chat({
- adapter: ollamaText("llama3"),
- messages: [{ role: "user", content: "Hello!" }],
-});
-```
+Ollama needs no API key. It needs a running server with a model.
-## Basic Usage - Custom Host
+1. Install Ollama:
-```typescript
-import { chat } from "@tanstack/ai";
-import { createOllamaChat } from "@tanstack/ai-ollama";
+```bash
+# macOS
+brew install ollama
-const adapter = createOllamaChat("llama3", "http://your-server:11434");
+# Linux
+curl -fsSL https://ollama.com/install.sh | sh
-const stream = chat({
- adapter,
- messages: [{ role: "user", content: "Hello!" }],
-});
+# Windows: download the installer from https://ollama.com
```
-## Configuration
-
-```typescript
-import { createOllamaChat } from "@tanstack/ai-ollama";
-
-// Custom host (URL string)
-const adapter = createOllamaChat("llama3", "http://your-server:11434");
+2. Pull a model:
-// Custom client config (e.g., custom headers, fetch)
-const adapter2 = createOllamaChat("llama3", {
- host: "http://your-server:11434",
- headers: { Authorization: "Bearer ..." },
-});
+```bash
+ollama pull qwen3:8b
```
-## Behind a proxy
+3. Start the server:
-Route every request through a gateway, such as Cloudflare AI Gateway or a corporate proxy, with `baseURL` and `defaultHeaders`. These two option names are the same on every TanStack AI adapter, so one gateway config works for all of them.
-
-```typescript
-import { createOllamaChat } from "@tanstack/ai-ollama";
-
-const adapter = createOllamaChat("llama3", {
- baseURL: "https://gateway.example.com/ollama",
- defaultHeaders: { Authorization: `Bearer ${process.env.GATEWAY_TOKEN}` },
-});
+```bash
+ollama serve
```
-`host` and `headers` are aliases of the same two options. If you set both forms, `baseURL` and `defaultHeaders` win.
-
-Need more than a fixed header, such as a log of each model call? This adapter supports [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). An adapter with your own `Ollama` client ignores it and the abort signal.
-
-## Available Models
-
-To see available models on your Ollama instance:
+The server runs on `http://localhost:11434` by default. If your server is somewhere else, set `OLLAMA_HOST`:
```bash
-ollama list
+OLLAMA_HOST=http://localhost:11434
```
-### Popular Models
-
-- `llama3` / `llama3.1` / `llama3.2` - Meta's Llama models
-- `mistral` / `mistral:7b` - Mistral AI models
-- `mixtral` - Mixtral MoE model
-- `codellama` - Code-focused Llama
-- `phi3` - Microsoft's Phi models
-- `gemma` / `gemma2` - Google's Gemma models
-- `qwen2` / `qwen2.5` - Alibaba's Qwen models
-- `deepseek-coder` - DeepSeek coding model
-
-## Example: Chat Completion
+## 3. Stream a chat from the server
```typescript
-import { chat, toServerSentEventsResponse } from "@tanstack/ai";
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
import { ollamaText } from "@tanstack/ai-ollama";
export async function POST(request: Request) {
- const { messages } = await request.json();
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
const stream = chat({
- adapter: ollamaText("llama3"),
+ adapter: ollamaText("qwen3:8b"),
messages,
+ threadId,
+ runId,
});
return toServerSentEventsResponse(stream);
}
```
-## Example: With Tools
+`ollamaText` reads the host from `OLLAMA_HOST`. To pass the host in code, use `createOllamaChat("qwen3:8b", "http://your-server:11434")`.
-```typescript
-import { chat, toServerSentEventsResponse, toolDefinition } from "@tanstack/ai";
-import { ollamaText } from "@tanstack/ai-ollama";
-import { z } from "zod";
-
-const getLocalDataDef = toolDefinition({
- name: "get_local_data",
- description: "Get data from local storage",
- inputSchema: z.object({
- key: z.string(),
- }),
-});
+## 4. Render the chat
-const getLocalData = getLocalDataDef.server(async ({ key }) => {
- // Access local data
- return { data: "..." };
-});
+The client is the same for every provider. Point `useChat` at your route:
-export async function POST(request: Request) {
- const { messages } = await request.json();
+```tsx
+import { useState } from "react";
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
- const stream = chat({
- adapter: ollamaText("llama3"),
- messages,
- tools: [getLocalData],
+export function Chat() {
+ const [input, setInput] = useState("");
+ const { messages, sendMessage } = useChat({
+ connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
});
- return toServerSentEventsResponse(stream);
+ return (
+
+ );
}
```
-**Note:** Tool support varies by model. Models like `llama3`, `mistral`, and `qwen2` generally have good tool calling support.
+For Vue, Solid, Svelte, Angular, and the other frameworks, see [Quick Start](../getting-started/quick-start#3-render-the-chat).
+
+You now have a streaming chat with a local model. The speed depends on your hardware. A GPU helps for larger models.
+
+## Tools
+
+[Tools](../tools/tools) work the same as with every adapter. The tool support depends on the model. Models like `llama3`, `mistral`, and `qwen2` call tools well.
-## Tool choice
+### Tool choice
Ollama ignores `toolChoice`. Ollama has no tool choice, so the model decides to call a tool or not, also with `'none'` or `'required'`.
-Want no tool calls for a request? Do not pass `tools`. For the other providers, see [Choose when the model calls a tool](../tools/tools#choose-when-the-model-calls-a-tool).
+To get no tool calls for a request, do not pass `tools`. For the other providers, see [Choose when the model calls a tool](../tools/tools#choose-when-the-model-calls-a-tool).
-## Model Options
+## Embeddings
+
+```typescript ignore
+import { embed } from "@tanstack/ai";
+import { ollamaEmbedding } from "@tanstack/ai-ollama";
+
+const result = await embed({
+ adapter: ollamaEmbedding("nomic-embed-text"),
+ input: ["a red guitar", "a blue drum kit"],
+});
+
+console.log(result.embeddings[0]?.vector);
+```
+
+- Pull the model first: `ollama pull nomic-embed-text`.
+- The known models get autocomplete: `nomic-embed-text`, `mxbai-embed-large`, `all-minilm`, `snowflake-arctic-embed`, `bge-m3`, and `embeddinggemma`. Any other model name works too.
+- Each model has a fixed output size, so the top-level `dimensions` option does not work.
+
+See the [Embeddings guide](../embeddings.md).
+
+## Summarization
+
+```typescript ignore
+import { summarize } from "@tanstack/ai";
+import { ollamaSummarize } from "@tanstack/ai-ollama";
+
+const result = await summarize({
+ adapter: ollamaSummarize("qwen3:8b"),
+ text: "Your long text to summarize...",
+ threadId: "thread-1",
+ maxLength: 100,
+ style: "concise", // "concise" | "bullet-points" | "paragraph"
+});
-Ollama supports various provider-specific options. Unlike the other providers, Ollama nests its sampling and runner parameters inside an `options` object **within** `modelOptions` — `temperature`, `top_p`, and `num_predict` (the token-limit key) all live under `modelOptions.options`:
+console.log(result.summary);
+```
+
+## No image generation
+
+Ollama does not make images. For images, use [OpenAI](./openai) or [Gemini](./gemini).
+
+## Models and options
+
+### Model options
+
+Ollama nests its sampling and runner options in an `options` object **inside** `modelOptions`. This includes `temperature`, `top_p`, and `num_predict` (the token limit):
```typescript
import { chat } from "@tanstack/ai";
import { ollamaText } from "@tanstack/ai-ollama";
const stream = chat({
- adapter: ollamaText("llama3:latest"),
+ adapter: ollamaText("qwen3:8b"),
messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
modelOptions: {
options: {
temperature: 0.7,
@@ -192,47 +210,9 @@ const stream = chat({
});
```
-> If you previously passed `temperature` / `topP` / `maxTokens` at the root of `chat()`, note that for Ollama they map to `modelOptions.options.temperature`, `modelOptions.options.top_p`, and `modelOptions.options.num_predict`. See [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options).
-
-### Advanced Options
+Sampling options do not go at the root of `chat()`. For Ollama, they go to `modelOptions.options.temperature`, `modelOptions.options.top_p`, and `modelOptions.options.num_predict`. See [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options). The full option list is in [All runner options](#all-runner-options).
-All sampling and runner parameters are nested under `modelOptions.options`:
-
-```typescript ignore
-modelOptions: {
- options: {
- // Sampling
- temperature: 0.7,
- top_p: 0.9,
- top_k: 40,
- min_p: 0.05,
- typical_p: 1.0,
-
- // Generation
- num_predict: 1000,
- repeat_penalty: 1.1,
- repeat_last_n: 64,
- penalize_newline: false,
-
- // Performance
- num_ctx: 4096,
- num_batch: 512,
- num_gpu: -1,
- num_thread: 0, // 0 = auto
-
- // Memory
- use_mmap: true,
- use_mlock: false,
-
- // Mirostat sampling
- mirostat: 0, // 0 = disabled, 1 = Mirostat, 2 = Mirostat 2.0
- mirostat_tau: 5.0,
- mirostat_eta: 0.1,
- },
-}
-```
-
-## Reasoning
+### Reasoning
Running a thinking model like Qwen 3 or gpt-oss? Set `reasoning` on `chat()`:
@@ -243,6 +223,7 @@ import { ollamaText } from "@tanstack/ai-ollama";
const stream = chat({
adapter: ollamaText("qwen3:8b"),
messages: [{ role: "user", content: "Plan a database migration." }],
+ threadId: "thread-1",
reasoning: "off",
});
```
@@ -253,137 +234,123 @@ The adapter sends it as `think`:
| --- | --- | --- |
| `qwen3`, `deepseek-r1`, `deepseek-v3.1` | `off`, `high` | `think: false` or `true` |
| `gpt-oss` | `low`, `medium`, `high` | `think: "low"` and so on |
-| a model name this package does not list | `off`, `high` | `think: false` or `true` |
+| a model name that this package does not list | `off`, `high` | `think: false` or `true` |
The other listed models take no `reasoning`.
-## Summarization
+### Models
-Summarize long text content locally:
-
-```typescript ignore
-import { summarize } from "@tanstack/ai";
-import { ollamaSummarize } from "@tanstack/ai-ollama";
-
-const result = await summarize({
- adapter: ollamaSummarize("llama3"),
- text: "Your long text to summarize...",
- maxLength: 100,
- style: "concise", // "concise" | "bullet-points" | "paragraph"
-});
+To see the models on your Ollama server, run:
-console.log(result.summary);
+```bash
+ollama list
```
-## Embeddings
+Any model name works. The package knows many families and gives them autocomplete. Some of them:
-Generate embedding vectors locally with any Ollama embedding model:
+- `qwen3`, `qwen2.5`, `qwq`: Qwen models from Alibaba.
+- `llama4`, `llama3.3`, `llama3.2`, `llama3.1`, `llama3`: Llama models from Meta.
+- `gpt-oss`: open-weight models from OpenAI.
+- `gemma3`, `deepseek-r1`, `deepseek-v3.1`, `phi4`: models from Google, DeepSeek, and Microsoft.
+- `mistral`, `mistral-small`, `mixtral`, `codellama`: Mistral and code models.
-```typescript ignore
-import { embed } from "@tanstack/ai";
-import { ollamaEmbedding } from "@tanstack/ai-ollama";
+The package exports the full list as `OllamaTextModels`.
-const result = await embed({
- adapter: ollamaEmbedding("nomic-embed-text"),
- input: ["a red guitar", "a blue drum kit"],
-});
+## Advanced
-console.log(result.embeddings[0]?.vector);
-```
+### Custom host and client config
-Known models (`nomic-embed-text`, `mxbai-embed-large`, `all-minilm`, `snowflake-arctic-embed`, `bge-m3`, `embeddinggemma`) get autocomplete, and any other model name is accepted. Pull the model first with `ollama pull nomic-embed-text`. Output dimensions are fixed per model — the top-level `dimensions` option is not supported.
-
-See the [Embeddings guide](../embeddings.md) for the full API.
-
-## Setting Up Ollama
-
-### 1. Install Ollama
-
-```bash
-# macOS
-brew install ollama
+```typescript
+import { createOllamaChat } from "@tanstack/ai-ollama";
-# Linux
-curl -fsSL https://ollama.com/install.sh | sh
+// Custom host (URL string)
+const adapter = createOllamaChat("qwen3:8b", "http://your-server:11434");
-# Windows
-# Download from https://ollama.com
+// Custom client config (for example, custom headers)
+const adapter2 = createOllamaChat("qwen3:8b", {
+ host: "http://your-server:11434",
+ headers: { Authorization: "Bearer ..." },
+});
```
-### 2. Pull a Model
+### Running on a remote server
-```bash
-ollama pull llama3
-```
-
-### 3. Start Ollama Server
+To make Ollama listen on a network interface, start it with `OLLAMA_HOST`:
```bash
-ollama serve
+OLLAMA_HOST=0.0.0.0:11434 ollama serve
```
-The server runs on `http://localhost:11434` by default.
-
-## Running on a Remote Server
+Then point the adapter at that server:
```typescript
import { createOllamaChat } from "@tanstack/ai-ollama";
-const adapter = createOllamaChat("llama3", "http://your-server:11434");
+const adapter = createOllamaChat("qwen3:8b", "http://your-server:11434");
```
-To expose Ollama on a network interface:
+### Behind a proxy
-```bash
-OLLAMA_HOST=0.0.0.0:11434 ollama serve
-```
+Send every request through a gateway, such as Cloudflare AI Gateway or a corporate proxy, with `baseURL` and `defaultHeaders`. These two option names are the same on every TanStack AI adapter, so one gateway config works for all of them.
-## Environment Variables
-
-Optionally set the host in environment variables:
+```typescript
+import { createOllamaChat } from "@tanstack/ai-ollama";
-```bash
-OLLAMA_HOST=http://localhost:11434
+const adapter = createOllamaChat("qwen3:8b", {
+ baseURL: "https://gateway.example.com/ollama",
+ defaultHeaders: { Authorization: `Bearer ${process.env.GATEWAY_TOKEN}` },
+});
```
-## API Reference
-
-### `ollamaText(model)`
-
-Creates an Ollama text/chat adapter using `OLLAMA_HOST` from the environment (defaults to `http://localhost:11434`).
+- `host` and `headers` are aliases of the same two options. If you set both forms, `baseURL` and `defaultHeaders` win.
+- Need more than a fixed header, such as a log of each model call? This adapter supports [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). An adapter with your own `Ollama` client ignores `wrapFetch` and the abort signal.
-**Parameters:**
+### All runner options
-- `model` - Model name (e.g. `"llama3"`, `"mistral:7b"`)
+All sampling and runner options go in `modelOptions.options`:
-### `createOllamaChat(model, hostOrConfig?)`
-
-Creates an Ollama text/chat adapter with an explicit host or client config.
-
-**Parameters:**
-
-- `model` - Model name
-- `hostOrConfig?` - Either an `OLLAMA_HOST`-style URL string, or an `OllamaClientConfig` object (e.g. `{ baseURL, defaultHeaders }`). `host` and `headers` are aliases.
-
-### `ollamaSummarize(model)` / `createOllamaSummarize(model, hostOrConfig?)`
+```typescript ignore
+modelOptions: {
+ options: {
+ // Sampling
+ temperature: 0.7,
+ top_p: 0.9,
+ top_k: 40,
+ min_p: 0.05,
+ typical_p: 1.0,
-Creates an Ollama summarization adapter — same signature shape as the chat adapter.
+ // Generation
+ num_predict: 1000,
+ repeat_penalty: 1.1,
+ repeat_last_n: 64,
+ penalize_newline: false,
-## Benefits of Ollama
+ // Performance
+ num_ctx: 4096,
+ num_batch: 512,
+ num_gpu: -1,
+ num_thread: 0, // 0 = auto
-- ✅ **Privacy** - Data stays on your infrastructure
-- ✅ **Cost** - No API costs after hardware
-- ✅ **Customization** - Use any compatible model
-- ✅ **Offline** - Works without internet
-- ✅ **Speed** - No network latency for local deployment
+ // Memory
+ use_mmap: true,
+ use_mlock: false,
-## Limitations
+ // Mirostat sampling
+ mirostat: 0, // 0 = disabled, 1 = Mirostat, 2 = Mirostat 2.0
+ mirostat_tau: 5.0,
+ mirostat_eta: 0.1,
+ },
+}
+```
-- **Image Generation**: Ollama does not support image generation. Use OpenAI or Gemini for image generation.
-- **Performance**: Depends on your hardware (GPU recommended for larger models)
+### API reference
-## Next Steps
+| Factory | Creates |
+| --- | --- |
+| `ollamaText(model)` | Chat adapter. Reads the host from `OLLAMA_HOST` (default `http://localhost:11434`) |
+| `createOllamaChat(model, hostOrConfig?)` | Chat adapter with an explicit host or client config |
+| `ollamaSummarize(model)` / `createOllamaSummarize(model, host?)` | Summarization adapter. `host` is a URL string |
+| `ollamaEmbedding(model)` / `createOllamaEmbedding(model, hostOrConfig?)` | Embedding adapter |
-- [Getting Started](../getting-started/quick-start) - Learn the basics
-- [Tools Guide](../tools/tools) - Learn about tools
-- [Other Adapters](./openai) - Explore other providers
+- `model`: a model name, for example `"qwen3:8b"` or `"mistral:7b"`.
+- `hostOrConfig`: a URL string in the `OLLAMA_HOST` style, or an `OllamaClientConfig` object (for example `{ baseURL, defaultHeaders }`). `host` and `headers` are aliases.
diff --git a/docs/adapters/ollaya.md b/docs/adapters/ollaya.md
index f315e126b9..c005e352e3 100644
--- a/docs/adapters/ollaya.md
+++ b/docs/adapters/ollaya.md
@@ -1,7 +1,7 @@
---
title: Ollaya
id: ollaya-adapter
-description: "Call decide() on a local Ollaya server with @tanstack/ai-ollaya."
+description: "Run decide() on a local Ollaya server with @tanstack/ai-ollaya. Typed answers about your data, with no API key, and the data stays on your machine."
keywords:
- tanstack ai
- ollaya
@@ -12,114 +12,133 @@ keywords:
- adapter
---
-You want a queue, an urgency, and a yes or no from a ticket that stays on your machine.
-Install Ollaya.
-Then pass `ollayaDecider` to `decide()`.
+The Ollaya adapter calls the `laya` models on your machine through `decide()`. It returns a typed answer. It has no chat.
-The adapter covers evaluate only.
-It sends `POST /v1/systemone` to a local Ollaya server with `fetch`.
-There is no Ollaya SDK.
+## 1. Install
-## Install the server
+
+
+react: @tanstack/ai @tanstack/ai-ollaya
+vue: @tanstack/ai @tanstack/ai-ollaya
+solid: @tanstack/ai @tanstack/ai-ollaya
+svelte: @tanstack/ai @tanstack/ai-ollaya
+preact: @tanstack/ai @tanstack/ai-ollaya
+angular: @tanstack/ai @tanstack/ai-ollaya
+vanilla: @tanstack/ai @tanstack/ai-ollaya
+octane: @tanstack/ai @tanstack/ai-ollaya
-On macOS or Linux, run:
+
+
+## 2. Start Ollaya and pull a model
+
+Ollaya needs no API key. It needs a running server with a model.
+
+1. Install Ollaya:
```bash
+# macOS or Linux
curl -fsSL https://ollaya.dev/install.sh | sh
```
-On Windows, run this in PowerShell:
-
```powershell
+# Windows (PowerShell)
irm https://ollaya.dev/install.ps1 | iex
```
-The server listens on `http://127.0.0.1:11435`.
-This adapter uses that URL by default.
+2. Start the server:
-1. Run `ollaya serve`.
-2. Run `ollaya pull laya:latest`.
-
-The download page and the desktop app are on [ollaya.dev/download](https://ollaya.dev/download).
+```bash
+ollaya serve
+```
-## Install the adapter
+3. Pull a model:
-
+```bash
+ollaya pull laya:latest
+```
-react: @tanstack/ai @tanstack/ai-ollaya
-vue: @tanstack/ai @tanstack/ai-ollaya
-solid: @tanstack/ai @tanstack/ai-ollaya
-svelte: @tanstack/ai @tanstack/ai-ollaya
-preact: @tanstack/ai @tanstack/ai-ollaya
-angular: @tanstack/ai @tanstack/ai-ollaya
-vanilla: @tanstack/ai @tanstack/ai-ollaya
-octane: @tanstack/ai @tanstack/ai-ollaya
+The server listens on `http://127.0.0.1:11435`. The adapter uses this URL by default. The download page and the desktop app are on [ollaya.dev/download](https://ollaya.dev/download).
-
+## 3. Ask your first questions from the server
-## Evaluate
+This route reads a support ticket and asks three questions about it:
```typescript
import { decide, choice, score, boolean } from "@tanstack/ai";
import { ollayaDecider } from "@tanstack/ai-ollaya";
-const ticket = {
- subject: "Charged twice for the same invoice",
- body: "Please refund the extra payment.",
-};
-
-const result = await decide({
- adapter: ollayaDecider("laya:latest"),
- state: ticket,
- questions: {
- queue: choice({
- instructions: "Which team should handle this ticket?",
- options: {
- billing: "Payments, invoices, refunds",
- tech: "Bugs, outages, integrations",
- sales: "Pricing, upgrades, new accounts",
- },
- }),
- urgency: score({
- instructions: "How urgent is this ticket?",
- levels: ["low", "medium", "high"],
- }),
- refund: boolean({
- instructions: "Is the customer asking for a refund?",
- }),
- },
-});
+export async function POST(request: Request) {
+ const ticket = await request.text();
+
+ const result = await decide({
+ adapter: ollayaDecider("laya:latest"),
+ state: ticket,
+ questions: {
+ queue: choice({
+ instructions: "Which team should handle this ticket?",
+ options: {
+ billing: "Payments, invoices, refunds",
+ tech: "Bugs, outages, integrations",
+ sales: "Pricing, upgrades, new accounts",
+ },
+ }),
+ urgency: score({
+ instructions: "How urgent is this ticket?",
+ levels: ["low", "medium", "high"],
+ }),
+ refund: boolean({
+ instructions: "Is the customer asking for a refund?",
+ }),
+ },
+ });
+
+ return Response.json({
+ queue: result.queue.value,
+ urgency: result.urgency.value,
+ refund: result.refund.value,
+ model: result.meta.model,
+ });
+}
+```
+
+When you ask for `laya:latest`, the server picks `laya:en` or `laya:multilingual` from the text. `result.meta.model` tells you which one answered.
+
+## 4. Call the route from the client
+
+The client sends the ticket text to the route:
-console.log(result.queue.value);
-console.log(result.urgency.value);
-console.log(result.refund.value);
-console.log(result.meta.model);
-console.log(result.meta.usage);
+```typescript
+export async function evaluateTicket(ticket: string) {
+ const response = await fetch("/api/evaluate", {
+ method: "POST",
+ body: ticket,
+ });
+ return response.json();
+}
```
-You now have `result.queue.value`, `result.urgency.value`, and `result.refund.value`.
+Call `evaluateTicket("Charged twice for the same invoice. Please refund the extra payment.")` and you get back the queue, the urgency, and the refund answer.
-See [Evaluate](../evaluate/evaluate) for the rest of `decide()`.
+For the rest of `decide()`, see [Evaluate](../evaluate/evaluate). For a full TanStack Start app, see [Evaluate a ticket](../tutorials/evaluate).
-When you request `laya:latest`, `result.meta.model` can be `laya:en` or `laya:multilingual`.
-The server picks the model from the text.
+## Models and options
-## Models
+| Model | Description |
+| --- | --- |
+| `laya:latest` | Sends each request to `laya:en` or `laya:multilingual`. |
+| `laya:en` | The English decision model (ModernBERT-large). |
+| `laya:multilingual` | The decision model for 100+ languages (mmBERT-base). |
-| Model | Description |
-| ------------------- | -------------------------------------------------------- |
-| `laya:latest` | Routes each request to `laya:en` or `laya:multilingual`. |
-| `laya:en` | English decision model (ModernBERT-large). |
-| `laya:multilingual` | Decision model for 100+ languages (mmBERT-base). |
+You can pass any other model tag as a string. The package exports the known tags as `OLLAYA_EVALUATE_MODELS`.
-You can pass any other model tag as a string.
+## Advanced
-## Host and API key
+### Another host, or an API key
-By default the adapter calls `http://127.0.0.1:11435`.
The adapter does not read environment variables.
-If the server is on another host, pass `baseURL`.
-If the server requires `OLLAYA_API_KEY`, pass `apiKey`.
+
+- If the server is on another host, pass `baseURL`.
+- If the server requires `OLLAYA_API_KEY`, pass the key as `apiKey`.
```typescript
import { ollayaDecider } from "@tanstack/ai-ollaya";
@@ -130,25 +149,15 @@ const adapter = ollayaDecider("laya:latest", {
});
```
-`baseUrl` and `headers` are aliases of `baseURL` and `defaultHeaders`.
-If you set both forms, `baseURL` and `defaultHeaders` win.
-A blank `baseURL` keeps the default host.
-
-## API reference
-
-### `ollayaDecider(model, config?)`
-
-Creates an evaluate adapter.
+`baseUrl` and `headers` are aliases of `baseURL` and `defaultHeaders`. If you set both forms, `baseURL` and `defaultHeaders` win. A blank `baseURL` keeps the default host.
-- `model`: `"laya:latest"`, `"laya:en"`, `"laya:multilingual"`, or another model tag
-- `config.baseURL`: server URL (default `http://127.0.0.1:11435`). `baseUrl` is an alias. A blank value keeps the default.
-- `config.apiKey`: bearer token when the server requires `OLLAYA_API_KEY`
-- `config.defaultHeaders`: extra request headers. `headers` is an alias.
-- `config.fetch`: override `fetch`
+### API reference
-The adapter sends `POST /v1/systemone` to that base URL.
+The adapter sends `POST /v1/systemone` to the base URL with `fetch`. There is no Ollaya SDK.
-## Next steps
+`ollayaDecider(model, config?)` creates an evaluate adapter. The `config` fields:
-- [Evaluate guide](../evaluate/evaluate): full `decide()` walkthrough
-- [Evaluate a ticket](../tutorials/evaluate): build a Start route
+- `baseURL`: the server URL. The default is `http://127.0.0.1:11435`. `baseUrl` is an alias.
+- `apiKey`: a bearer token, for a server that requires `OLLAYA_API_KEY`.
+- `defaultHeaders`: extra request headers. `headers` is an alias.
+- `fetch`: your own `fetch` function.
diff --git a/docs/adapters/openai-advanced.md b/docs/adapters/openai-advanced.md
new file mode 100644
index 0000000000..600f8397df
--- /dev/null
+++ b/docs/adapters/openai-advanced.md
@@ -0,0 +1,865 @@
+---
+title: "OpenAI: Advanced"
+id: openai-advanced
+order: 1
+description: "Advanced OpenAI setup in TanStack AI: Chat Completions, Azure OpenAI, Sign in with ChatGPT, prompt caching, compaction, every provider tool, and the API reference."
+keywords:
+ - tanstack ai
+ - openai
+ - azure openai
+ - chat completions
+ - responses api
+ - sign in with chatgpt
+ - prompt caching
+ - provider tools
+ - adapter
+---
+
+This page has the details of the OpenAI adapter. To get a first chat working, start at [OpenAI](./openai).
+
+## Chat Completions API
+
+`@tanstack/ai-openai` has two text adapters. They call different OpenAI endpoints:
+
+| | `openaiText` (Responses) | `openaiChatCompletions` (Chat Completions) |
+|---|---|---|
+| Endpoint | `/v1/responses` | `/v1/chat/completions` |
+| Reasoning summaries | Yes. With `reasoning` set, the summary text streams as `REASONING_*` events | No. `reasoning` goes out as `reasoning_effort`, and no text streams back |
+| Wire format | OpenAI only | The older shape that many providers use (Grok, Groq, OpenRouter, many local model servers) |
+| Structured output streaming | `text.format: { type: 'json_schema', strict: true }` + `stream: true` | `response_format: { type: 'json_schema', strict: true }` + `stream: true` |
+
+- Use `openaiText` for reasoning summaries and the other Responses features.
+- If you move from a provider with the Chat Completions shape, or share request code with other Chat Completions adapters, use `openaiChatCompletions`.
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { openaiChatCompletions } from "@tanstack/ai-openai";
+
+const stream = chat({
+ adapter: openaiChatCompletions("gpt-6.1-sol"),
+ messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
+});
+```
+
+With an explicit API key:
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { createOpenaiChatCompletions } from "@tanstack/ai-openai";
+
+const adapter = createOpenaiChatCompletions("gpt-6.1-sol", process.env.OPENAI_API_KEY!, {
+ organization: "org-...", // optional
+});
+
+const stream = chat({
+ adapter,
+ messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
+});
+```
+
+Both adapters work with [Structured Outputs](../structured-outputs/overview), also with `stream: true`. Both take the same `modelOptions` (temperature, top_p, max_tokens, stop, and more) and [`reasoning`](./openai#reasoning). `openaiChatCompletions` cannot stream the thinking text.
+
+## Azure OpenAI
+
+Does your OpenAI model run as a deployment in Azure? Use `createAzureOpenaiText`. It calls the Azure Responses API and sends your key in the `api-key` header.
+
+```typescript
+import { chat, toServerSentEventsResponse } from "@tanstack/ai";
+import { createAzureOpenaiText } from "@tanstack/ai-openai";
+
+export async function POST(request: Request) {
+ const { messages, threadId } = await request.json();
+
+ const stream = chat({
+ adapter: createAzureOpenaiText("gpt-5.6", process.env.AZURE_OPENAI_API_KEY!, {
+ resourceName: "my-resource",
+ deploymentName: "production-chat",
+ }),
+ messages,
+ threadId,
+ });
+
+ return toServerSentEventsResponse(stream);
+}
+```
+
+- Azure gets `production-chat` as the model. Your code still uses `gpt-5.6`.
+- The client does not change. It reads this route like any other chat route.
+- `reasoning` takes the levels of the OpenAI model name, here `gpt-5.6`. A model name that is not an OpenAI model, such as a bare deployment name, takes no `reasoning`. See [Reasoning](./openai#reasoning).
+- `createAzureOpenaiText` reads nothing from the environment. You pass the key, and you pass the endpoint as `resourceName` or `baseURL`.
+
+To keep all of it in the environment, use `azureOpenaiText`. Like `openaiText`, it reads the key from the environment:
+
+```sh
+AZURE_OPENAI_API_KEY=your-key
+AZURE_OPENAI_RESOURCE_NAME=my-resource
+AZURE_OPENAI_DEPLOYMENT_NAME_MAP=gpt-5.6=production-chat
+```
+
+```typescript
+import { azureOpenaiText } from "@tanstack/ai-openai";
+
+const adapter = azureOpenaiText("gpt-5.6");
+```
+
+`azureOpenaiText` always reads the key from `AZURE_OPENAI_API_KEY`. If that variable is not set, it throws. For the other values, the config wins over the environment:
+
+- **Endpoint:** `baseURL`, then `resourceName`, then `AZURE_OPENAI_BASE_URL`, then `AZURE_OPENAI_RESOURCE_NAME`. You must set one of them.
+- **API version:** `apiVersion`, then `AZURE_OPENAI_API_VERSION`, then `v1`.
+- **Deployment:** `deploymentName`, then `deploymentNameMap`, then `AZURE_OPENAI_DEPLOYMENT_NAME_MAP`, then the model name. A `deploymentNameMap` in the config replaces the environment map. The two maps do not merge.
+
+An Azure resource URL always goes to the `/openai/v1` path. A proxy URL keeps its own path.
+
+## Sign in with ChatGPT (BYOK)
+
+Many of your users have a ChatGPT plan but no API key. Let them sign in with ChatGPT. The token bills their ChatGPT plan, not your account.
+
+The helpers in `@tanstack/ai-openai/siwc` put the access token into the `openai` [BYOK](../advanced/byok) slot. Your relay reads it like a pasted key.
+
+Before you start, know these limits:
+
+- OpenAI allows this sign-in only for open-source and locally hosted apps. For a hosted app, fill in the [interest form](https://openai.com/form/sign-in-with-chatgpt-interest/).
+- OpenAI redirects only to `127.0.0.1`. Something must answer on that address. Vite listens on `::1` by default. The `ts-react-chat` example runs a small script on `127.0.0.1:3000` that redirects to `localhost:3000`.
+- Keep the app on `localhost`, because passkey storage does not work on an IP address. The callback lands on `127.0.0.1`, then `completeChatGptSignIn` sends the browser back to `localhost`.
+- The callback page must be at `/auth/callback`.
+
+### 1. Add the sign-in button
+
+Call `startChatGptSignIn` from a click. `agentName` is your app name. OpenAI shows it on the consent screen.
+
+```tsx
+import { startChatGptSignIn } from "@tanstack/ai-openai/siwc";
+
+export function ChatGptButton() {
+ return (
+ {
+ void startChatGptSignIn({ agentName: "My App" });
+ }}
+ >
+ Continue with ChatGPT
+
+ );
+}
+```
+
+### 2. Finish on `/auth/callback`
+
+Render this component on the `/auth/callback` route. It exchanges the code when the page loads. Then the user clicks to save.
+
+```tsx
+import { useEffect, useRef, useState } from "react";
+import {
+ completeChatGptSignIn,
+ saveChatGptSignIn,
+} from "@tanstack/ai-openai/siwc";
+import type { ChatGptSignIn } from "@tanstack/ai-openai/siwc";
+import { byok } from "./byok";
+
+export function ChatGptCallback() {
+ const started = useRef(false);
+ const [signIn, setSignIn] = useState(null);
+ const [error, setError] = useState("");
+
+ useEffect(() => {
+ // The code works one time. Strict Mode runs effects two times.
+ if (started.current) return;
+ started.current = true;
+ completeChatGptSignIn().then(setSignIn, (caught: unknown) => {
+ setError(caught instanceof Error ? caught.message : "Sign-in failed");
+ });
+ }, []);
+
+ if (error) return
{error}
;
+ if (!signIn) return
Finishing sign-in...
;
+ return (
+ {
+ void saveChatGptSignIn(byok, signIn);
+ }}
+ >
+ Save ChatGPT sign-in
+
+ );
+}
+```
+
+The save needs a click, because passkey storage shows a browser prompt. The refresh token goes into a second keyring slot. No send attaches that slot, so the refresh token stays in the browser.
+
+### 3. Refresh the token
+
+The access token lasts one hour. Call `refreshChatGptSignIn` on a timer:
+
+```tsx
+import { useEffect } from "react";
+import { refreshChatGptSignIn } from "@tanstack/ai-openai/siwc";
+import { byok } from "./byok";
+
+export function useChatGptRefresh() {
+ useEffect(() => {
+ const refresh = () => {
+ refreshChatGptSignIn(byok).catch(console.error);
+ };
+ refresh();
+ const timer = setInterval(refresh, 60_000);
+ return () => clearInterval(timer);
+ }, []);
+}
+```
+
+The call does nothing until the token has less than five minutes left. If OpenAI rejects the refresh token, the call clears the sign-in. Then the user must sign in again.
+
+### 4. Read the token on the relay
+
+The relay code is the same as for a pasted key. Add `store: false`, because the ChatGPT route requires it.
+
+```typescript
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
+import { createOpenaiChat } from "@tanstack/ai-openai";
+import { openaiByok } from "@tanstack/ai-openai/byok";
+import { byokMissing, getByokKey } from "@tanstack/ai/byok/server";
+
+export async function POST(request: Request) {
+ const params = await chatParamsFromRequest(request);
+ const apiKey = getByokKey(request, openaiByok);
+ if (!apiKey) return byokMissing(openaiByok);
+
+ const stream = chat({
+ adapter: createOpenaiChat("gpt-6-astra", apiKey),
+ messages: params.messages,
+ threadId: params.threadId,
+ runId: params.runId,
+ modelOptions: { store: false },
+ });
+ return toServerSentEventsResponse(stream);
+}
+```
+
+`store: false` also works with an API key. A ChatGPT token does not start with `sk-`. If you must send different options, use that test.
+
+The ChatGPT route has these limits:
+
+- Use a model that the ChatGPT plan of the user includes.
+- Do not send `temperature`, `top_p`, `max_output_tokens`, `metadata`, or `previous_response_id`.
+- Hosted tools do not work. This includes image generation, file search, code interpreter, computer use, and hosted MCP.
+- Audio input and transcription do not work.
+
+Click **Continue with ChatGPT**, approve, save, then send a message. The relay calls OpenAI on the ChatGPT plan of the user.
+
+The `ts-react-chat` example has this flow in its key dialog.
+
+## Configuration
+
+```typescript
+import { createOpenaiChat, type OpenAITextConfig } from "@tanstack/ai-openai";
+
+const config: Omit = {
+ organization: "org-...", // Optional
+ baseURL: "https://api.openai.com/v1", // Optional, for custom endpoints
+};
+
+const adapter = createOpenaiChat("gpt-6.1-sol", process.env.OPENAI_API_KEY!, config);
+```
+
+Need a header on each request, or a log of each model call? The chat adapters of this package support [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call).
+
+### Tools that cannot use strict mode
+
+OpenAI holds tool arguments to the schema only when the tool goes out with `strict: true`. Some schemas cannot be strict, for example a schema that uses `$ref`, `z.any()`, or `z.record()`. The adapter sends these tools with `strict: false`, so the tool still works. The model can then return arguments that do not match the schema.
+
+In development, the adapter logs a warning one time for each of these tools:
+
+```txt
+tool "lookup_user" sent with strict: false: schema uses $ref, which strict mode does not support
+```
+
+To get strict mode back, change the schema so that the reason goes away. If you accept a non-strict tool, turn the warning off in the adapter config:
+
+```typescript
+import { createOpenaiChat } from "@tanstack/ai-openai";
+
+const adapter = createOpenaiChat("gpt-6-astra", process.env.OPENAI_API_KEY!, {
+ strictFallbackWarning: false,
+});
+```
+
+The warning never runs when `NODE_ENV` is `production`. The same option works on every adapter built on the OpenAI API, for example Groq, Bedrock, and Vercel AI Gateway.
+
+## Prompt caching
+
+`chat()` sends `prompt_cache_key` by default, set to the `threadId` that you pass. OpenAI uses the key to send requests with the same start to the same cache. With `promptCache: 'long'`, it also asks for the long retention. See [Prompt Caching](../advanced/prompt-caching).
+
+To use your own key, set it in `modelOptions`. Your value wins:
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
+
+const stream = chat({
+ adapter: openaiText("gpt-6.1-sol"),
+ messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
+ modelOptions: {
+ prompt_cache_key: "acme-support",
+ },
+});
+```
+
+- On gpt-5.6 and later, `modelOptions.prompt_cache_options` wins over the automatic options.
+- On earlier models, `modelOptions.prompt_cache_retention` wins over the automatic retention.
+- `openaiChatCompletions` sends the key only to `api.openai.com` and its regional hosts (such as `eu.api.openai.com`), unless you pass `promptCache: 'long'`.
+
+### Tools and prompts added during a conversation
+
+On some models, a tool or a system prompt that you add between model calls goes into the conversation. The cached start stays the same. These models are `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6-astra`, `gpt-6-luna`, and `gpt-6-sol`.
+
+What `openaiText` sends on these models:
+
+- `tools` and `instructions` keep the tools and the system prompts of the first call.
+- An added tool goes into `input` as `{ type: "additional_tools", role: "developer", tools: [...] }`.
+- An added system prompt goes into `input` as a `developer` message, at its place in the conversation.
+- With a provider tool such as `webSearchTool()`, `tools` is the full list for that request.
+
+The channels are on by default with the API of OpenAI. A custom `baseURL`, a custom `fetch`, or `OPENAI_BASE_URL` turns them off. Set `midConversationChannels` to choose:
+
+```typescript
+import { openaiText } from "@tanstack/ai-openai";
+
+export const fullLists = openaiText("gpt-6-astra", { midConversationChannels: false });
+
+export const throughProxy = openaiText("gpt-6-astra", {
+ baseURL: "https://llm-proxy.example.com/v1",
+ midConversationChannels: true,
+});
+```
+
+If that endpoint sends the request on to OpenAI without changes, set `true`. See [Mid-Conversation Changes](../advanced/mid-conversation-changes).
+
+## Native compaction
+
+OpenAI can compact a long conversation on its side, with `POST /responses/compact`. The Responses adapters (`openaiText` and `createOpenaiChat`) have a `compact` method for it. Turn it on with the `native` option of `withCompaction`:
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
+import { withCompaction } from "@tanstack/ai-compaction";
+
+const adapter = openaiText("gpt-6.1-sol");
+
+const stream = chat({
+ adapter,
+ messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
+ middleware: [withCompaction({ maxTokens: 100_000, native: adapter })],
+});
+```
+
+- The result holds your user messages and one encrypted `compaction` item.
+- The adapter sends that item back without changes on each later request to the same model.
+- `openaiChatCompletions` has no `compact` method, so `withCompaction` uses its `strategy`.
+
+See [Let the provider compact](../advanced/compaction#let-the-provider-compact).
+
+## Answers on the next request
+
+OpenAI gives each answer item an `id` and a `phase`: `commentary` for text before a tool call, and `final_answer` for the answer. The Responses adapters (`openaiText` and `createOpenaiChat`) keep both in the assistant message, in `metadata.tanstack.responseItems`.
+
+When the same model gets that message again, for example on the next turn, the adapter sends each item back with its `id` and `phase`. Another model gets the plain text.
+
+## Transcription options
+
+```typescript
+import { generateTranscription } from "@tanstack/ai";
+import { openaiTranscription } from "@tanstack/ai-openai";
+import { audioFile } from "./audio";
+
+const result = await generateTranscription({
+ adapter: openaiTranscription("whisper-1"),
+ audio: audioFile,
+ responseFormat: "verbose_json",
+ prompt: "Technical terms: API, SDK",
+ threadId: "thread-1",
+ modelOptions: {
+ temperature: 0,
+ timestamp_granularities: ["word", "segment"],
+ },
+});
+
+console.log(result.text);
+```
+
+### Speaker diarization
+
+Use `gpt-4o-transcribe-diarize` for a transcript with speaker labels:
+
+```typescript
+import { generateTranscription } from "@tanstack/ai";
+import { openaiTranscription } from "@tanstack/ai-openai";
+import { meetingAudioFile } from "./audio";
+
+const result = await generateTranscription({
+ adapter: openaiTranscription("gpt-4o-transcribe-diarize"),
+ audio: meetingAudioFile,
+ modelOptions: {
+ known_speaker_names: ["agent", "customer"],
+ known_speaker_references: [
+ "data:audio/wav;base64,...",
+ "data:audio/wav;base64,...",
+ ],
+ },
+ threadId: "thread-1",
+});
+
+for (const segment of result.segments ?? []) {
+ console.log(segment.speaker, segment.start, segment.end, segment.text);
+}
+```
+
+- With no response format, the request uses `response_format: "diarized_json"` and `chunking_strategy: "auto"`.
+- A top-level `responseFormat` of `"json"` or `"text"` gives no speaker segments.
+- Pass `known_speaker_names` and `known_speaker_references` together: up to 4 each, with the same length.
+- OpenAI does not support `prompt`, `include`, or `timestamp_granularities` with diarized transcription.
+
+## Provider tools
+
+OpenAI has tools of its own, next to the function tools that you define. Import them from `@tanstack/ai-openai/tools` and pass them into `chat({ tools: [...] })`.
+
+For the concept, a comparison table, and type gating, see [Provider Tools](../tools/provider-tools.md).
+
+### `webSearchTool`
+
+The model runs a web search and returns grounded results with citations. Pass a `WebSearchToolConfig` object (typed from the OpenAI SDK).
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
+import { webSearchTool } from "@tanstack/ai-openai/tools";
+
+const stream = chat({
+ adapter: openaiText("gpt-6.1-sol"),
+ messages: [{ role: "user", content: "What's new in AI this week?" }],
+ threadId: "thread-1",
+ tools: [webSearchTool({ type: "web_search" })],
+});
+```
+
+**Supported models:** GPT-4o, GPT-5, and the models that use the Responses API. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+
+### `webSearchPreviewTool`
+
+The preview version of web search. It has more options to set the search context size and the user location.
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
+import { webSearchPreviewTool } from "@tanstack/ai-openai/tools";
+
+const stream = chat({
+ adapter: openaiText("gpt-6.1-sol"),
+ messages: [{ role: "user", content: "Latest news about TypeScript" }],
+ threadId: "thread-1",
+ tools: [
+ webSearchPreviewTool({
+ type: "web_search_preview_2025_03_11",
+ search_context_size: "high",
+ }),
+ ],
+});
+```
+
+**Supported models:** GPT-4o and later. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+
+When the model uses hosted web search:
+
+- The assistant tool-call part has `metadata.providerExecuted: true` and the normalized `metadata.sources` array.
+- The raw search call and URL citations of OpenAI stay under `metadata.openai`.
+- The agent loop does not run this call in your app. The adapter sends the raw response items back on the next turn.
+
+### `fileSearchTool`
+
+Searches OpenAI vector stores that you filled before. The model gets the matching document chunks. Give the `vector_store_ids` to search. You can set `max_num_results` (1 to 50) to limit the results.
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
+import { fileSearchTool } from "@tanstack/ai-openai/tools";
+
+const stream = chat({
+ adapter: openaiText("gpt-6.1-sol"),
+ messages: [{ role: "user", content: "What does the handbook say about PTO?" }],
+ threadId: "thread-1",
+ tools: [
+ fileSearchTool({
+ type: "file_search",
+ vector_store_ids: ["vs_abc123"],
+ max_num_results: 5,
+ }),
+ ],
+});
+```
+
+**Supported models:** GPT-4o and later. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+
+### `imageGenerationTool`
+
+The model makes images during a conversation with DALL-E or GPT-Image. Set quality, size, and style in the config object.
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
+import { imageGenerationTool } from "@tanstack/ai-openai/tools";
+
+const stream = chat({
+ adapter: openaiText("gpt-6.1-sol"),
+ messages: [{ role: "user", content: "Draw a logo for my app" }],
+ threadId: "thread-1",
+ tools: [
+ imageGenerationTool({
+ quality: "high",
+ size: "1024x1024",
+ }),
+ ],
+});
+```
+
+**Supported models:** GPT-5 and the models that can use GPT-Image. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+
+### `codeInterpreterTool`
+
+Gives the model a sandbox that runs Python. The `container` field sets the environment. Pass the full `CodeInterpreterToolConfig` object.
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
+import { codeInterpreterTool } from "@tanstack/ai-openai/tools";
+
+const stream = chat({
+ adapter: openaiText("gpt-6.1-sol"),
+ messages: [{ role: "user", content: "Analyse this CSV and plot a chart" }],
+ threadId: "thread-1",
+ tools: [
+ codeInterpreterTool({ type: "code_interpreter", container: { type: "auto" } }),
+ ],
+});
+```
+
+**Supported models:** GPT-4o and later. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+
+### `mcpTool`
+
+Connects the model to a remote MCP (Model Context Protocol) server. The model can call all tools of that server. Give `server_url` or `connector_id`, not both.
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
+import { mcpTool } from "@tanstack/ai-openai/tools";
+
+const stream = chat({
+ adapter: openaiText("gpt-6.1-sol"),
+ messages: [{ role: "user", content: "List my GitHub issues" }],
+ threadId: "thread-1",
+ tools: [
+ mcpTool({
+ server_url: "https://mcp.example.com",
+ server_label: "github",
+ }),
+ ],
+});
+```
+
+**Supported models:** GPT-4o and later. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+
+### `computerUseTool`
+
+The model can look at a screen and use the mouse and the keyboard. Your app runs each action and sends back a screenshot. `createComputerUse` from `@tanstack/ai-computer-use` does this on a Playwright browser or an E2B desktop. Each thread gets its own machine:
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
+import { createComputerUse } from "@tanstack/ai-computer-use";
+import { playwrightComputer } from "@tanstack/ai-computer-playwright";
+
+declare const threadId: string;
+
+const computerUse = createComputerUse({
+ driver: playwrightComputer({ startUrl: "https://tanstack.com" }),
+});
+
+const stream = chat({
+ adapter: openaiText("gpt-6.1-sol"),
+ messages: [{ role: "user", content: "Find the TanStack AI docs" }],
+ threadId,
+ tools: computerUse.tools,
+ middleware: [computerUse.middleware],
+ interrupts: [computerUse.interrupt],
+});
+```
+
+The adapter sends the `computer` tool to OpenAI as its native computer tool.
+
+- For the setup, the threads, and how to close the machines, see [Computer use](../computer-use/computer-use.md).
+- For the approval of OpenAI safety checks, see [Safety checks](../computer-use/safety-checks.md).
+- For your own machine, see [Custom driver](../computer-use/custom-driver.md).
+
+#### The raw tool
+
+`computerUseTool` from `@tanstack/ai-openai/tools` is the native tool with no `execute`. Use it when your app runs the actions itself:
+
+```typescript
+import { computerUseTool } from "@tanstack/ai-openai/tools";
+
+const computer = computerUseTool({ type: "computer" });
+```
+
+Add an `execute` that runs the actions and returns a screenshot as an image part. The input of each call has two fields:
+
+- `actions`: the actions to run, in order, as OpenAI sends them. For example `[{ type: "click", button: "left", x: 120, y: 340 }, { type: "type", text: "ada@example.com" }]`.
+- `pending_safety_checks`: the safety checks that OpenAI attached to the actions.
+
+The adapter sends the first image of the result back as the screenshot. When the call does not fail, the adapter also tells OpenAI that the safety checks are accepted. When the call fails, they stay unaccepted.
+
+The `computer-use-preview` model takes the older tool type. Give it the display size and the environment. Each call then has one `action` field in place of `actions`:
+
+```typescript
+import { computerUseTool } from "@tanstack/ai-openai/tools";
+
+const previewTool = computerUseTool({
+ type: "computer_use_preview",
+ display_width: 1024,
+ display_height: 768,
+ environment: "browser",
+});
+```
+
+**Supported models:** `type: "computer"` works with the models that list computer use, for example `gpt-6.1-sol`. `type: "computer_use_preview"` works with `computer-use-preview`. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+
+### `localShellTool`
+
+The model returns a `local_shell` tool call. Your app runs the command. Then your app sends the output back on the next request. `localShellTool()` takes no arguments. Add it to `tools`.
+
+Match `toolCallName` on `TOOL_CALL_START`. Read `input` on the `TOOL_CALL_END` with the same `toolCallId`. `input.command` is the command. `input.env` is the environment variables.
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
+import { localShellTool } from "@tanstack/ai-openai/tools";
+
+const stream = chat({
+ adapter: openaiText("gpt-6.1-sol"),
+ messages: [{ role: "user", content: "Run the test suite and summarise failures" }],
+ threadId: "thread-1",
+ tools: [localShellTool()],
+});
+
+let localShellCallId: string | undefined;
+for await (const chunk of stream) {
+ if (chunk.type === "TOOL_CALL_START" && chunk.toolCallName === "local_shell") {
+ localShellCallId = chunk.toolCallId;
+ }
+ if (chunk.type === "TOOL_CALL_END" && chunk.toolCallId === localShellCallId) {
+ console.log(chunk.input);
+ }
+}
+```
+
+Send the command output as the tool result. Use `{ output: string }`, or a string. The next request sends `local_shell_call_output`.
+
+**Supported models:** GPT-5.x and other models that can act as agents. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+
+### `shellTool`
+
+`shellTool()` gives the model a shell. A container environment runs on OpenAI. A local environment runs in your app.
+
+If your app must run the commands, pass `environment: { type: "local" }`. Then:
+
+- Match `toolCallName` on `TOOL_CALL_START`. Read `input` on the `TOOL_CALL_END` with the same `toolCallId`.
+- `input.commands` is the command list.
+- `input.timeout_ms` is the time limit.
+- `input.max_output_length` is the output limit.
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
+import { shellTool } from "@tanstack/ai-openai/tools";
+
+const stream = chat({
+ adapter: openaiText("gpt-6.1-sol"),
+ messages: [{ role: "user", content: "Count lines in all JS files" }],
+ threadId: "thread-1",
+ tools: [shellTool({ environment: { type: "local" } })],
+});
+
+let shellCallId: string | undefined;
+for await (const chunk of stream) {
+ if (chunk.type === "TOOL_CALL_START" && chunk.toolCallName === "shell") {
+ shellCallId = chunk.toolCallId;
+ }
+ if (chunk.type === "TOOL_CALL_END" && chunk.toolCallId === shellCallId) {
+ console.log(chunk.input);
+ }
+}
+```
+
+Return one result for each command:
+
+```typescript
+const shellResult = {
+ output: [
+ {
+ stdout: "12\n",
+ stderr: "",
+ outcome: { type: "exit" as const, exit_code: 0 },
+ },
+ ],
+};
+```
+
+- `outcome.type` is `"exit"` or `"timeout"`. The next request sends `shell_call_output`.
+- If the same response has no `shell_call_output`, a shell call with no environment comes back to your app.
+- A container environment does not come back as a tool call. Pass `environment` to attach that container and hosted skills.
+
+**Supported models:** GPT-5.x and other models that can act as agents. Responses API only: Chat Completions does not support the shell tool. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+
+#### Attaching hosted skills
+
+Pass `environment.skills` to load skill bundles that the provider manages into the container of the shell (Responses API only).
+
+```typescript
+import { chat, toServerSentEventsResponse } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
+import { shellTool } from "@tanstack/ai-openai/tools";
+
+export async function POST(request: Request) {
+ const { messages, threadId } = await request.json();
+
+ const stream = chat({
+ adapter: openaiText("gpt-6.1-sol"),
+ messages,
+ threadId,
+ tools: [
+ shellTool({
+ environment: {
+ type: "container_auto",
+ skills: [
+ { type: "skill_reference", skill_id: "skill_abc", version: "2" },
+ ],
+ },
+ }),
+ ],
+ });
+
+ return toServerSentEventsResponse(stream);
+}
+```
+
+For the skill shape, the `version` string format, and the Anthropic equivalent, see [Provider Skills](../tools/provider-skills.md).
+
+### `applyPatchTool`
+
+The model returns an `apply_patch` tool call. Your app applies the diff. Then your app sends the result back. `applyPatchTool()` takes no arguments. Add it to `tools`.
+
+Match `toolCallName` on `TOOL_CALL_START`. Read `input` on the `TOOL_CALL_END` with the same `toolCallId`:
+
+- `input.operation` is one file change.
+- `operation.type` is `create_file`, `update_file`, or `delete_file`.
+- `operation.path` is the file path.
+- `create_file` and `update_file` also include `operation.diff`.
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
+import { applyPatchTool } from "@tanstack/ai-openai/tools";
+
+const stream = chat({
+ adapter: openaiText("gpt-6.1-sol"),
+ messages: [{ role: "user", content: "Fix the import paths in src/index.ts" }],
+ threadId: "thread-1",
+ tools: [applyPatchTool()],
+});
+
+let patchCallId: string | undefined;
+for await (const chunk of stream) {
+ if (chunk.type === "TOOL_CALL_START" && chunk.toolCallName === "apply_patch") {
+ patchCallId = chunk.toolCallId;
+ }
+ if (chunk.type === "TOOL_CALL_END" && chunk.toolCallId === patchCallId) {
+ console.log(chunk.input);
+ }
+}
+```
+
+Send this object as the tool result. The next request sends `apply_patch_call_output`.
+
+```typescript
+const patchResult = {
+ status: "completed" as const,
+ output: "Updated src/index.ts",
+};
+```
+
+- If the patch fails, set `status` to `"failed"`. Put the error text in `output`.
+- Keep `applyPatchTool()` in `tools` on the next request.
+- A turn with only a patch has no text. With `chat({ stream: false })`, `text` is empty. Read the `apply_patch` tool call from `chunks` with the same checks as the loop above.
+
+**Supported models:** GPT-5.x and other models that can act as agents. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+
+### `customTool`
+
+Defines a custom Responses API tool with an explicit name, description, and format. If none of the other tool types fits, use it. `customTool` returns a plain `Tool`, so every chat model accepts it.
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
+import { customTool } from "@tanstack/ai-openai/tools";
+
+const stream = chat({
+ adapter: openaiText("gpt-6.1-sol"),
+ messages: [{ role: "user", content: "Look up order #1234" }],
+ threadId: "thread-1",
+ tools: [
+ customTool({
+ type: "custom",
+ name: "lookup_order",
+ description: "Look up the status of a customer order by order ID",
+ }),
+ ],
+});
+```
+
+**Supported models:** all Responses API models. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+
+## API reference
+
+Each factory pair has the same shape. The short factory (`openaiText`, `openaiImage`, and the others) reads `OPENAI_API_KEY` from the environment. The `create*` factory takes an explicit API key. Both take `model` as the first argument.
+
+| Factories | Creates |
+| --- | --- |
+| `openaiText(model, config?)` / `createOpenaiChat(model, apiKey, config?)` | Text adapter on the Responses API (`/v1/responses`) |
+| `openaiChatCompletions(model, config?)` / `createOpenaiChatCompletions(model, apiKey, config?)` | Text adapter on `/v1/chat/completions`. See [Chat Completions API](#chat-completions-api) |
+| `azureOpenaiText(model, config?)` / `createAzureOpenaiText(model, apiKey, config)` | Text adapter on Azure. See [Azure OpenAI](#azure-openai) |
+| `openaiSummarize(model, config?)` / `createOpenaiSummarize(model, apiKey, config?)` | Summarization adapter |
+| `openaiImage(model, config?)` / `createOpenaiImage(model, apiKey, config?)` | Image adapter (DALL-E, gpt-image) |
+| `openaiSpeech(model, config?)` / `createOpenaiSpeech(model, apiKey, config?)` | Text-to-speech adapter |
+| `openaiTranscription(model, config?)` / `createOpenaiTranscription(model, apiKey, config?)` | Transcription adapter (Whisper, GPT-4o transcription, and GPT-4o diarized transcription) |
+| `openaiEmbedding(model, config?)` / `createOpenaiEmbedding(model, apiKey, config?)` | Embedding adapter |
+| `openaiVideo(model, config?)` / `createOpenaiVideo(model, apiKey, config?)` | Video adapter (Sora). Experimental |
+| `openaiDecider(model, config?)` / `createOpenaiDecider(model, apiKey, config?)` | Evaluate adapter for `decide()`. See [Evaluate](./openai#evaluate) |
+| `openaiFiles` / `createOpenaiFiles` | Files API adapter. See [Files API](../advanced/files-api) |
+| `openaiRealtime(...)` / `openaiRealtimeToken(...)` | Realtime voice adapters. See [Realtime Voice Chat](../media/realtime-chat) |
+
+Text adapter config:
+
+- `config.organization`: the organization ID. Optional.
+- `config.baseURL`: a custom base URL. Optional.
+- `config.strictFallbackWarning`: set `false` to turn off the [strict mode warning](#tools-that-cannot-use-strict-mode).
+- `config.midConversationChannels`: see [Tools and prompts added during a conversation](#tools-and-prompts-added-during-a-conversation).
+
+`adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts).
diff --git a/docs/adapters/openai-compatible-advanced.md b/docs/adapters/openai-compatible-advanced.md
new file mode 100644
index 0000000000..a79f1988e6
--- /dev/null
+++ b/docs/adapters/openai-compatible-advanced.md
@@ -0,0 +1,480 @@
+---
+title: "OpenAI-Compatible: Advanced"
+id: openai-compatible-advanced
+description: "Advanced setup for the OpenAI-compatible adapter in TanStack AI: headers and query parameters, the Responses API, Azure OpenAI, and model calls paid by a GitHub Copilot or ChatGPT plan."
+keywords:
+ - tanstack ai
+ - openai compatible
+ - responses api
+ - azure openai
+ - github copilot
+ - chatgpt plan
+ - adapter
+---
+
+This page has the details of the OpenAI-compatible adapter. To get a first chat working, start at [OpenAI-Compatible Adapter](./openai-compatible).
+
+## Configuration
+
+`openaiCompatible` accepts every `ClientOptions` field of the OpenAI SDK. `apiKey` and `baseURL` are required, and they are at the top level. The most useful other fields are `defaultHeaders` and `defaultQuery`, for providers that need extra auth or routing parameters:
+
+```typescript
+import { openaiCompatible } from "@tanstack/ai-openai/compatible";
+
+const provider = openaiCompatible({
+ baseURL: "https://api.example.com/v1",
+ apiKey: process.env.EXAMPLE_API_KEY!,
+ models: ["some-model"],
+ defaultHeaders: { "X-Custom-Header": "value" },
+ defaultQuery: { "api-version": "2026-01-01" },
+});
+```
+
+Need a header on each request, or a log of each model call? The adapters that `openaiCompatible` makes support [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call).
+
+## Chat Completions vs Responses
+
+By default, the adapter calls the **Chat Completions** API (`/chat/completions`). For a provider with the **Responses** API, set `api: "responses"`:
+
+```typescript
+import { openaiCompatible } from "@tanstack/ai-openai/compatible";
+
+const provider = openaiCompatible({
+ baseURL: "https://my-resource.openai.azure.com/openai/v1",
+ apiKey: process.env.AZURE_OPENAI_API_KEY!,
+ models: ["gpt-4o"],
+ api: "responses", // default is "chat-completions"
+});
+```
+
+The `api` choice also sets how `ChatStreamSummarizeAdapter` sends `maxLength`, with any wrapper name:
+
+- Chat Completions uses `max_tokens`.
+- Responses uses `max_output_tokens`.
+
+An explicit token limit in `modelOptions` wins over `maxLength`.
+
+Reasoning deltas stream as thinking content. Endpoints on the OpenAI spec from before July 2025 can still send the old event name `response.reasoning.delta`, which the spec replaced with `response.reasoning_text.delta`. The adapter reads both names the same way.
+
+## Azure OpenAI
+
+Use `createAzureOpenaiText` from `@tanstack/ai-openai`. It sets the `api-key` header, the endpoint, the API version, and the deployment name for you:
+
+```typescript
+import { createAzureOpenaiText } from "@tanstack/ai-openai";
+
+const adapter = createAzureOpenaiText("gpt-5.6", process.env.AZURE_OPENAI_API_KEY!, {
+ resourceName: "my-resource",
+ deploymentName: "production-chat",
+});
+```
+
+See [Azure OpenAI](./openai-advanced#azure-openai) for `azureOpenaiText`, which reads the key and the other values from the environment.
+
+## GitHub Copilot
+
+Your users can pay for model calls with their GitHub Copilot plan. Each user signs in with GitHub. Your server sends the GitHub token of the user to Copilot as `Authorization: Bearer`.
+
+### 1. Create a GitHub OAuth app
+
+1. On GitHub, open **Settings > Developer settings > OAuth Apps**. Click **New OAuth App**.
+2. Set **Authorization callback URL** to your callback route, for example `https://my-app.com/auth/github/callback`.
+3. Clear **Expire user access tokens**. The examples below keep one token and do not refresh it.
+4. If your app runs in a terminal, select **Enable Device Flow**.
+5. Set `GITHUB_CLIENT_ID` to the client ID. For a web app, also generate a client secret and set `GITHUB_CLIENT_SECRET` to it.
+
+If you keep **Expire user access tokens** on, a token lasts 8 hours. Refresh it with its refresh token, as [Authorizing OAuth apps](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps) tells you.
+
+### 2. Sign the user in
+
+Pick the flow for your app:
+
+- Web app: the [web flow](#web-app) sends the user to GitHub and back.
+- Terminal or desktop app: the [device flow](#terminal-app) shows a code. The user types the code on GitHub.
+
+Both flows ask for the `read:user` scope only.
+
+#### Web app
+
+`copilot-auth.ts` has the two sign-in routes and a reader for your chat routes. The token goes into an `HttpOnly` cookie, so browser code cannot read it:
+
+```typescript
+import { copilotBaseURL } from "./copilot-host";
+
+function env(name: string): string {
+ const value = process.env[name];
+ if (!value) throw new Error(`Set ${name}.`);
+ return value;
+}
+
+function readCookie(request: Request, name: string): string | undefined {
+ for (const pair of (request.headers.get("cookie") ?? "").split(";")) {
+ const [key, ...rest] = pair.trim().split("=");
+ if (key !== name) continue;
+ try {
+ return decodeURIComponent(rest.join("="));
+ } catch {
+ return undefined; // A malformed value counts as no cookie.
+ }
+ }
+ return undefined;
+}
+
+function cookie(name: string, value: string, maxAge: number): string {
+ return `${name}=${encodeURIComponent(value)}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${maxAge}`;
+}
+
+/** GET /auth/github: send the user to GitHub. */
+export function startGithubSignIn(): Response {
+ const state = crypto.randomUUID();
+ const url = new URL("https://github.com/login/oauth/authorize");
+ url.searchParams.set("client_id", env("GITHUB_CLIENT_ID"));
+ url.searchParams.set("scope", "read:user");
+ url.searchParams.set("state", state);
+ return new Response(null, {
+ status: 302,
+ headers: {
+ Location: url.toString(),
+ "Set-Cookie": cookie("github_state", state, 600),
+ },
+ });
+}
+
+/** GET /auth/github/callback: swap the code for a token. */
+export async function finishGithubSignIn(request: Request): Promise {
+ const url = new URL(request.url);
+ const state = url.searchParams.get("state");
+ if (!state || state !== readCookie(request, "github_state")) {
+ return new Response("The sign-in failed. Try again.", { status: 400 });
+ }
+ const reply = await fetch("https://github.com/login/oauth/access_token", {
+ method: "POST",
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
+ body: JSON.stringify({
+ client_id: env("GITHUB_CLIENT_ID"),
+ client_secret: env("GITHUB_CLIENT_SECRET"),
+ code: url.searchParams.get("code"),
+ }),
+ });
+ const { access_token }: { access_token?: string } = await reply.json();
+ if (!access_token) {
+ return new Response("The sign-in failed. Try again.", { status: 400 });
+ }
+ const baseURL = await copilotBaseURL(access_token);
+ if (!baseURL) {
+ return new Response("This GitHub account has no Copilot access.", {
+ status: 403,
+ });
+ }
+ const month = 60 * 60 * 24 * 30;
+ const headers = new Headers({ Location: "/" });
+ headers.append("Set-Cookie", cookie("copilot_token", access_token, month));
+ headers.append("Set-Cookie", cookie("copilot_base_url", baseURL, month));
+ headers.append("Set-Cookie", cookie("github_state", "", 0));
+ return new Response(null, { status: 302, headers });
+}
+
+/** The Copilot sign-in of the request, or `undefined`. */
+export function getCopilotSignIn(request: Request) {
+ const token = readCookie(request, "copilot_token");
+ const baseURL = readCookie(request, "copilot_base_url");
+ if (!token || !baseURL || !URL.canParse(baseURL)) return undefined;
+ // The user can change cookies. Send the token only to a Copilot host.
+ const { protocol, hostname } = new URL(baseURL);
+ if (protocol !== "https:" || !hostname.endsWith(".githubcopilot.com")) {
+ return undefined;
+ }
+ return { token, baseURL };
+}
+```
+
+1. Call `startGithubSignIn` from your `GET /auth/github` route.
+2. Call `finishGithubSignIn` from your `GET /auth/github/callback` route.
+3. Link a **Sign in with GitHub** button to `/auth/github`.
+
+- For GitHub Enterprise, also accept your `copilot-api.` host in `getCopilotSignIn`.
+- To keep the token fully out of the browser, keep it in your session store. Then put only the session id in the cookie.
+
+#### Terminal app
+
+`copilot-device.ts` shows a code. Then it waits until the user types the code on GitHub:
+
+```typescript
+interface DeviceCode {
+ device_code?: string;
+ user_code?: string;
+ verification_uri?: string;
+ interval?: number;
+ error_description?: string;
+}
+
+interface TokenReply {
+ access_token?: string;
+ error?: string;
+ error_description?: string;
+ interval?: number;
+}
+
+async function postGithub(path: string, body: Record) {
+ const response = await fetch(`https://github.com${path}`, {
+ method: "POST",
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ return response.json();
+}
+
+export async function signInWithDevice(): Promise {
+ const clientId = process.env.GITHUB_CLIENT_ID;
+ if (!clientId) throw new Error("Set GITHUB_CLIENT_ID.");
+ const code: DeviceCode = await postGithub("/login/device/code", {
+ client_id: clientId,
+ scope: "read:user",
+ });
+ if (!code.device_code) {
+ throw new Error(code.error_description ?? "The GitHub sign-in failed.");
+ }
+ console.log(`Open ${code.verification_uri} and type ${code.user_code}`);
+
+ let interval = code.interval ?? 5;
+ for (;;) {
+ await new Promise((resolve) => setTimeout(resolve, interval * 1000));
+ const reply: TokenReply = await postGithub("/login/oauth/access_token", {
+ client_id: clientId,
+ device_code: code.device_code,
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
+ });
+ if (reply.access_token) return reply.access_token;
+ if (reply.error === "slow_down") {
+ interval = reply.interval ?? interval + 5;
+ } else if (reply.error !== "authorization_pending") {
+ throw new Error(reply.error_description ?? "The GitHub sign-in failed.");
+ }
+ }
+}
+```
+
+- `authorization_pending`: the user did not type the code yet. The loop asks again.
+- `slow_down`: GitHub wants a longer wait. The loop waits longer.
+- Any other error stops the sign-in. For example, the code expires after 15 minutes.
+
+Keep the token in the OS keychain, or in a file that only the user can read. With **Expire user access tokens** cleared, the token does not expire, so the user signs in one time.
+
+### 3. Find the Copilot host
+
+Any GitHub account can sign in, but only some accounts have Copilot. Business and enterprise accounts can also have a different host. `copilot-host.ts` reads both from GitHub:
+
+```typescript
+interface CopilotUser {
+ chat_enabled?: boolean;
+ endpoints?: { api?: string };
+}
+
+/**
+ * The Copilot API host of the user, or `undefined` when the account has no
+ * Copilot. `githubHost` is your GitHub Enterprise domain, or `github.com`.
+ */
+export async function copilotBaseURL(
+ token: string,
+ githubHost = "github.com",
+): Promise {
+ const api =
+ githubHost === "github.com"
+ ? "https://api.github.com"
+ : `https://api.${githubHost}`;
+ const response = await fetch(`${api}/copilot_internal/user`, {
+ headers: {
+ Accept: "application/json",
+ Authorization: `Bearer ${token}`,
+ "X-GitHub-Api-Version": "2025-04-01",
+ },
+ });
+ // A failed lookup uses the default host. Only GitHub can say "no Copilot".
+ const user: CopilotUser = response.ok ? await response.json() : {};
+ if (user.chat_enabled === false) return undefined;
+ if (user.endpoints?.api) return user.endpoints.api.replace(/\/+$/, "");
+ return githubHost === "github.com"
+ ? "https://api.githubcopilot.com"
+ : `https://copilot-api.${githubHost}`;
+}
+```
+
+- `chat_enabled: false`: the account has no Copilot plan and no seat from an organization.
+- `endpoints.api`: the host of this account.
+- GitHub Enterprise: pass your domain as `githubHost`. Also put your domain in place of `github.com` in the sign-in URLs of step 2.
+
+`copilot_internal/user` is not a public GitHub API. GitHub can change it.
+
+### 4. Add the Copilot headers
+
+Copilot reads a set of headers on every request. One `wrapFetch` in `copilot-headers.ts` sets all of them for each route:
+
+```typescript
+import type { FetchWrapper } from "@tanstack/ai";
+
+/** The Copilot interaction types: the main loop, a subagent, a title, or a compaction. */
+type Interaction =
+ | "conversation-agent"
+ | "conversation-subagent"
+ | "conversation-background"
+ | "conversation-compaction";
+
+export function copilotHeaders(
+ sessionId: string,
+ interaction: Interaction = "conversation-agent",
+): FetchWrapper {
+ return (next) => (input, init) => {
+ const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
+ const items = body.input ?? body.messages ?? [];
+ const last = items.at(-1);
+ // A user turn has text from the user. A tool result alone is an agent turn.
+ const userTurn =
+ last?.role === "user" &&
+ (!Array.isArray(last.content) ||
+ last.content.some(
+ (part: { type?: string }) => part.type !== "tool_result",
+ ));
+ const url = input instanceof Request ? input.url : String(input);
+ const headers = new Headers(init?.headers);
+ headers.set("User-Agent", "my-app/1.0.0");
+ headers.set("Openai-Intent", "conversation-edits");
+ headers.set("X-GitHub-Api-Version", "2026-08-01");
+ headers.set("X-Interaction-Type", interaction);
+ headers.set("X-Interaction-Id", sessionId);
+ headers.set(
+ "x-initiator",
+ interaction === "conversation-agent" && userTurn ? "user" : "agent",
+ );
+ if (/"type":"(image|image_url|input_image)"/.test(JSON.stringify(items))) {
+ headers.set("Copilot-Vision-Request", "true");
+ }
+ if (url.includes("/v1/messages")) {
+ // Keep the betas that the adapter already set.
+ const betas = headers.get("anthropic-beta");
+ const thinking = "interleaved-thinking-2025-05-14";
+ headers.set("anthropic-beta", betas ? `${betas},${thinking}` : thinking);
+ }
+ return next(input, { ...init, headers });
+ };
+}
+```
+
+- `x-initiator`: Copilot bills the requests that a user starts (`user`). Tool loop requests and the other interaction types are `agent`.
+- `X-Interaction-Id`: use one stable id for each chat session.
+- `X-Interaction-Type`: use `conversation-subagent` in a subagent, `conversation-background` for a title, and `conversation-compaction` for a summary of old messages.
+- `Copilot-Vision-Request`: if a request has images, set it.
+
+### 5. Call the model
+
+Copilot serves each model on one of three APIs. `GET {baseURL}/models` lists the `supported_endpoints` of each model. Choose the adapter in this order:
+
+1. `/v1/messages`: use `createAnthropicChat` from `@tanstack/ai-anthropic`, with the token and `auth: "bearer"`.
+2. `/responses`: use `openaiCompatible` with `api: "responses"`.
+3. `/chat/completions`: use `openaiCompatible` without `api` and without `modelOptions`.
+
+The examples below read the sign-in from the cookies of the web app. In a terminal app, use the saved token and the host from step 3 in place of `signIn`.
+
+#### Claude models
+
+Set the Copilot host as `baseURL`. The Anthropic SDK adds `/v1/messages`. Set `auth: "bearer"`. Do not use `"oauth"`, because it adds the Claude Code headers:
+
+```typescript
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
+import { createAnthropicChat } from "@tanstack/ai-anthropic";
+import { copilotHeaders } from "./copilot-headers";
+import { getCopilotSignIn } from "./copilot-auth";
+
+export async function POST(request: Request) {
+ const signIn = getCopilotSignIn(request);
+ if (!signIn) return new Response("Sign in with GitHub.", { status: 401 });
+ const params = await chatParamsFromRequest(request);
+ const stream = chat({
+ adapter: createAnthropicChat("claude-sonnet-5-5", signIn.token, {
+ baseURL: signIn.baseURL,
+ auth: "bearer",
+ }),
+ messages: params.messages,
+ threadId: params.threadId,
+ wrapFetch: copilotHeaders(params.threadId),
+ });
+ return toServerSentEventsResponse(stream);
+}
+```
+
+`auth: "bearer"` sends `Authorization: Bearer` and no `x-api-key`. With a custom `baseURL`, the adapter sends no mid-conversation betas. It adds cache markers only when you set `promptCache`.
+
+#### OpenAI models
+
+Pass the token as `apiKey`:
+
+```typescript
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
+import { openaiCompatible } from "@tanstack/ai-openai/compatible";
+import { copilotHeaders } from "./copilot-headers";
+import { getCopilotSignIn } from "./copilot-auth";
+
+export async function POST(request: Request) {
+ const signIn = getCopilotSignIn(request);
+ if (!signIn) return new Response("Sign in with GitHub.", { status: 401 });
+ const params = await chatParamsFromRequest(request);
+ const copilot = openaiCompatible({
+ name: "github-copilot",
+ baseURL: signIn.baseURL,
+ apiKey: signIn.token,
+ api: "responses",
+ models: ["gpt-6.1-sol"],
+ });
+
+ const stream = chat({
+ adapter: copilot("gpt-6.1-sol"),
+ messages: params.messages,
+ threadId: params.threadId,
+ modelOptions: { store: false, include: ["reasoning.encrypted_content"] },
+ wrapFetch: copilotHeaders(params.threadId),
+ });
+ return toServerSentEventsResponse(stream);
+}
+```
+
+- `store: false`: Copilot keeps no responses. With `include`, the stream gives the encrypted reasoning. The adapter sends it back on the next turn when your history keeps it.
+- The Chat Completions adapter reads the thinking that Copilot streams on `delta.reasoning_text`.
+
+Click **Sign in with GitHub**, approve, then send a message. The model call goes to the Copilot plan of the user.
+
+## ChatGPT plan
+
+A user with a ChatGPT plan can sign in with ChatGPT, with no API key. [Sign in with ChatGPT](./openai-advanced#sign-in-with-chatgpt-byok) adds the sign-in button and keeps the access token in the `openai` BYOK slot. Your server reads the token like a pasted key:
+
+```typescript
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
+import { createOpenaiChat } from "@tanstack/ai-openai";
+import { openaiByok } from "@tanstack/ai-openai/byok";
+import { byokMissing, getByokKey } from "@tanstack/ai/byok/server";
+
+export async function POST(request: Request) {
+ const apiKey = getByokKey(request, openaiByok);
+ if (!apiKey) return byokMissing(openaiByok);
+ const params = await chatParamsFromRequest(request);
+ const stream = chat({
+ adapter: createOpenaiChat("gpt-6.1-sol", apiKey),
+ messages: params.messages,
+ threadId: params.threadId,
+ modelOptions: { store: false },
+ });
+ return toServerSentEventsResponse(stream);
+}
+```
+
+The ChatGPT route requires `store: false`. Do not set `max_output_tokens`. [Sign in with ChatGPT](./openai-advanced#sign-in-with-chatgpt-byok) lists the other limits.
diff --git a/docs/adapters/openai-compatible.md b/docs/adapters/openai-compatible.md
index a5d2897064..0f73318eed 100644
--- a/docs/adapters/openai-compatible.md
+++ b/docs/adapters/openai-compatible.md
@@ -1,7 +1,7 @@
---
title: OpenAI-Compatible Adapter
id: openai-compatible-adapter
-description: "Use any OpenAI-compatible provider (DeepSeek, Moonshot/Kimi, Together, Fireworks, Cerebras, Qwen, Perplexity, local servers, and more) in TanStack AI with one generic adapter."
+description: "Use any OpenAI-compatible provider in TanStack AI with one generic adapter: DeepSeek, Moonshot/Kimi, Together, Fireworks, Cerebras, Qwen, Perplexity, local servers, and more."
keywords:
- tanstack ai
- openai compatible
@@ -19,15 +19,21 @@ keywords:
- adapter
---
-Many providers expose the OpenAI **Chat Completions** API (`/chat/completions`) — DeepSeek, Moonshot/Kimi, Together, Fireworks, Cerebras, Alibaba Qwen, Perplexity, NVIDIA NIM, and local servers like LM Studio, Ollama, and vLLM. Instead of a dedicated package per provider, TanStack AI ships one generic adapter: point it at any compatible `baseURL`, give it your models, and you get the same type-safe `chat()` experience as the first-class adapters.
+`openaiCompatible` calls any server that speaks `/chat/completions` and has no `@tanstack/ai-*` package. Set `baseURL` and the model list, then call `chat()`.
-Use this when your provider speaks the OpenAI Chat Completions wire format but doesn't have its own `@tanstack/ai-*` package. If a dedicated adapter exists (OpenAI, Grok, Groq, OpenRouter), prefer it. Those carry curated per-model metadata. For Vercel AI Gateway, install `@tanstack/ai-vercel-gateway` and use `vercelGatewayText`. See [Vercel AI Gateway](./vercel-gateway.md). For Lovable AI Gateway, install `@tanstack/ai-lovable` and use `lovableText` (plus image, video, embeddings, and speech factories). See [Lovable AI Gateway](./lovable.md).
+`openaiCompatible` is one generic adapter for all of them. Point it at the `baseURL`, list your models, and you get the same type-safe `chat()` as with the other adapters.
-Perplexity Sonar chat stays on this adapter. [`@tanstack/ai-perplexity`](./perplexity.md) is Search/grounding only — it does not replace `openaiCompatible` for `chat()`. Optional: pass `defaultHeaders: getPerplexityIntegrationHeaders()` from that package to send Perplexity's `X-Pplx-Integration` attribution header.
+If a dedicated adapter exists, use it, because it has curated metadata for each model:
-## Installation
+- [OpenAI](./openai), [Grok](./grok), [Groq](./groq), and [OpenRouter](./openrouter).
+- Vercel AI Gateway: `vercelGatewayText` from `@tanstack/ai-vercel-gateway`. See [Vercel AI Gateway](./vercel-gateway.md).
+- Lovable AI Gateway: `lovableText` (plus image, video, embeddings, and speech factories) from `@tanstack/ai-lovable`. See [Lovable AI Gateway](./lovable.md).
-The adapter ships inside `@tanstack/ai-openai` under the `/compatible` subpath — no extra install:
+Perplexity Sonar chat uses this adapter. [`@tanstack/ai-perplexity`](./perplexity.md) is for search and grounding only. To send the `X-Pplx-Integration` attribution header of Perplexity, pass `defaultHeaders: getPerplexityIntegrationHeaders()` from that package.
+
+## 1. Install
+
+The adapter is in `@tanstack/ai-openai`, under the `/compatible` subpath:
@@ -42,146 +48,110 @@ octane: @tanstack/ai-openai
-## Basic Usage
-
-Configure the provider once with `openaiCompatible({ baseURL, apiKey, models })`, then select a model per call. The returned model name is a type-safe union of the models you declared:
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { openaiCompatible } from "@tanstack/ai-openai/compatible";
-
-const deepseek = openaiCompatible({
- name: "deepseek", // optional label shown in devtools/errors (default: "openai-compatible")
- baseURL: "https://api.deepseek.com/v1",
- apiKey: process.env.DEEPSEEK_API_KEY!,
- models: ["deepseek-chat", "deepseek-reasoner"],
-});
-
-const stream = chat({
- adapter: deepseek("deepseek-chat"),
- messages: [{ role: "user", content: "Hello!" }],
-});
-```
-
-`deepseek("deepseek-reasoner")` is valid; `deepseek("gpt-4o")` is a type error — only declared models are accepted.
-
-## One-Shot Usage
+## 2. Set the API key
-For a single model, skip the provider-factory and build the adapter inline with `openaiCompatibleText`:
+Put the key of your provider in the environment of your server, with the variable name that you like:
-```typescript
-import { chat } from "@tanstack/ai";
-import { openaiCompatibleText } from "@tanstack/ai-openai/compatible";
-
-const stream = chat({
- adapter: openaiCompatibleText("deepseek-chat", {
- baseURL: "https://api.deepseek.com/v1",
- apiKey: process.env.DEEPSEEK_API_KEY!,
- }),
- messages: [{ role: "user", content: "Hello!" }],
-});
+```bash
+DEEPSEEK_API_KEY=sk-...
```
-## Declaring Models
+The adapter reads no environment variable itself. You pass the key as `apiKey` in the next step.
-The `models` array accepts two forms, which you can mix:
+## 3. Stream a chat from the server
-- **A bare string** — gets optimistic defaults: `text` + `image` input, with `streaming`, `function_calling`, and `structured_outputs` support. Good for mainstream chat models.
-- **A `createModel(name, capabilities)` definition** — declares precise per-model capabilities so the types match reality (e.g. a reasoning model with no image input).
+Configure the provider one time with `openaiCompatible({ baseURL, apiKey, models })`. Then pick a model for each call:
```typescript
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
import { openaiCompatible } from "@tanstack/ai-openai/compatible";
-import { createModel } from "@tanstack/ai";
-const provider = openaiCompatible({
+const deepseek = openaiCompatible({
+ name: "deepseek", // optional label for devtools and errors (default: "openai-compatible")
baseURL: "https://api.deepseek.com/v1",
apiKey: process.env.DEEPSEEK_API_KEY!,
- models: [
- "deepseek-chat", // string → optimistic defaults
- createModel("deepseek-reasoner", {
- input: ["text"], // text only
- features: ["reasoning", "structured_outputs"],
- }),
- ],
+ models: ["deepseek-chat", "deepseek-reasoner"],
});
-```
-> Capabilities are enforced at the type level. If a provider rejects a feature at runtime (e.g. tools on a model that doesn't support them), declare that model with `createModel` and omit the unsupported feature so the types stop you from calling it.
-
-## Configuration
-
-`openaiCompatible` accepts every OpenAI SDK `ClientOptions` field besides `apiKey`/`baseURL` (which are required and promoted to the top level). The most useful are `defaultHeaders` and `defaultQuery`, for providers that need extra auth or routing parameters:
+export async function POST(request: Request) {
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
-```typescript
-import { openaiCompatible } from "@tanstack/ai-openai/compatible";
+ const stream = chat({
+ adapter: deepseek("deepseek-chat"),
+ messages,
+ threadId,
+ runId,
+ });
-const provider = openaiCompatible({
- baseURL: "https://api.example.com/v1",
- apiKey: process.env.EXAMPLE_API_KEY!,
- models: ["some-model"],
- defaultHeaders: { "X-Custom-Header": "value" },
- defaultQuery: { "api-version": "2026-01-01" },
-});
+ return toServerSentEventsResponse(stream);
+}
```
-Need a header on each request, or a log of each model call? The chat adapters on this page support [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call).
-
-## Chat Completions vs Responses
+The model name is a type-safe union of the models that you declared. `deepseek("deepseek-reasoner")` is valid. `deepseek("gpt-4o")` is a type error.
-By default the adapter targets the **Chat Completions** API (`/chat/completions`). For providers that implement the **Responses** API, select `api: "responses"`. This API choice also controls how `ChatStreamSummarizeAdapter` forwards `maxLength`, regardless of the wrapper name:
+## 4. Render the chat
-- Chat Completions uses `max_tokens`.
-- Responses uses `max_output_tokens`.
+The client is the same for every provider. Point `useChat` at your route:
-An explicit token limit in `modelOptions` takes precedence over `maxLength`.
+```tsx
+import { useState } from "react";
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
-```typescript
-import { openaiCompatible } from "@tanstack/ai-openai/compatible";
+export function Chat() {
+ const [input, setInput] = useState("");
+ const { messages, sendMessage } = useChat({
+ connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
+ });
-const provider = openaiCompatible({
- baseURL: "https://my-resource.openai.azure.com/openai/v1",
- apiKey: process.env.AZURE_OPENAI_API_KEY!,
- models: ["gpt-4o"],
- api: "responses", // default is "chat-completions"
-});
+ return (
+
+ );
+}
```
-**Reasoning:** Reasoning deltas stream as thinking content. Endpoints frozen on the pre-July-2025 OpenAI spec may still emit the legacy event name `response.reasoning.delta` (removed from the spec in favor of `response.reasoning_text.delta`); the adapter recognizes both and maps them identically.
+For Vue, Solid, Svelte, Angular, and the other frameworks, see [Quick Start](../getting-started/quick-start#3-render-the-chat).
-## Supported Providers
+You now have a streaming chat with any OpenAI-compatible provider. [Tools](../tools/tools) work the same as with every adapter, on models that support function calling.
-Any provider implementing the OpenAI Chat Completions API works. Common ones are below — **verify the `baseURL` and model ids against each provider's current docs**, since they change over time. Set the API key via the provider's own environment variable and pass it as `apiKey`.
-
-| Provider | `baseURL` | Example model |
-| --- | --- | --- |
-| DeepSeek | `https://api.deepseek.com/v1` | `deepseek-chat`, `deepseek-reasoner` |
-| Moonshot / Kimi | `https://api.moonshot.ai/v1` | `kimi-k2-0711-preview` |
-| Alibaba Qwen (DashScope, intl) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `qwen-max`, `qwen-plus` |
-| Alibaba Qwen (DashScope, China) | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `qwen-max` |
-| Together AI | `https://api.together.xyz/v1` | `meta-llama/Llama-3.3-70B-Instruct-Turbo` |
-| Fireworks AI | `https://api.fireworks.ai/inference/v1` | `accounts/fireworks/models/llama-v3p3-70b-instruct` |
-| Cerebras | `https://api.cerebras.ai/v1` | `llama-3.3-70b` |
-| DeepInfra | `https://api.deepinfra.com/v1/openai` | `meta-llama/Llama-3.3-70B-Instruct` |
-| Perplexity | `https://api.perplexity.ai` | `sonar`, `sonar-pro` |
-| Mistral | `https://api.mistral.ai/v1` | `mistral-large-latest` |
-| Nebius | `https://api.studio.nebius.ai/v1` | `meta-llama/Llama-3.3-70B-Instruct` |
-| Z.AI (GLM) | `https://api.z.ai/api/paas/v4` | `glm-4.6` |
-| Baseten | `https://inference.baseten.co/v1` | model-dependent |
-| Hugging Face (router) | `https://router.huggingface.co/v1` | `meta-llama/Llama-3.3-70B-Instruct` |
-| NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | `meta/llama-3.3-70b-instruct` |
-| Kilo Gateway | `https://api.kilo.ai/api/gateway` | `anthropic/claude-opus-5.5` |
-| ZenMux | `https://zenmux.ai/api/v1` | `openai/gpt-5.5` |
-| Poe | `https://api.poe.com/v1` | `openai/gpt-5.5` |
-| DigitalOcean | `https://inference.do-ai.run/v1` | `openai-gpt-5.5` |
-| Modal | `https://inference.us-west.modal.direct/v1` | `moonshotai/Kimi-K3` |
-| Snowflake Cortex | `https://.snowflakecomputing.com/api/v2/cortex/v1` | `claude-opus-5` |
+## One model only
-Some providers sign the user in with OAuth instead of an API key. For Poe, DigitalOcean, and Snowflake Cortex, pass the access token from your sign-in code as `apiKey`.
+For a single model, skip the provider factory and build the adapter inline with `openaiCompatibleText`:
+```typescript
+import { chat } from "@tanstack/ai";
+import { openaiCompatibleText } from "@tanstack/ai-openai/compatible";
-On Chat Completions, `RUN_FINISHED.usage` carries the prompt cache counts that the provider reports. Cache reads arrive on `promptTokensDetails.cachedTokens`, and cache writes arrive on `promptTokensDetails.cacheWriteTokens`. Moonshot / Kimi reports both.
+const stream = chat({
+ adapter: openaiCompatibleText("deepseek-chat", {
+ baseURL: "https://api.deepseek.com/v1",
+ apiKey: process.env.DEEPSEEK_API_KEY!,
+ }),
+ messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
+});
+```
-## Local & Self-Hosted Servers
+## Local and self-hosted servers
Point the adapter at any local OpenAI-compatible server. The API key is usually a placeholder:
@@ -204,7 +174,7 @@ const vllm = openaiCompatible({
models: ["meta-llama/Llama-3.3-70B-Instruct"],
});
-// Ollama's OpenAI-compatible endpoint
+// The OpenAI-compatible endpoint of Ollama
const ollama = openaiCompatible({
name: "ollama",
baseURL: "http://localhost:11434/v1",
@@ -213,11 +183,11 @@ const ollama = openaiCompatible({
});
```
-> Ollama also has a dedicated adapter, [`@tanstack/ai-ollama`](./ollama), which understands its native API. Use `openaiCompatible` only if you specifically want Ollama's OpenAI-compatible surface.
+Ollama also has a dedicated adapter, [`@tanstack/ai-ollama`](./ollama), for its native API. If you want the OpenAI-compatible surface of Ollama, use `openaiCompatible`.
-## LiteLLM Proxy
+## LiteLLM proxy
-[LiteLLM](https://github.com/BerriAI/litellm) is a self-hosted gateway that exposes a single OpenAI Chat Completions endpoint in front of 100+ providers (OpenAI, Anthropic, Google, Azure, AWS Bedrock, Mistral, Groq, and more). Because the proxy speaks the OpenAI wire format, it needs no dedicated package — point `openaiCompatible` at your proxy's `baseURL` (default `http://localhost:4000/v1`) and route to a provider with LiteLLM's `provider/model` naming:
+[LiteLLM](https://github.com/BerriAI/litellm) is a self-hosted gateway. It gives one OpenAI Chat Completions endpoint for 100+ providers (OpenAI, Anthropic, Google, Azure, AWS Bedrock, Mistral, Groq, and more). Point `openaiCompatible` at your proxy (default `http://localhost:4000/v1`). Pick a provider with the `provider/model` names of LiteLLM:
```typescript
import { openaiCompatible } from "@tanstack/ai-openai/compatible";
@@ -234,465 +204,77 @@ const litellm = openaiCompatible({
});
```
-`litellm("anthropic/claude-sonnet-5")` selects the Anthropic route; `litellm("openai/gpt-5.5")` selects OpenAI — all through the one proxy. Declare only the model routes you configured on the proxy; for precise per-model capabilities (e.g. a reasoning route without image input), use `createModel` as shown under [Declaring Models](#declaring-models).
-
-> The proxy holds each upstream provider's real credentials; the `apiKey` here is the proxy's own virtual/master key, not the upstream provider's.
-
-## Azure OpenAI
-
-Use `createAzureOpenaiText` from `@tanstack/ai-openai`. It sets the `api-key` header, the endpoint, the API version, and the deployment name for you:
-
-```typescript
-import { createAzureOpenaiText } from "@tanstack/ai-openai";
-
-const adapter = createAzureOpenaiText("gpt-5.6", process.env.AZURE_OPENAI_API_KEY!, {
- resourceName: "my-resource",
- deploymentName: "production-chat",
-});
-```
-
-See [Azure OpenAI](./openai#azure-openai) for `azureOpenaiText`, which reads the key and the other values from the environment.
-
-## GitHub Copilot
-
-Your users can pay for model calls with their GitHub Copilot plan. Each user signs in with GitHub. Your server sends their GitHub token to Copilot as `Authorization: Bearer`.
-
-### 1. Create a GitHub OAuth app
-
-1. On GitHub, open **Settings > Developer settings > OAuth Apps**. Click **New OAuth App**.
-2. Set **Authorization callback URL** to your callback route, for example `https://my-app.com/auth/github/callback`.
-3. Clear **Expire user access tokens**. The examples below keep one token and do not refresh it.
-4. If your app runs in a terminal, select **Enable Device Flow**.
-5. Set `GITHUB_CLIENT_ID` to the client ID. For a web app, also generate a client secret and set `GITHUB_CLIENT_SECRET` to it.
-
-If you keep **Expire user access tokens** on, a token lasts 8 hours. Refresh it with its refresh token, as [Authorizing OAuth apps](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps) describes.
-
-### 2. Sign the user in
-
-Pick the flow for your app:
-
-- Web app: the [web flow](#web-app) sends the user to GitHub and back.
-- Terminal or desktop app: the [device flow](#terminal-app) shows a code. The user types the code on GitHub.
-
-Both flows ask for the `read:user` scope only.
-
-#### Web app
-
-`copilot-auth.ts` has the two sign-in routes and a reader for your chat routes. The token goes into an `HttpOnly` cookie, so browser code cannot read it:
-
-```typescript
-import { copilotBaseURL } from "./copilot-host";
-
-function env(name: string): string {
- const value = process.env[name];
- if (!value) throw new Error(`Set ${name}.`);
- return value;
-}
-
-function readCookie(request: Request, name: string): string | undefined {
- for (const pair of (request.headers.get("cookie") ?? "").split(";")) {
- const [key, ...rest] = pair.trim().split("=");
- if (key !== name) continue;
- try {
- return decodeURIComponent(rest.join("="));
- } catch {
- return undefined; // A malformed value counts as no cookie.
- }
- }
- return undefined;
-}
-
-function cookie(name: string, value: string, maxAge: number): string {
- return `${name}=${encodeURIComponent(value)}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${maxAge}`;
-}
-
-/** GET /auth/github: send the user to GitHub. */
-export function startGithubSignIn(): Response {
- const state = crypto.randomUUID();
- const url = new URL("https://github.com/login/oauth/authorize");
- url.searchParams.set("client_id", env("GITHUB_CLIENT_ID"));
- url.searchParams.set("scope", "read:user");
- url.searchParams.set("state", state);
- return new Response(null, {
- status: 302,
- headers: {
- Location: url.toString(),
- "Set-Cookie": cookie("github_state", state, 600),
- },
- });
-}
-
-/** GET /auth/github/callback: swap the code for a token. */
-export async function finishGithubSignIn(request: Request): Promise {
- const url = new URL(request.url);
- const state = url.searchParams.get("state");
- if (!state || state !== readCookie(request, "github_state")) {
- return new Response("The sign-in failed. Try again.", { status: 400 });
- }
- const reply = await fetch("https://github.com/login/oauth/access_token", {
- method: "POST",
- headers: { Accept: "application/json", "Content-Type": "application/json" },
- body: JSON.stringify({
- client_id: env("GITHUB_CLIENT_ID"),
- client_secret: env("GITHUB_CLIENT_SECRET"),
- code: url.searchParams.get("code"),
- }),
- });
- const { access_token }: { access_token?: string } = await reply.json();
- if (!access_token) {
- return new Response("The sign-in failed. Try again.", { status: 400 });
- }
- const baseURL = await copilotBaseURL(access_token);
- if (!baseURL) {
- return new Response("This GitHub account has no Copilot access.", {
- status: 403,
- });
- }
- const month = 60 * 60 * 24 * 30;
- const headers = new Headers({ Location: "/" });
- headers.append("Set-Cookie", cookie("copilot_token", access_token, month));
- headers.append("Set-Cookie", cookie("copilot_base_url", baseURL, month));
- headers.append("Set-Cookie", cookie("github_state", "", 0));
- return new Response(null, { status: 302, headers });
-}
-
-/** The Copilot sign-in of the request, or `undefined`. */
-export function getCopilotSignIn(request: Request) {
- const token = readCookie(request, "copilot_token");
- const baseURL = readCookie(request, "copilot_base_url");
- if (!token || !baseURL || !URL.canParse(baseURL)) return undefined;
- // The user can change cookies. Send the token only to a Copilot host.
- const { protocol, hostname } = new URL(baseURL);
- if (protocol !== "https:" || !hostname.endsWith(".githubcopilot.com")) {
- return undefined;
- }
- return { token, baseURL };
-}
-```
-
-1. Call `startGithubSignIn` from your `GET /auth/github` route.
-2. Call `finishGithubSignIn` from your `GET /auth/github/callback` route.
-3. Link a **Sign in with GitHub** button to `/auth/github`.
-
-- For GitHub Enterprise, also accept your `copilot-api.` host in `getCopilotSignIn`.
-- To keep the token out of the browser completely, keep it in your session store. Then put only the session id in the cookie.
-
-#### Terminal app
-
-`copilot-device.ts` shows a code. Then it waits until the user types the code on GitHub:
-
-```typescript
-interface DeviceCode {
- device_code?: string;
- user_code?: string;
- verification_uri?: string;
- interval?: number;
- error_description?: string;
-}
-
-interface TokenReply {
- access_token?: string;
- error?: string;
- error_description?: string;
- interval?: number;
-}
-
-async function postGithub(path: string, body: Record) {
- const response = await fetch(`https://github.com${path}`, {
- method: "POST",
- headers: { Accept: "application/json", "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
- return response.json();
-}
-
-export async function signInWithDevice(): Promise {
- const clientId = process.env.GITHUB_CLIENT_ID;
- if (!clientId) throw new Error("Set GITHUB_CLIENT_ID.");
- const code: DeviceCode = await postGithub("/login/device/code", {
- client_id: clientId,
- scope: "read:user",
- });
- if (!code.device_code) {
- throw new Error(code.error_description ?? "The GitHub sign-in failed.");
- }
- console.log(`Open ${code.verification_uri} and type ${code.user_code}`);
-
- let interval = code.interval ?? 5;
- for (;;) {
- await new Promise((resolve) => setTimeout(resolve, interval * 1000));
- const reply: TokenReply = await postGithub("/login/oauth/access_token", {
- client_id: clientId,
- device_code: code.device_code,
- grant_type: "urn:ietf:params:oauth:grant-type:device_code",
- });
- if (reply.access_token) return reply.access_token;
- if (reply.error === "slow_down") {
- interval = reply.interval ?? interval + 5;
- } else if (reply.error !== "authorization_pending") {
- throw new Error(reply.error_description ?? "The GitHub sign-in failed.");
- }
- }
-}
-```
-
-- `authorization_pending`: the user did not type the code yet. The loop asks again.
-- `slow_down`: GitHub wants a longer wait. The loop waits longer.
-- Any other error stops the sign-in. For example, the code expires after 15 minutes.
+- `litellm("anthropic/claude-sonnet-5")` goes to Anthropic, and `litellm("openai/gpt-5.5")` goes to OpenAI, through the one proxy.
+- Declare only the routes that you configured on the proxy. For exact capabilities of each route, use `createModel`. See [Declaring models](#declaring-models).
+- The proxy holds the real credentials of each provider. The `apiKey` here is the virtual or master key of the proxy.
-Keep the token in the OS keychain, or in a file that only the user can read. With **Expire user access tokens** cleared, the token does not expire, so the user signs in one time.
+## Models and options
-### 3. Find the Copilot host
+### Declaring models
-Any GitHub account can sign in, but only some accounts have Copilot. Business and enterprise accounts can also have a different host. `copilot-host.ts` reads both from GitHub:
+The `models` array takes two forms. You can mix them:
-```typescript
-interface CopilotUser {
- chat_enabled?: boolean;
- endpoints?: { api?: string };
-}
-
-/**
- * The Copilot API host of the user, or `undefined` when the account has no
- * Copilot. `githubHost` is your GitHub Enterprise domain, or `github.com`.
- */
-export async function copilotBaseURL(
- token: string,
- githubHost = "github.com",
-): Promise {
- const api =
- githubHost === "github.com"
- ? "https://api.github.com"
- : `https://api.${githubHost}`;
- const response = await fetch(`${api}/copilot_internal/user`, {
- headers: {
- Accept: "application/json",
- Authorization: `Bearer ${token}`,
- "X-GitHub-Api-Version": "2025-04-01",
- },
- });
- // A failed lookup uses the default host. Only GitHub can say "no Copilot".
- const user: CopilotUser = response.ok ? await response.json() : {};
- if (user.chat_enabled === false) return undefined;
- if (user.endpoints?.api) return user.endpoints.api.replace(/\/+$/, "");
- return githubHost === "github.com"
- ? "https://api.githubcopilot.com"
- : `https://copilot-api.${githubHost}`;
-}
-```
-
-- `chat_enabled: false`: the account has no Copilot plan and no seat from an organization.
-- `endpoints.api`: the host of this account.
-- GitHub Enterprise: pass your domain as `githubHost`. Also put your domain in place of `github.com` in the sign-in URLs of step 2.
-
-`copilot_internal/user` is not a public GitHub API. GitHub can change it.
-
-### 4. Add the Copilot headers
-
-Copilot reads a set of headers on every request. One `wrapFetch` in `copilot-headers.ts` sets all of them for each route:
+- **A string** gets optimistic defaults: `text` and `image` input, with `streaming`, `function_calling`, and `structured_outputs`. Good for mainstream chat models.
+- **A `createModel(name, capabilities)` definition** declares the exact capabilities of a model, so the types match the model. An example is a reasoning model with no image input.
```typescript
-import type { FetchWrapper } from "@tanstack/ai";
-
-/** The Copilot interaction types: the main loop, a subagent, a title, or a compaction. */
-type Interaction =
- | "conversation-agent"
- | "conversation-subagent"
- | "conversation-background"
- | "conversation-compaction";
-
-export function copilotHeaders(
- sessionId: string,
- interaction: Interaction = "conversation-agent",
-): FetchWrapper {
- return (next) => (input, init) => {
- const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
- const items = body.input ?? body.messages ?? [];
- const last = items.at(-1);
- // A user turn has text from the user. A tool result alone is an agent turn.
- const userTurn =
- last?.role === "user" &&
- (!Array.isArray(last.content) ||
- last.content.some(
- (part: { type?: string }) => part.type !== "tool_result",
- ));
- const url = input instanceof Request ? input.url : String(input);
- const headers = new Headers(init?.headers);
- headers.set("User-Agent", "my-app/1.0.0");
- headers.set("Openai-Intent", "conversation-edits");
- headers.set("X-GitHub-Api-Version", "2026-08-01");
- headers.set("X-Interaction-Type", interaction);
- headers.set("X-Interaction-Id", sessionId);
- headers.set(
- "x-initiator",
- interaction === "conversation-agent" && userTurn ? "user" : "agent",
- );
- if (/"type":"(image|image_url|input_image)"/.test(JSON.stringify(items))) {
- headers.set("Copilot-Vision-Request", "true");
- }
- if (url.includes("/v1/messages")) {
- // Keep the betas that the adapter already set.
- const betas = headers.get("anthropic-beta");
- const thinking = "interleaved-thinking-2025-05-14";
- headers.set("anthropic-beta", betas ? `${betas},${thinking}` : thinking);
- }
- return next(input, { ...init, headers });
- };
-}
-```
-
-- `x-initiator`: Copilot bills the requests that a user starts (`user`). Tool loop requests and the other interaction types are `agent`.
-- `X-Interaction-Id`: use one stable id for each chat session.
-- `X-Interaction-Type`: use `conversation-subagent` in a subagent, `conversation-background` for a title, and `conversation-compaction` for a summary of old messages.
-- `Copilot-Vision-Request`: set it when a request has images.
-
-### 5. Call the model
-
-Copilot serves each model on one of three APIs. `GET {baseURL}/models` lists the `supported_endpoints` of each model. Choose the adapter in this order:
-
-1. `/v1/messages`: use `createAnthropicChat` from `@tanstack/ai-anthropic`, with the token and `auth: "bearer"`.
-2. `/responses`: use `openaiCompatible` with `api: "responses"`.
-3. `/chat/completions`: use `openaiCompatible` without `api` and without `modelOptions`.
-
-The examples below read the sign-in from the web app cookies. In a terminal app, use the saved token and the host from step 3 in place of `signIn`.
-
-#### Claude models
-
-Set the Copilot host as `baseURL`. The Anthropic SDK adds `/v1/messages`. Set `auth: "bearer"`. Do not use `"oauth"`, because it adds the Claude Code headers:
-
-```typescript
-import {
- chat,
- chatParamsFromRequest,
- toServerSentEventsResponse,
-} from "@tanstack/ai";
-import { createAnthropicChat } from "@tanstack/ai-anthropic";
-import { copilotHeaders } from "./copilot-headers";
-import { getCopilotSignIn } from "./copilot-auth";
-
-export async function POST(request: Request) {
- const signIn = getCopilotSignIn(request);
- if (!signIn) return new Response("Sign in with GitHub.", { status: 401 });
- const params = await chatParamsFromRequest(request);
- const stream = chat({
- adapter: createAnthropicChat("claude-sonnet-5-5", signIn.token, {
- baseURL: signIn.baseURL,
- auth: "bearer",
- }),
- messages: params.messages,
- wrapFetch: copilotHeaders(params.threadId),
- });
- return toServerSentEventsResponse(stream);
-}
-```
-
-`auth: "bearer"` sends `Authorization: Bearer` and no `x-api-key`. With a custom `baseURL`, the adapter sends no mid-conversation betas. It adds cache markers only when you set `promptCache`.
-
-#### OpenAI models
-
-Pass the token as `apiKey`:
-
-```typescript
-import {
- chat,
- chatParamsFromRequest,
- toServerSentEventsResponse,
-} from "@tanstack/ai";
import { openaiCompatible } from "@tanstack/ai-openai/compatible";
-import { copilotHeaders } from "./copilot-headers";
-import { getCopilotSignIn } from "./copilot-auth";
-
-export async function POST(request: Request) {
- const signIn = getCopilotSignIn(request);
- if (!signIn) return new Response("Sign in with GitHub.", { status: 401 });
- const params = await chatParamsFromRequest(request);
- const copilot = openaiCompatible({
- name: "github-copilot",
- baseURL: signIn.baseURL,
- apiKey: signIn.token,
- api: "responses",
- models: ["gpt-6.1-sol"],
- });
-
- const stream = chat({
- adapter: copilot("gpt-6.1-sol"),
- messages: params.messages,
- modelOptions: { store: false, include: ["reasoning.encrypted_content"] },
- wrapFetch: copilotHeaders(params.threadId),
- });
- return toServerSentEventsResponse(stream);
-}
-```
-
-- `store: false`: Copilot keeps no responses. With `include`, the stream gives the encrypted reasoning. The adapter sends it back on the next turn when your history keeps it.
-- The Chat Completions adapter reads the thinking that Copilot streams on `delta.reasoning_text`.
-
-Click **Sign in with GitHub**, approve, then send a message. The model call goes to the Copilot plan of the user.
-
-## ChatGPT plan
-
-A user with a ChatGPT plan can sign in with ChatGPT instead of an API key. [Sign in with ChatGPT](./openai#sign-in-with-chatgpt-byok) adds the sign-in button and keeps the access token in the `openai` BYOK slot. Your server reads the token like a pasted key:
-
-```typescript
-import {
- chat,
- chatParamsFromRequest,
- toServerSentEventsResponse,
-} from "@tanstack/ai";
-import { createOpenaiChat } from "@tanstack/ai-openai";
-import { openaiByok } from "@tanstack/ai-openai/byok";
-import { byokMissing, getByokKey } from "@tanstack/ai/byok/server";
+import { createModel } from "@tanstack/ai";
-export async function POST(request: Request) {
- const apiKey = getByokKey(request, openaiByok);
- if (!apiKey) return byokMissing(openaiByok);
- const params = await chatParamsFromRequest(request);
- const stream = chat({
- adapter: createOpenaiChat("gpt-6.1-sol", apiKey),
- messages: params.messages,
- modelOptions: { store: false },
- });
- return toServerSentEventsResponse(stream);
-}
+const provider = openaiCompatible({
+ baseURL: "https://api.deepseek.com/v1",
+ apiKey: process.env.DEEPSEEK_API_KEY!,
+ models: [
+ "deepseek-chat", // string: optimistic defaults
+ createModel("deepseek-reasoner", {
+ input: ["text"], // text only
+ features: ["reasoning", "structured_outputs"],
+ }),
+ ],
+});
```
-The ChatGPT route requires `store: false`. Do not set `max_output_tokens`. [Sign in with ChatGPT](./openai#sign-in-with-chatgpt-byok) lists the other limits.
+The types enforce the `input` modalities, the `modelOptions`, and the provider `tools` you declare. They do not read `features`. Function tools and structured output are always allowed on this path. If a provider rejects one of them at run time (for example, tools on a model without tool support), do not pass it for that model.
-## Example: With Tools
+### Providers
-Tools work exactly as they do with any other adapter, for models that support function calling:
+Any provider with the OpenAI Chat Completions API works. Common ones are below. **Check the `baseURL` and the model ids in the current docs of each provider**, because they change.
-```typescript
-import { chat, toolDefinition } from "@tanstack/ai";
-import { openaiCompatible } from "@tanstack/ai-openai/compatible";
-import { z } from "zod";
-
-const getWeatherDef = toolDefinition({
- name: "get_weather",
- description: "Get the current weather",
- inputSchema: z.object({ location: z.string() }),
-});
+| Provider | `baseURL` | Example model |
+| --- | --- | --- |
+| DeepSeek | `https://api.deepseek.com/v1` | `deepseek-chat`, `deepseek-reasoner` |
+| Moonshot / Kimi | `https://api.moonshot.ai/v1` | `kimi-k2-0711-preview` |
+| Alibaba Qwen (DashScope, intl) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `qwen-max`, `qwen-plus` |
+| Alibaba Qwen (DashScope, China) | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `qwen-max` |
+| Together AI | `https://api.together.xyz/v1` | `meta-llama/Llama-3.3-70B-Instruct-Turbo` |
+| Fireworks AI | `https://api.fireworks.ai/inference/v1` | `accounts/fireworks/models/llama-v3p3-70b-instruct` |
+| Cerebras | `https://api.cerebras.ai/v1` | `llama-3.3-70b` |
+| DeepInfra | `https://api.deepinfra.com/v1/openai` | `meta-llama/Llama-3.3-70B-Instruct` |
+| Perplexity | `https://api.perplexity.ai` | `sonar`, `sonar-pro` |
+| Mistral | `https://api.mistral.ai/v1` | `mistral-large-latest` |
+| Nebius | `https://api.studio.nebius.ai/v1` | `meta-llama/Llama-3.3-70B-Instruct` |
+| Z.AI (GLM) | `https://api.z.ai/api/paas/v4` | `glm-4.6` |
+| Baseten | `https://inference.baseten.co/v1` | depends on the model |
+| Hugging Face (router) | `https://router.huggingface.co/v1` | `meta-llama/Llama-3.3-70B-Instruct` |
+| NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | `meta/llama-3.3-70b-instruct` |
+| Kilo Gateway | `https://api.kilo.ai/api/gateway` | `anthropic/claude-opus-5.5` |
+| ZenMux | `https://zenmux.ai/api/v1` | `openai/gpt-5.5` |
+| Poe | `https://api.poe.com/v1` | `openai/gpt-5.5` |
+| DigitalOcean | `https://inference.do-ai.run/v1` | `openai-gpt-5.5` |
+| Modal | `https://inference.us-west.modal.direct/v1` | `moonshotai/Kimi-K3` |
+| Snowflake Cortex | `https://.snowflakecomputing.com/api/v2/cortex/v1` | `claude-opus-5` |
-const getWeather = getWeatherDef.server(async ({ location }) => {
- return { temperature: 72, conditions: "sunny" };
-});
+- Some providers sign the user in with OAuth, not with an API key. For Poe, DigitalOcean, and Snowflake Cortex, pass the access token from your sign-in code as `apiKey`.
+- On Chat Completions, `RUN_FINISHED.usage` carries the prompt cache counts that the provider reports. Cache reads go to `promptTokensDetails.cachedTokens`, and cache writes go to `promptTokensDetails.cacheWriteTokens`. Moonshot / Kimi reports both.
-const deepseek = openaiCompatible({
- baseURL: "https://api.deepseek.com/v1",
- apiKey: process.env.DEEPSEEK_API_KEY!,
- models: ["deepseek-chat"],
-});
+## Advanced
-const stream = chat({
- adapter: deepseek("deepseek-chat"),
- messages: [{ role: "user", content: "What's the weather in Tokyo?" }],
- tools: [getWeather],
-});
-```
+These topics are on [OpenAI-Compatible: Advanced](./openai-compatible-advanced):
-## Next Steps
+- Configuration: headers, query parameters, and `wrapFetch`.
+- The Responses API (`api: "responses"`).
+- Azure OpenAI.
+- GitHub Copilot: let users pay with their Copilot plan.
+- ChatGPT plan: let users pay with their ChatGPT plan.
-- [OpenAI Adapter](./openai) - The first-class OpenAI adapter
-- [OpenRouter Adapter](./openrouter) - Access 300+ models through one gateway
-- [Tools Guide](../tools/tools) - Learn about tools
-- [Extending Adapters](../advanced/extend-adapter) - Add custom models to any adapter
+To add custom models to any adapter, see [Extending Adapters](../advanced/extend-adapter).
diff --git a/docs/adapters/openai.md b/docs/adapters/openai.md
index d6a0985b7c..a94b0c29c8 100644
--- a/docs/adapters/openai.md
+++ b/docs/adapters/openai.md
@@ -2,7 +2,7 @@
title: OpenAI
id: openai-adapter
order: 1
-description: "Use OpenAI models with TanStack AI — GPT-4o, GPT-5, DALL-E image generation, TTS, and Whisper transcription via @tanstack/ai-openai."
+description: "Use OpenAI models in TanStack AI with @tanstack/ai-openai: GPT chat, gpt-image images, speech, Whisper transcription, embeddings, and Sora video."
keywords:
- tanstack ai
- openai
@@ -15,11 +15,11 @@ keywords:
- chatgpt
---
-The OpenAI adapter provides access to OpenAI's models, including GPT-4o, GPT-5, image generation (DALL-E), text-to-speech (TTS), and audio transcription (Whisper).
+The OpenAI adapter calls GPT for chat, and it calls image, speech, transcription, embedding, and video models from the same package.
-> Using a third-party provider that speaks the OpenAI API (DeepSeek, Moonshot/Kimi, Together, Fireworks, a local LM Studio/vLLM server, …)? See the [OpenAI-Compatible Adapter](./openai-compatible) for a generic `openaiCompatible({ baseURL, apiKey, models })` factory.
+Does your provider speak the OpenAI API, for example DeepSeek, Kimi, or a local vLLM server? Use the [OpenAI-Compatible adapter](./openai-compatible).
-## Installation
+## 1. Install
@@ -34,371 +34,307 @@ octane: @tanstack/ai-openai
-## Basic Usage
+## 2. Set the API key
-```typescript
-import { chat } from "@tanstack/ai";
-import { openaiText } from "@tanstack/ai-openai";
-
-const stream = chat({
- adapter: openaiText("gpt-5.2"),
- messages: [{ role: "user", content: "Hello!" }],
-});
-```
-
-## Chat Completions API
-
-`@tanstack/ai-openai` ships two text adapters that hit different OpenAI endpoints. `openaiText` (default) calls the Responses API (`/v1/responses`). `openaiChatCompletions` calls the older Chat Completions API (`/v1/chat/completions`).
-
-Pick whichever fits your wire format and feature needs:
-
-| | `openaiText` (Responses) | `openaiChatCompletions` (Chat Completions) |
-|---|---|---|
-| Endpoint | `/v1/responses` | `/v1/chat/completions` |
-| Reasoning summaries | Yes. With `reasoning` set, the summary text streams as `REASONING_*` events | No. `reasoning` goes out as `reasoning_effort`, and no text streams back |
-| Wire-format compatibility | OpenAI-only | Matches the older de-facto industry shape (Grok, Groq, OpenRouter, many local model servers) |
-| Structured output streaming | `text.format: { type: 'json_schema', strict: true }` + `stream: true` | `response_format: { type: 'json_schema', strict: true }` + `stream: true` |
-
-Use `openaiText` when you want reasoning-summary streaming or OpenAI-specific Responses features. Use `openaiChatCompletions` when you're migrating off a Chat-Completions-style provider, share request-building code with other Chat-Completions adapters in your stack, or want the more battle-tested wire format.
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { openaiChatCompletions } from "@tanstack/ai-openai";
+Put the key in the environment of your server:
-const stream = chat({
- adapter: openaiChatCompletions("gpt-5.2"),
- messages: [{ role: "user", content: "Hello!" }],
-});
+```bash
+OPENAI_API_KEY=sk-...
```
-With an explicit API key:
+`openaiText` and the other short factories read `OPENAI_API_KEY`. To pass the key in code, use the `create*` factory of the same adapter:
```typescript
-import { chat } from "@tanstack/ai";
-import { createOpenaiChatCompletions } from "@tanstack/ai-openai";
-
-const adapter = createOpenaiChatCompletions("gpt-5.2", process.env.OPENAI_API_KEY!, {
- // organization, baseURL, headers — all optional
-});
+import { createOpenaiChat } from "@tanstack/ai-openai";
-const stream = chat({
- adapter,
- messages: [{ role: "user", content: "Hello!" }],
-});
+const adapter = createOpenaiChat("gpt-6.1-sol", process.env.OPENAI_API_KEY!);
```
-Both adapters work the same with [Structured Outputs](../structured-outputs/overview), including `stream: true`, and accept the same `modelOptions` (temperature, top_p, max_tokens, stop, and more). Both take [`reasoning`](#reasoning). `openaiChatCompletions` cannot stream the thinking text.
-
-## Azure OpenAI
-
-Your OpenAI model runs as a deployment in Azure? Use `createAzureOpenaiText`. It calls the Azure Responses API and sends your key in the `api-key` header.
+## 3. Stream a chat from the server
```typescript
-import { chat, toServerSentEventsResponse } from "@tanstack/ai";
-import { createAzureOpenaiText } from "@tanstack/ai-openai";
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
export async function POST(request: Request) {
- const { messages } = await request.json();
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
const stream = chat({
- adapter: createAzureOpenaiText("gpt-5.6", process.env.AZURE_OPENAI_API_KEY!, {
- resourceName: "my-resource",
- deploymentName: "production-chat",
- }),
+ adapter: openaiText("gpt-6.1-sol"),
messages,
+ threadId,
+ runId,
});
return toServerSentEventsResponse(stream);
}
```
-Azure gets `production-chat` as the model. Your code still uses `gpt-5.6`. The client does not change: it reads this route like any other chat route.
-
-`reasoning` takes the levels of the OpenAI model name, here `gpt-5.6`. A model name that is not an OpenAI model, such as a bare deployment name, takes no `reasoning`. See [Reasoning](#reasoning).
-
-`createAzureOpenaiText` reads nothing from the environment. You pass the key, and you pass the endpoint as `resourceName` or `baseURL`.
-
-Rather keep it all in the environment? Use `azureOpenaiText`. Like `openaiText`, it reads the key from the environment:
+## 4. Render the chat
-```sh
-AZURE_OPENAI_API_KEY=your-key
-AZURE_OPENAI_RESOURCE_NAME=my-resource
-AZURE_OPENAI_DEPLOYMENT_NAME_MAP=gpt-5.6=production-chat
-```
+The client is the same for every provider. Point `useChat` at your route:
-```typescript
-import { azureOpenaiText } from "@tanstack/ai-openai";
+```tsx
+import { useState } from "react";
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
+
+export function Chat() {
+ const [input, setInput] = useState("");
+ const { messages, sendMessage } = useChat({
+ connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
+ });
-const adapter = azureOpenaiText("gpt-5.6");
+ return (
+
+ );
+}
```
-`azureOpenaiText` always reads the key from `AZURE_OPENAI_API_KEY`, and it throws when that is not set. For the other values, the config wins over the environment:
-
-- **Endpoint:** `baseURL`, then `resourceName`, then `AZURE_OPENAI_BASE_URL`, then `AZURE_OPENAI_RESOURCE_NAME`. You must set one of them.
-- **API version:** `apiVersion`, then `AZURE_OPENAI_API_VERSION`, then `v1`.
-- **Deployment:** `deploymentName`, then `deploymentNameMap`, then `AZURE_OPENAI_DEPLOYMENT_NAME_MAP`, then the model name. A `deploymentNameMap` in the config replaces the environment map. The two maps do not merge.
+For Vue, Solid, Svelte, Angular, and the other frameworks, see [Quick Start](../getting-started/quick-start#3-render-the-chat).
-An Azure resource URL always goes to the `/openai/v1` path. A proxy URL keeps its own path.
+You now have a streaming GPT chat. [Tools](../tools/tools) and [Structured Outputs](../structured-outputs/overview) work the same as with every adapter.
-## Basic Usage - Custom API Key
+## Images
```typescript
-import { chat } from "@tanstack/ai";
-import { createOpenaiChat } from "@tanstack/ai-openai";
+import { generateImage } from "@tanstack/ai";
+import { openaiImage } from "@tanstack/ai-openai";
-const adapter = createOpenaiChat("gpt-5.2", process.env.OPENAI_API_KEY!, {
- // ... your config options
+const result = await generateImage({
+ adapter: openaiImage("gpt-image-2.5-flare"),
+ prompt: "A futuristic cityscape at sunset",
+ threadId: "thread-1",
+ numberOfImages: 1,
+ size: "1024x1024",
+ modelOptions: {
+ quality: "high",
+ },
});
-const stream = chat({
- adapter,
- messages: [{ role: "user", content: "Hello!" }],
-});
+console.log(result.images);
```
-## Sign in with ChatGPT (BYOK)
+`quality` takes `low`, `medium`, `high`, or `auto`. The `gpt-image-2.5` models also take `xhigh` and `max`. See [Image Generation](../media/image-generation).
-Many of your users have a ChatGPT plan but no API key. Let them sign in with ChatGPT. The token bills their ChatGPT plan, not your account.
+## Speech
-The helpers in `@tanstack/ai-openai/siwc` put the access token into the `openai` [BYOK](../advanced/byok) slot. Your relay reads it like a pasted key.
-
-Before you start, check these limits:
-
-- OpenAI allows this sign-in only for open-source and locally hosted apps. For a hosted app, fill in the [interest form](https://openai.com/form/sign-in-with-chatgpt-interest/).
-- OpenAI redirects only to `127.0.0.1`. Something must answer on that address. Vite listens on `::1` by default. The `ts-react-chat` example runs a small script on `127.0.0.1:3000` that redirects to `localhost:3000`.
-- Keep the app on `localhost`, because passkey storage does not work on an IP address. The callback lands on `127.0.0.1`, then `completeChatGptSignIn` sends the browser back to `localhost`.
-- The callback page must be at `/auth/callback`.
-
-### 1. Add the sign-in button
-
-Call `startChatGptSignIn` from a click. `agentName` is your app name. OpenAI shows it on the consent screen.
+```typescript
+import { generateSpeech } from "@tanstack/ai";
+import { openaiSpeech } from "@tanstack/ai-openai";
-```tsx
-import { startChatGptSignIn } from "@tanstack/ai-openai/siwc";
+const result = await generateSpeech({
+ adapter: openaiSpeech("tts-1"),
+ text: "Hello, welcome to TanStack AI!",
+ threadId: "thread-1",
+ voice: "alloy",
+ format: "mp3",
+});
-export function ChatGptButton() {
- return (
- {
- void startChatGptSignIn({ agentName: "My App" });
- }}
- >
- Continue with ChatGPT
-
- );
-}
+// result.audio holds base64-encoded audio
+console.log(result.format); // "mp3"
```
-### 2. Finish on `/auth/callback`
+- Voices: `alloy`, `ash`, `ballad`, `coral`, `echo`, `fable`, `nova`, `onyx`, `sage`, `shimmer`, `verse`.
+- `modelOptions.instructions` sets how the voice speaks. It does not work with `tts-1` or `tts-1-hd`.
-Render this component on the `/auth/callback` route. It exchanges the code when the page loads. Then the user clicks to save.
-
-```tsx
-import { useEffect, useRef, useState } from "react";
-import {
- completeChatGptSignIn,
- saveChatGptSignIn,
-} from "@tanstack/ai-openai/siwc";
-import type { ChatGptSignIn } from "@tanstack/ai-openai/siwc";
-import { byok } from "./byok";
-
-export function ChatGptCallback() {
- const started = useRef(false);
- const [signIn, setSignIn] = useState(null);
- const [error, setError] = useState("");
-
- useEffect(() => {
- // The code works one time. Strict Mode runs effects two times.
- if (started.current) return;
- started.current = true;
- completeChatGptSignIn().then(setSignIn, (caught: unknown) => {
- setError(caught instanceof Error ? caught.message : "Sign-in failed");
- });
- }, []);
-
- if (error) return
{error}
;
- if (!signIn) return
Finishing sign-in...
;
- return (
- {
- void saveChatGptSignIn(byok, signIn);
- }}
- >
- Save ChatGPT sign-in
-
- );
-}
-```
+See [Text-to-Speech](../media/text-to-speech).
-The save needs a click because passkey storage shows a browser prompt. The refresh token goes into a second keyring slot. No send attaches that slot, so the refresh token stays in the browser.
+## Transcription
-### 3. Refresh the token
+```typescript
+import { generateTranscription } from "@tanstack/ai";
+import { openaiTranscription } from "@tanstack/ai-openai";
+import { audioFile } from "./audio";
-The access token lasts one hour. Call `refreshChatGptSignIn` on a timer:
+const result = await generateTranscription({
+ adapter: openaiTranscription("whisper-1"),
+ audio: audioFile, // File object or base64 string
+ language: "en",
+ threadId: "thread-1",
+});
-```tsx
-import { useEffect } from "react";
-import { refreshChatGptSignIn } from "@tanstack/ai-openai/siwc";
-import { byok } from "./byok";
-
-export function useChatGptRefresh() {
- useEffect(() => {
- const refresh = () => {
- refreshChatGptSignIn(byok).catch(console.error);
- };
- refresh();
- const timer = setInterval(refresh, 60_000);
- return () => clearInterval(timer);
- }, []);
-}
+console.log(result.text);
```
-The call does nothing until the token has less than five minutes left. If OpenAI rejects the refresh token, the call clears the sign-in. Then the user must sign in again.
+For speaker labels, use `gpt-4o-transcribe-diarize`. See [Speaker diarization](./openai-advanced#speaker-diarization).
-### 4. Read the token on the relay
-
-The relay code is the same as for a pasted key. Add `store: false`, because the ChatGPT route requires it.
+## Embeddings
```typescript
-import {
- chat,
- chatParamsFromRequest,
- toServerSentEventsResponse,
-} from "@tanstack/ai";
-import { createOpenaiChat } from "@tanstack/ai-openai";
-import { openaiByok } from "@tanstack/ai-openai/byok";
-import { byokMissing, getByokKey } from "@tanstack/ai/byok/server";
+import { embed } from "@tanstack/ai";
+import { openaiEmbedding } from "@tanstack/ai-openai";
-export async function POST(request: Request) {
- const params = await chatParamsFromRequest(request);
- const apiKey = getByokKey(request, openaiByok);
- if (!apiKey) return byokMissing(openaiByok);
+const result = await embed({
+ adapter: openaiEmbedding("text-embedding-3-small"),
+ input: ["a red guitar", "a blue drum kit"],
+});
- const stream = chat({
- adapter: createOpenaiChat("gpt-6-astra", apiKey),
- messages: params.messages,
- threadId: params.threadId,
- runId: params.runId,
- modelOptions: { store: false },
- });
- return toServerSentEventsResponse(stream);
-}
+console.log(result.embeddings[0]?.vector);
+console.log(result.usage?.promptTokens);
```
-`store: false` also works with an API key. A ChatGPT token does not start with `sk-`, so use that test if you must send different options.
-
-The ChatGPT route has these limits:
+Both models (`text-embedding-3-small` and `text-embedding-3-large`) take the top-level `dimensions` option to make the vectors shorter:
-- Use a model that the user's ChatGPT plan includes.
-- Do not send `temperature`, `top_p`, `max_output_tokens`, `metadata`, or `previous_response_id`.
-- Hosted tools do not work. This includes image generation, file search, code interpreter, computer use, and hosted MCP.
-- Audio input and transcription do not work.
+```typescript
+import { embed } from "@tanstack/ai";
+import { openaiEmbedding } from "@tanstack/ai-openai";
-Click **Continue with ChatGPT**, approve, save, then send a message. The relay calls OpenAI on the user's ChatGPT plan.
+const result = await embed({
+ adapter: openaiEmbedding("text-embedding-3-large"),
+ input: "a red guitar",
+ dimensions: 1024,
+});
+```
-The `ts-react-chat` example has this flow in its key dialog.
+See the [Embeddings guide](../embeddings.md).
-## Configuration
+## Summarization
```typescript
-import { createOpenaiChat, type OpenAITextConfig } from "@tanstack/ai-openai";
+import { summarize } from "@tanstack/ai";
+import { openaiSummarize } from "@tanstack/ai-openai";
-const config: Omit = {
- organization: "org-...", // Optional
- baseURL: "https://api.openai.com/v1", // Optional, for custom endpoints
-};
+const result = await summarize({
+ adapter: openaiSummarize("gpt-5-mini"),
+ text: "Your long text to summarize...",
+ threadId: "thread-1",
+ maxLength: 100,
+ style: "concise", // "concise" | "bullet-points" | "paragraph"
+});
-const adapter = createOpenaiChat("gpt-5.2", process.env.OPENAI_API_KEY!, config);
+console.log(result.summary);
```
-Need a header on each request, or a log of each model call? The chat adapters on this page support [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call).
+## Video
-### Tools that cannot use strict mode
+`openaiVideo` makes Sora videos with `sora-2` and `sora-2-pro`. Video generation is experimental. See [Video Generation](../media/video-generation).
-OpenAI holds tool arguments to the schema only when the tool is sent with `strict: true`. Some schemas cannot be strict, for example a schema that uses `$ref`, `z.any()`, or `z.record()`. The adapter sends these tools with `strict: false`, so the tool still works. The model can then return arguments that do not match the schema.
+## Realtime voice
-In development, the adapter logs a warning once for each of these tools:
+`openaiRealtime` and `openaiRealtimeToken` run a live voice chat. See [Realtime Voice Chat](../media/realtime-chat).
-```txt
-tool "lookup_user" sent with strict: false: schema uses $ref, which strict mode does not support
-```
+## Files
+
+`openaiFiles` uploads media to the OpenAI Files API, so a message can point to the file. See [Files API](../advanced/files-api).
+
+## Evaluate
-To get strict mode back, change the schema so that the reason goes away. If you accept a non-strict tool, turn the warning off in the adapter config:
+Sometimes you need an answer that your code can branch on, not chat text: a queue name, an urgency level, or a yes or no. Use `openaiDecider` with `decide()` to ask typed questions about one shared `state`. The adapter calls the OpenAI Decisions API (`/v1/decisions`):
```typescript
-import { createOpenaiChat } from "@tanstack/ai-openai";
+import { decide, choice, score, boolean } from "@tanstack/ai";
+import { openaiDecider } from "@tanstack/ai-openai";
+
+const ticket = {
+ subject: "Charged twice for the same invoice",
+ body: "Please refund the extra payment.",
+};
-const adapter = createOpenaiChat("gpt-6-astra", process.env.OPENAI_API_KEY!, {
- strictFallbackWarning: false,
+const result = await decide({
+ adapter: openaiDecider("gpt-6-luna"),
+ state: ticket,
+ questions: {
+ queue: choice({
+ instructions: "Which team should handle this ticket?",
+ options: {
+ billing: "Payments, invoices, refunds",
+ tech: "Bugs, outages, integrations",
+ sales: "Pricing, upgrades, new accounts",
+ },
+ }),
+ urgency: score({
+ instructions: "How urgent is this ticket?",
+ levels: ["low", "medium", "high"],
+ }),
+ refund: boolean({
+ instructions: "Is the customer asking for a refund?",
+ }),
+ },
});
-```
-The warning never runs when `NODE_ENV` is `production`. The same option works on every adapter built on the OpenAI API, for example Groq, Bedrock, and Vercel AI Gateway.
-
-## Example: Chat Completion
+console.log(result.queue.value); // "billing"
+console.log(result.urgency.value); // "medium"
+console.log(result.refund.value); // true
+console.log(result.meta.usage);
+```
-```typescript
-import { chat, toServerSentEventsResponse } from "@tanstack/ai";
-import { openaiText } from "@tanstack/ai-openai";
+`openaiDecider` reads `OPENAI_API_KEY`. To pass a key yourself, use `createOpenaiDecider("gpt-6-luna", "sk-...")`.
-export async function POST(request: Request) {
- const { messages } = await request.json();
+- `gpt-6-luna` is the only Decisions model.
+- An object `state` goes to OpenAI as JSON text.
+- If OpenAI refuses to answer a question, `decide()` throws an error with the name of that question.
+- `boolean()` questions work. The adapter adds the meaning of true and false to the instructions.
- const stream = chat({
- adapter: openaiText("gpt-5.2"),
- messages,
- });
+See the [Evaluate guide](../evaluate/evaluate) for the question helpers, the result shape, abort, and middleware.
- return toServerSentEventsResponse(stream);
-}
-```
+## Provider tools
-## Example: With Tools
+OpenAI runs some tools itself, such as web search and a code sandbox. Import them from `@tanstack/ai-openai/tools` and add them to `tools`:
```typescript
-import { chat, toServerSentEventsResponse, toolDefinition } from "@tanstack/ai";
+import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
-import { z } from "zod";
-
-const getWeatherDef = toolDefinition({
- name: "get_weather",
- description: "Get the current weather",
- inputSchema: z.object({
- location: z.string(),
- }),
-});
+import { webSearchTool } from "@tanstack/ai-openai/tools";
-const getWeather = getWeatherDef.server(async ({ location }) => {
- // Fetch weather data
- return { temperature: 72, conditions: "sunny" };
+const stream = chat({
+ adapter: openaiText("gpt-6.1-sol"),
+ messages: [{ role: "user", content: "What's new in AI this week?" }],
+ threadId: "thread-1",
+ tools: [webSearchTool({ type: "web_search" })],
});
+```
-export async function POST(request: Request) {
- const { messages } = await request.json();
+Tools that OpenAI runs:
- const stream = chat({
- adapter: openaiText("gpt-5.2"),
- messages,
- tools: [getWeather],
- });
+- `webSearchTool` and `webSearchPreviewTool`: search the web, with citations.
+- `fileSearchTool`: search your OpenAI vector stores.
+- `imageGenerationTool`: make images inside the chat.
+- `codeInterpreterTool`: run Python in a sandbox.
+- `mcpTool`: connect a remote MCP server.
- return toServerSentEventsResponse(stream);
-}
-```
+Tools that your app runs when the model asks:
+
+- `localShellTool` and `shellTool`: run shell commands.
+- `applyPatchTool`: apply a file diff.
+- `computerUseTool`: control a virtual desktop.
+- `customTool`: a Responses API tool with your own name and format.
+
+Each tool, with a full sample, is on [OpenAI: Advanced](./openai-advanced#provider-tools). For which models take which tool, see [Provider Tools](../tools/provider-tools.md).
+
+## Models and options
-## Model Options
+### Model options
-OpenAI supports various provider-specific options. Sampling parameters live here too — `temperature`, `top_p`, and `max_output_tokens` (the Responses API token-limit key) — rather than as root-level props on `chat()`:
+Provider options go in `modelOptions`. This includes the sampling options `temperature`, `top_p`, and `max_output_tokens`:
```typescript
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
const stream = chat({
- adapter: openaiText("gpt-5.2"),
+ adapter: openaiText("gpt-4.1"),
messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
modelOptions: {
temperature: 0.7,
max_output_tokens: 1000,
@@ -407,11 +343,16 @@ const stream = chat({
});
```
-> The `openaiChatCompletions` adapter targets `/v1/chat/completions`, where the token-limit key is `max_tokens` (not `max_output_tokens`). If you previously passed `temperature` / `topP` / `maxTokens` at the root of `chat()`, see [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options).
+- OpenAI rejects `temperature` and `top_p` on the reasoning models. `openaiText` drops the two options for these model ids:
+ - The `o` models (`o1`, `o3`, `o4-mini`, and so on).
+ - Every id that starts with `gpt-5`, except ids that end in `-chat-latest`.
+ - `codex-mini-latest`.
+- With `openaiChatCompletions`, the token limit is `max_tokens`.
+- Sampling options do not go at the root of `chat()`. See [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options).
-## Reasoning
+### Reasoning
-Want GPT to think harder, or skip it? Set `reasoning` on `chat()`:
+Want GPT to think harder, or not at all? Set `reasoning` on `chat()`:
```typescript
import { chat } from "@tanstack/ai";
@@ -420,11 +361,12 @@ import { openaiText } from "@tanstack/ai-openai";
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "Plan a database migration." }],
+ threadId: "thread-1",
reasoning: "xhigh",
});
```
-What goes on the wire:
+What goes to OpenAI:
- `openaiText` and `azureOpenaiText`: `reasoning: { effort, summary: "auto" }`. The summary text streams as thinking parts. `summary: false` drops the summary.
- `openaiChatCompletions`: `reasoning_effort`. No thinking text streams back.
@@ -444,796 +386,30 @@ The levels of each model:
| `gpt-5.2-chat-latest` | `medium` |
| `gpt-5-pro` | `high` |
-The other models, such as `gpt-4.1` and `gpt-4o`, take no `reasoning`.
-
-### Native compaction
-
-OpenAI can compact a long conversation on its side, with `POST /responses/compact`. The Responses adapters (`openaiText` and `createOpenaiChat`) have a `compact` method for it. Turn it on with the `native` option of `withCompaction`:
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { openaiText } from "@tanstack/ai-openai";
-import { withCompaction } from "@tanstack/ai-compaction";
-
-const adapter = openaiText("gpt-6.1-sol");
-
-const stream = chat({
- adapter,
- messages: [{ role: "user", content: "Hello!" }],
- middleware: [withCompaction({ maxTokens: 100_000, native: adapter })],
-});
-```
-
-- The result holds your user messages and one encrypted `compaction` item.
-- The adapter sends that item back as it is on each later request to the same model.
-- `openaiChatCompletions` has no `compact` method, so `withCompaction` uses its `strategy`.
-
-See [Let the provider compact](../advanced/compaction#let-the-provider-compact).
-
-### Answers on the next request
-
-OpenAI gives each answer item an `id` and a `phase`: `commentary` for text before a tool call, and `final_answer` for the answer. The Responses adapters (`openaiText` and `createOpenaiChat`) keep both in the assistant message, in `metadata.tanstack.responseItems`. When the same model gets that message again, for example on the next turn, the adapter sends each item back with its `id` and `phase`. Another model gets the plain text.
-
-### Prompt caching
-
-`chat()` sends `prompt_cache_key` by default, set to the `threadId` that you pass. OpenAI uses the key to send requests with the same start to the same cache. With `promptCache: 'long'`, it also asks for the long retention. See [Prompt Caching](../advanced/prompt-caching).
-
-Want your own key? Set it in `modelOptions`. Your value wins:
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { openaiText } from "@tanstack/ai-openai";
-
-const stream = chat({
- adapter: openaiText("gpt-6.1-sol"),
- messages: [{ role: "user", content: "Hello!" }],
- modelOptions: {
- prompt_cache_key: "acme-support",
- },
-});
-```
-
-On gpt-5.6 and later, `modelOptions.prompt_cache_options` wins over the automatic options. On earlier models, `modelOptions.prompt_cache_retention` wins over the automatic retention. `openaiChatCompletions` sends the key only to `api.openai.com` and its regional hosts (such as `eu.api.openai.com`), unless you pass `promptCache: 'long'`.
+The other models, such as `gpt-4.1` and `gpt-4o`, take no `reasoning`. See [Reasoning](../chat/reasoning).
-#### Tools and prompts added during a conversation
+### Models
-On `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6-astra`, `gpt-6-luna`, and `gpt-6-sol`, a tool or a system prompt that you add between model calls goes into the conversation. The cached start stays the same.
+Newest first. The package exports the full lists, for example `OPENAI_CHAT_MODELS`.
-What `openaiText` sends on these models:
+| Activity | Factory | Models |
+| --- | --- | --- |
+| Chat | `openaiText` | `gpt-6.1-sol`, `gpt-6-astra`, `gpt-6-sol`, `gpt-6-luna`, `gpt-5.6`, `gpt-5.5`, `gpt-4.1`, `gpt-4o`, the `o` models, and more |
+| Images | `openaiImage` | `gpt-image-2.5-flare`, `gpt-image-2.5-sunburst`, `gpt-image-2`, `gpt-image-1`, `gpt-image-1-mini`, `dall-e-3`, `dall-e-2` |
+| Speech | `openaiSpeech` | `tts-1`, `tts-1-hd`, `gpt-4o-audio-preview` |
+| Transcription | `openaiTranscription` | `whisper-1`, `gpt-4o-transcribe`, `gpt-4o-mini-transcribe`, `gpt-4o-transcribe-diarize` |
+| Embeddings | `openaiEmbedding` | `text-embedding-3-small`, `text-embedding-3-large` |
+| Video | `openaiVideo` | `sora-2`, `sora-2-pro` |
+| Evaluate | `openaiDecider` | `gpt-6-luna` |
-- `tools` and `instructions` keep the tools and the system prompts of the first call.
-- An added tool goes into `input` as `{ type: "additional_tools", role: "developer", tools: [...] }`.
-- An added system prompt goes into `input` as a `developer` message, at its place in the conversation.
-- With a provider tool such as `webSearchTool()`, `tools` is the full list for that request.
-
-The channels are on by default with OpenAI's own API. A custom `baseURL`, a custom `fetch`, or `OPENAI_BASE_URL` turns them off. Set `midConversationChannels` to choose:
-
-```typescript
-import { openaiText } from "@tanstack/ai-openai";
-
-export const fullLists = openaiText("gpt-6-astra", { midConversationChannels: false });
-
-export const throughProxy = openaiText("gpt-6-astra", {
- baseURL: "https://llm-proxy.example.com/v1",
- midConversationChannels: true,
-});
-```
+`adapter.inputModalities` lists the input kinds of the selected model. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts).
-If that endpoint sends the request on to OpenAI as it is, set `true`. See [Mid-Conversation Changes](../advanced/mid-conversation-changes).
+## Advanced
-## Summarization
-
-Summarize long text content:
-
-```typescript
-import { summarize } from "@tanstack/ai";
-import { openaiSummarize } from "@tanstack/ai-openai";
-
-const result = await summarize({
- adapter: openaiSummarize("gpt-5-mini"),
- text: "Your long text to summarize...",
- maxLength: 100,
- style: "concise", // "concise" | "bullet-points" | "paragraph"
-});
-
-console.log(result.summary);
-```
-
-## Embeddings
-
-Generate embedding vectors with the text-embedding-3 models:
-
-```typescript
-import { embed } from "@tanstack/ai";
-import { openaiEmbedding } from "@tanstack/ai-openai";
-
-const result = await embed({
- adapter: openaiEmbedding("text-embedding-3-small"),
- input: ["a red guitar", "a blue drum kit"],
-});
-
-console.log(result.embeddings[0]?.vector);
-console.log(result.usage?.promptTokens);
-```
-
-Both models support Matryoshka dimension reduction via the top-level `dimensions` option:
-
-```typescript
-import { embed } from "@tanstack/ai";
-import { openaiEmbedding } from "@tanstack/ai-openai";
-
-const result = await embed({
- adapter: openaiEmbedding("text-embedding-3-large"),
- input: "a red guitar",
- dimensions: 1024,
-});
-```
-
-See the [Embeddings guide](../embeddings.md) for the full API.
-
-## Image Generation
-
-Generate images:
-
-```typescript
-import { generateImage } from "@tanstack/ai";
-import { openaiImage } from "@tanstack/ai-openai";
-
-const result = await generateImage({
- adapter: openaiImage("gpt-image-2"),
- prompt: "A futuristic cityscape at sunset",
- numberOfImages: 1,
- size: "1024x1024",
-});
-
-console.log(result.images);
-```
-
-### Image Model Options
-
-```typescript
-import { generateImage } from "@tanstack/ai";
-import { openaiImage } from "@tanstack/ai-openai";
-
-const result = await generateImage({
- adapter: openaiImage("gpt-image-2"),
- prompt: "...",
- modelOptions: {
- quality: "high", // "high" | "medium" | "low" | "auto"
- },
-});
-```
-
-## Text-to-Speech
-
-Generate speech from text:
-
-```typescript
-import { generateSpeech } from "@tanstack/ai";
-import { openaiSpeech } from "@tanstack/ai-openai";
-
-const result = await generateSpeech({
- adapter: openaiSpeech("tts-1"),
- text: "Hello, welcome to TanStack AI!",
- voice: "alloy",
- format: "mp3",
-});
-
-// result.audio contains base64-encoded audio
-console.log(result.format); // "mp3"
-```
-
-### TTS Voices
-
-Available voices: `alloy`, `echo`, `fable`, `onyx`, `nova`, `shimmer`, `ash`, `ballad`, `coral`, `sage`, `verse`
-
-### TTS Model Options
-
-```typescript
-import { generateSpeech } from "@tanstack/ai";
-import { openaiSpeech } from "@tanstack/ai-openai";
-
-const result = await generateSpeech({
- adapter: openaiSpeech("tts-1-hd"),
- text: "High quality speech",
- modelOptions: {
- instructions: "Speak slowly and clearly.", // voice instructions (not supported by tts-1/tts-1-hd)
- },
-});
-```
-
-## Transcription
-
-Transcribe audio to text:
-
-```typescript
-import { generateTranscription } from "@tanstack/ai";
-import { openaiTranscription } from "@tanstack/ai-openai";
-import { audioFile } from "./audio";
-
-const result = await generateTranscription({
- adapter: openaiTranscription("whisper-1"),
- audio: audioFile, // File object or base64 string
- language: "en",
-});
-
-console.log(result.text); // Transcribed text
-```
-
-### Transcription Model Options
-
-```typescript
-import { generateTranscription } from "@tanstack/ai";
-import { openaiTranscription } from "@tanstack/ai-openai";
-import { audioFile } from "./audio";
-
-const result = await generateTranscription({
- adapter: openaiTranscription("whisper-1"),
- audio: audioFile,
- responseFormat: "verbose_json",
- prompt: "Technical terms: API, SDK",
- modelOptions: {
- temperature: 0,
- timestamp_granularities: ["word", "segment"],
- },
-});
-
-// Access the transcribed text
-console.log(result.text);
-```
-
-### Speaker Diarization
-
-Use `gpt-4o-transcribe-diarize` for speaker-labeled transcripts:
-
-```typescript
-import { generateTranscription } from "@tanstack/ai";
-import { openaiTranscription } from "@tanstack/ai-openai";
-import { meetingAudioFile } from "./audio";
-
-const result = await generateTranscription({
- adapter: openaiTranscription("gpt-4o-transcribe-diarize"),
- audio: meetingAudioFile,
- modelOptions: {
- known_speaker_names: ["agent", "customer"],
- known_speaker_references: [
- "data:audio/wav;base64,...",
- "data:audio/wav;base64,...",
- ],
- },
-});
-
-for (const segment of result.segments ?? []) {
- console.log(segment.speaker, segment.start, segment.end, segment.text);
-}
-```
-
-When no response format is specified, `gpt-4o-transcribe-diarize` requests default to `response_format: "diarized_json"` and `chunking_strategy: "auto"`; passing a top-level `responseFormat` of `"json"` or `"text"` opts out of speaker segments. `known_speaker_names` and `known_speaker_references` must be provided together (up to 4, matching lengths). OpenAI does not support `prompt`, `include`, or `timestamp_granularities` with diarized transcription.
-
-## Evaluate
-
-Sometimes you need an answer that your code can branch on, not chat text. Examples are a queue name, an urgency level, or a yes or no.
-Use `openaiDecider` with `decide()` to ask typed questions about one shared `state`. The adapter calls the OpenAI Decisions API (`/v1/decisions`):
-
-```typescript
-import { decide, choice, score, boolean } from "@tanstack/ai";
-import { openaiDecider } from "@tanstack/ai-openai";
-
-const ticket = {
- subject: "Charged twice for the same invoice",
- body: "Please refund the extra payment.",
-};
-
-const result = await decide({
- adapter: openaiDecider("gpt-6-luna"),
- state: ticket,
- questions: {
- queue: choice({
- instructions: "Which team should handle this ticket?",
- options: {
- billing: "Payments, invoices, refunds",
- tech: "Bugs, outages, integrations",
- sales: "Pricing, upgrades, new accounts",
- },
- }),
- urgency: score({
- instructions: "How urgent is this ticket?",
- levels: ["low", "medium", "high"],
- }),
- refund: boolean({
- instructions: "Is the customer asking for a refund?",
- }),
- },
-});
-
-console.log(result.queue.value); // "billing"
-console.log(result.urgency.value); // "medium"
-console.log(result.refund.value); // true
-console.log(result.meta.usage);
-```
-
-`openaiDecider` reads `OPENAI_API_KEY` from the environment. To pass a key yourself, use `createOpenaiDecider("gpt-6-luna", "sk-...")`.
-
-Good to know:
-
-- `gpt-6-luna` is the only Decisions model.
-- An object `state` goes to OpenAI as JSON text.
-- If OpenAI refuses to answer a question, `decide()` throws an error with the name of that question.
-- `boolean()` criteria work. The adapter adds the true and false meanings to the instructions.
-
-See the [Evaluate guide](../evaluate/evaluate) for question helpers, the result shape, abort, and middleware.
-
-## Environment Variables
-
-Set your API key in environment variables:
-
-```bash
-OPENAI_API_KEY=sk-...
-```
-
-## API Reference
-
-Every factory pair follows the same shape: the short factory (`openaiText`, `openaiImage`, …) reads `OPENAI_API_KEY` from the environment, while the `create*` variant takes an explicit API key. Both take `model` as the first argument.
-
-### `openaiText(model, config?)`
-
-Creates an OpenAI text adapter against the Responses API (`/v1/responses`) using `OPENAI_API_KEY` from the environment.
-
-`adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts).
-
-**Parameters:**
-
-- `model` - OpenAI chat model id (e.g. `"gpt-5.2"`, `"gpt-4o-mini"`)
-- `config?.organization` - Organization ID (optional)
-- `config?.baseURL` - Custom base URL (optional)
-
-### `createOpenaiChat(model, apiKey, config?)`
-
-Creates an OpenAI text adapter (Responses API) with an explicit API key.
-
-### `openaiChatCompletions(model, config?)`
-
-Creates an OpenAI text adapter that targets `/v1/chat/completions` instead of the Responses API. See [Chat Completions API](#chat-completions-api) for when to use this over `openaiText`.
-
-### `createOpenaiChatCompletions(model, apiKey, config?)`
-
-Creates an OpenAI chat-completions adapter with an explicit API key.
-
-### `openaiSummarize(model, config?)` / `createOpenaiSummarize(model, apiKey, config?)`
-
-Creates an OpenAI summarization adapter.
-
-### `openaiImage(model, config?)` / `createOpenaiImage(model, apiKey, config?)`
-
-Creates an OpenAI image generation adapter (DALL-E, gpt-image).
-
-### `openaiSpeech(model, config?)` / `createOpenaiSpeech(model, apiKey, config?)`
-
-Creates an OpenAI text-to-speech adapter.
-
-### `openaiTranscription(model, config?)` / `createOpenaiTranscription(model, apiKey, config?)`
-
-Creates an OpenAI transcription adapter for Whisper, GPT-4o transcription, and GPT-4o diarized transcription models.
-
-### `openaiVideo(model, config?)` / `createOpenaiVideo(model, apiKey, config?)`
-
-Creates an OpenAI video generation adapter (Sora). _Experimental._
-
-### `openaiDecider(model, config?)` / `createOpenaiDecider(model, apiKey, config?)`
-
-Creates an OpenAI evaluate adapter for `decide()`. See [Evaluate](#evaluate) for usage.
-
-### `openaiRealtime(...)` / `openaiRealtimeToken(...)`
-
-Realtime voice adapters. See [Realtime Voice Chat](../media/realtime-chat) for usage.
-
-## Next Steps
-
-- [Getting Started](../getting-started/quick-start) - Learn the basics
-- [Tools Guide](../tools/tools) - Learn about tools
-- [Other Adapters](./anthropic) - Explore other providers
-
-## Provider Tools
-
-OpenAI exposes several native tools beyond user-defined function calls.
-Import them from `@tanstack/ai-openai/tools` and pass them into
-`chat({ tools: [...] })`.
-
-> For the full concept, a comparison matrix, and type-gating details, see
-> [Provider Tools](../tools/provider-tools.md).
-
-### `webSearchTool`
-
-Enables the model to run a web search and return grounded results with
-citations. Pass a `WebSearchToolConfig` object (typed from the OpenAI SDK)
-to configure the tool.
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { openaiText } from "@tanstack/ai-openai";
-import { webSearchTool } from "@tanstack/ai-openai/tools";
-
-const stream = chat({
- adapter: openaiText("gpt-5.2"),
- messages: [{ role: "user", content: "What's new in AI this week?" }],
- tools: [webSearchTool({ type: "web_search" })],
-});
-```
-
-**Supported models:** GPT-4o, GPT-5, and Responses API-capable models. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
-
-### `webSearchPreviewTool`
-
-The preview variant of web search with additional options for controlling
-search context size and user location. Use this when you want fine-grained
-control over the search context sent to the model.
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { openaiText } from "@tanstack/ai-openai";
-import { webSearchPreviewTool } from "@tanstack/ai-openai/tools";
-
-const stream = chat({
- adapter: openaiText("gpt-5.2"),
- messages: [{ role: "user", content: "Latest news about TypeScript" }],
- tools: [
- webSearchPreviewTool({
- type: "web_search_preview_2025_03_11",
- search_context_size: "high",
- }),
- ],
-});
-```
-
-**Supported models:** GPT-4o and above. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
-
-When the model uses hosted web search, the assistant tool-call part includes
-`metadata.providerExecuted: true` and the normalized `metadata.sources` array.
-OpenAI's raw search call and URL citations stay under `metadata.openai`. The
-agent loop does not execute this call in your application, and the adapter
-replays the raw response items on the next turn.
-
-### `fileSearchTool`
-
-Searches OpenAI vector stores that you have pre-populated, letting the model
-retrieve relevant document chunks. Provide the `vector_store_ids` to search
-and optionally limit results with `max_num_results` (1–50).
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { openaiText } from "@tanstack/ai-openai";
-import { fileSearchTool } from "@tanstack/ai-openai/tools";
-
-const stream = chat({
- adapter: openaiText("gpt-5.2"),
- messages: [{ role: "user", content: "What does the handbook say about PTO?" }],
- tools: [
- fileSearchTool({
- type: "file_search",
- vector_store_ids: ["vs_abc123"],
- max_num_results: 5,
- }),
- ],
-});
-```
-
-**Supported models:** GPT-4o and above. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
-
-### `imageGenerationTool`
-
-Allows the model to generate images inline during a conversation using
-DALL-E/GPT-Image. Pass quality, size, and style options via the config object.
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { openaiText } from "@tanstack/ai-openai";
-import { imageGenerationTool } from "@tanstack/ai-openai/tools";
-
-const stream = chat({
- adapter: openaiText("gpt-5.2"),
- messages: [{ role: "user", content: "Draw a logo for my app" }],
- tools: [
- imageGenerationTool({
- quality: "high",
- size: "1024x1024",
- }),
- ],
-});
-```
-
-**Supported models:** GPT-5 and GPT-Image-capable models. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
-
-### `codeInterpreterTool`
-
-Gives the model a sandboxed Python execution environment. The `container`
-field configures the execution environment; pass the full
-`CodeInterpreterToolConfig` object.
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { openaiText } from "@tanstack/ai-openai";
-import { codeInterpreterTool } from "@tanstack/ai-openai/tools";
-
-const stream = chat({
- adapter: openaiText("gpt-5.2"),
- messages: [{ role: "user", content: "Analyse this CSV and plot a chart" }],
- tools: [
- codeInterpreterTool({ type: "code_interpreter", container: { type: "auto" } }),
- ],
-});
-```
-
-**Supported models:** GPT-4o and above. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
-
-### `mcpTool`
-
-Connects the model to a remote MCP (Model Context Protocol) server, exposing
-all its capabilities as callable tools. Provide either `server_url` or
-`connector_id` — not both.
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { openaiText } from "@tanstack/ai-openai";
-import { mcpTool } from "@tanstack/ai-openai/tools";
-
-const stream = chat({
- adapter: openaiText("gpt-5.2"),
- messages: [{ role: "user", content: "List my GitHub issues" }],
- tools: [
- mcpTool({
- server_url: "https://mcp.example.com",
- server_label: "github",
- }),
- ],
-});
-```
-
-**Supported models:** GPT-4o and above. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
-
-### `computerUseTool`
-
-The model can look at a screen and use the mouse and the keyboard. Your app runs each action and sends back a screenshot. `createComputerUse` from `@tanstack/ai-computer-use` does this on a Playwright browser or an E2B desktop. Each thread gets its own machine:
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { openaiText } from "@tanstack/ai-openai";
-import { createComputerUse } from "@tanstack/ai-computer-use";
-import { playwrightComputer } from "@tanstack/ai-computer-playwright";
-
-declare const threadId: string;
-
-const computerUse = createComputerUse({
- driver: playwrightComputer({ startUrl: "https://tanstack.com" }),
-});
-
-const stream = chat({
- adapter: openaiText("gpt-6.1-sol"),
- messages: [{ role: "user", content: "Find the TanStack AI docs" }],
- threadId,
- tools: computerUse.tools,
- middleware: [computerUse.middleware],
- interrupts: [computerUse.interrupt],
-});
-```
-
-The adapter sends the `computer` tool to OpenAI as its native computer tool.
-
-- For the setup, the threads, and how to close the machines, see [Computer use](../computer-use/computer-use.md).
-- For the approval of OpenAI safety checks, see [Safety checks](../computer-use/safety-checks.md).
-- For your own machine, see [Custom driver](../computer-use/custom-driver.md).
-
-#### The raw tool
-
-`computerUseTool` from `@tanstack/ai-openai/tools` is the native tool with no `execute`. Use it when your app runs the actions itself:
-
-```typescript
-import { computerUseTool } from "@tanstack/ai-openai/tools";
-
-const computer = computerUseTool({ type: "computer" });
-```
-
-Add an `execute` that runs the actions and returns a screenshot as an image part. The input of each call has two fields:
-
-- `actions`: the actions to run, in order, as OpenAI sends them. For example `[{ type: "click", button: "left", x: 120, y: 340 }, { type: "type", text: "ada@example.com" }]`.
-- `pending_safety_checks`: the safety checks that OpenAI attached to the actions.
-
-The adapter sends the first image of the result back as the screenshot. When the call does not fail, the adapter also tells OpenAI that the safety checks are accepted. When the call fails, they stay unaccepted.
-
-The `computer-use-preview` model takes the older tool type. Give it the display size and the environment. Each call then has one `action` field in place of `actions`:
-
-```typescript
-import { computerUseTool } from "@tanstack/ai-openai/tools";
-
-const previewTool = computerUseTool({
- type: "computer_use_preview",
- display_width: 1024,
- display_height: 768,
- environment: "browser",
-});
-```
-
-**Supported models:** `type: "computer"` works with the models that list computer use, for example `gpt-6.1-sol`. `type: "computer_use_preview"` works with `computer-use-preview`. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
-
-### `localShellTool`
-
-The model returns a `local_shell` tool call. Your app runs the command. Then your app sends the output back on the next request. `localShellTool()` takes no arguments. Add it to `tools`.
-
-Match `toolName` on `TOOL_CALL_START`. Read `input` on the `TOOL_CALL_END` with the same `toolCallId`. `input.command` is the command. `input.env` is the environment variables.
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { openaiText } from "@tanstack/ai-openai";
-import { localShellTool } from "@tanstack/ai-openai/tools";
-
-const stream = chat({
- adapter: openaiText("gpt-5.6"),
- messages: [{ role: "user", content: "Run the test suite and summarise failures" }],
- tools: [localShellTool()],
-});
-
-let localShellCallId: string | undefined;
-for await (const chunk of stream) {
- if (chunk.type === "TOOL_CALL_START" && chunk.toolName === "local_shell") {
- localShellCallId = chunk.toolCallId;
- }
- if (chunk.type === "TOOL_CALL_END" && chunk.toolCallId === localShellCallId) {
- console.log(chunk.input);
- }
-}
-```
-
-Send the command output as the tool result. Use `{ output: string }`, or a string. The next request sends `local_shell_call_output`.
-
-**Supported models:** GPT-5.x and other agent-capable models. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
-
-### `shellTool`
-
-`shellTool()` gives the model a shell. A container environment runs on OpenAI. A local environment runs in your app.
-
-When your app must run the commands, pass `environment: { type: "local" }`. Match `toolName` on `TOOL_CALL_START`. Read `input` on the `TOOL_CALL_END` with the same `toolCallId`. `input.commands` is the command list. `input.timeout_ms` is the time limit. `input.max_output_length` is the output limit.
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { openaiText } from "@tanstack/ai-openai";
-import { shellTool } from "@tanstack/ai-openai/tools";
-
-const stream = chat({
- adapter: openaiText("gpt-5.6"),
- messages: [{ role: "user", content: "Count lines in all JS files" }],
- tools: [shellTool({ environment: { type: "local" } })],
-});
-
-let shellCallId: string | undefined;
-for await (const chunk of stream) {
- if (chunk.type === "TOOL_CALL_START" && chunk.toolName === "shell") {
- shellCallId = chunk.toolCallId;
- }
- if (chunk.type === "TOOL_CALL_END" && chunk.toolCallId === shellCallId) {
- console.log(chunk.input);
- }
-}
-```
-
-Return one result per command:
-
-```typescript
-const shellResult = {
- output: [
- {
- stdout: "12\n",
- stderr: "",
- outcome: { type: "exit" as const, exit_code: 0 },
- },
- ],
-};
-```
-
-`outcome.type` is `"exit"` or `"timeout"`. The next request sends `shell_call_output`. If the same response has no `shell_call_output`, a shell call with no environment comes back to your app.
-
-A container environment does not come back as a tool call. Pass `environment` to attach that container and hosted skills.
-
-**Supported models:** GPT-5.x and other agent-capable models. Responses API
-only — Chat Completions does not support the shell tool. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
-
-#### Attaching hosted skills
-
-Pass `environment.skills` to load provider-managed skill bundles into the
-shell's container (Responses API only).
-
-```typescript
-import { chat, toServerSentEventsResponse } from "@tanstack/ai";
-import { openaiText } from "@tanstack/ai-openai";
-import { shellTool } from "@tanstack/ai-openai/tools";
-
-export async function POST(request: Request) {
- const { messages } = await request.json();
-
- const stream = chat({
- adapter: openaiText("gpt-5.2"),
- messages,
- tools: [
- shellTool({
- environment: {
- type: "container_auto",
- skills: [
- { type: "skill_reference", skill_id: "skill_abc", version: "2" },
- ],
- },
- }),
- ],
- });
-
- return toServerSentEventsResponse(stream);
-}
-```
-
-For the full reference — skill shape, `version` string format, and the
-Anthropic equivalent — see [Provider Skills](../tools/provider-skills.md).
-
-### `applyPatchTool`
-
-The model returns an `apply_patch` tool call. Your app applies the diff. Then your app sends the result back. `applyPatchTool()` takes no arguments. Add it to `tools`.
-
-Match `toolName` on `TOOL_CALL_START`. Read `input` on the `TOOL_CALL_END` with the same `toolCallId`. `input.operation` is one file change. `operation.type` is `create_file`, `update_file`, or `delete_file`. `operation.path` is the file path. `create_file` and `update_file` also include `operation.diff`.
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { openaiText } from "@tanstack/ai-openai";
-import { applyPatchTool } from "@tanstack/ai-openai/tools";
-
-const stream = chat({
- adapter: openaiText("gpt-5.6"),
- messages: [{ role: "user", content: "Fix the import paths in src/index.ts" }],
- tools: [applyPatchTool()],
-});
-
-let patchCallId: string | undefined;
-for await (const chunk of stream) {
- if (chunk.type === "TOOL_CALL_START" && chunk.toolName === "apply_patch") {
- patchCallId = chunk.toolCallId;
- }
- if (chunk.type === "TOOL_CALL_END" && chunk.toolCallId === patchCallId) {
- console.log(chunk.input);
- }
-}
-```
-
-Send this object as the tool result. The next request sends `apply_patch_call_output`.
-
-```typescript
-const patchResult = {
- status: "completed" as const,
- output: "Updated src/index.ts",
-};
-```
-
-If the patch fails, set `status` to `"failed"`. Put the error text in `output`. Keep `applyPatchTool()` in `tools` on the next request. A patch-only turn has no text. If you use `chat({ stream: false })`, `text` is empty. Read the `apply_patch` tool call from `chunks` with the same checks as the loop above.
-
-**Supported models:** GPT-5.x and other agent-capable models. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
-
-### `customTool`
-
-Defines a custom Responses API tool with an explicit name, description, and
-format. Use this when none of the structured tool types fits your use case.
-Unlike branded provider tools, `customTool` returns a plain `Tool` and is
-accepted by any chat model.
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { openaiText } from "@tanstack/ai-openai";
-import { customTool } from "@tanstack/ai-openai/tools";
-
-const stream = chat({
- adapter: openaiText("gpt-5.2"),
- messages: [{ role: "user", content: "Look up order #1234" }],
- tools: [
- customTool({
- type: "custom",
- name: "lookup_order",
- description: "Look up the status of a customer order by order ID",
- }),
- ],
-});
-```
+These topics are on [OpenAI: Advanced](./openai-advanced):
-**Supported models:** all Responses API models. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+- The Chat Completions API (`openaiChatCompletions`) and Azure OpenAI.
+- Sign in with ChatGPT, so users pay with their ChatGPT plan.
+- Prompt caching, native compaction, and tools added during a conversation.
+- Every provider tool, with a full sample.
+- The configuration options and the API reference.
diff --git a/docs/adapters/opencode.md b/docs/adapters/opencode.md
index d7cf003ca1..37b6367ab3 100644
--- a/docs/adapters/opencode.md
+++ b/docs/adapters/opencode.md
@@ -2,7 +2,7 @@
title: OpenCode
id: opencode-adapter
order: 14
-description: "Use OpenCode as a chat backend in TanStack AI — agent harness with local tool execution, token-level streaming, stateful sessions, and tool bridging via @tanstack/ai-opencode."
+description: "Run the OpenCode agent inside a sandbox from TanStack AI, with any model that OpenCode supports. It edits files and runs commands in a real repo and streams each step."
keywords:
- tanstack ai
- opencode
@@ -13,122 +13,182 @@ keywords:
- adapter
---
-The OpenCode adapter runs [OpenCode](https://opencode.ai) as a chat backend, driving it over its local HTTP server (`@opencode-ai/sdk`). Unlike HTTP provider adapters, this is a **harness adapter**: OpenCode runs its own agent loop and executes its own tools — shell commands, file reads and edits, search — locally on your server. Each `chat()` call runs one full harness turn; assistant text and reasoning stream as true token-level deltas, and the harness's tool activity streams back as already-resolved tool-call events your UI can render.
+The OpenCode adapter starts `opencode serve` in a sandbox and streams text and tool steps to `chat()`. The agent edits files and runs commands in a repo.
-The adapter buffers each message part until it knows the parent message role. It streams assistant parts and skips user parts.
+You need:
-> **Server-only.** The adapter spawns (or attaches to) an `opencode serve` process, so it only works in a Node.js server environment — never in the browser. Treat it like giving OpenCode a shell on the machine it runs on, and configure permissions accordingly.
+- A sandbox provider. The samples use Docker, so Docker must run on your machine.
+- An API key for the model provider that OpenCode uses. The samples use OpenAI, so they need `OPENAI_API_KEY`.
+- The `opencode` CLI inside the sandbox. A setup step in the sandbox installs it.
-## Installation
+## 1. Install
-react: @tanstack/ai-opencode
-vue: @tanstack/ai-opencode
-solid: @tanstack/ai-opencode
-svelte: @tanstack/ai-opencode
-preact: @tanstack/ai-opencode
-angular: @tanstack/ai-opencode
-vanilla: @tanstack/ai-opencode
-octane: @tanstack/ai-opencode
+react: @tanstack/ai-opencode @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
+vue: @tanstack/ai-opencode @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
+solid: @tanstack/ai-opencode @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
+svelte: @tanstack/ai-opencode @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
+preact: @tanstack/ai-opencode @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
+angular: @tanstack/ai-opencode @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
+vanilla: @tanstack/ai-opencode @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
+octane: @tanstack/ai-opencode @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
-The `opencode` CLI must be installed and its providers authenticated on the host:
+## 2. Set the API key
-
-
-react: install -g opencode-ai
-vue: install -g opencode-ai
-solid: install -g opencode-ai
-svelte: install -g opencode-ai
-preact: install -g opencode-ai
-angular: install -g opencode-ai
-vanilla: install -g opencode-ai
-octane: install -g opencode-ai
-
-
-
-Then authenticate:
+Put the key in the environment of your server:
```bash
-opencode auth login
+OPENAI_API_KEY=sk-...
```
-A runnable demo lives at [`examples/sandbox-cloudflare`](https://github.com/TanStack/ai/tree/main/examples/sandbox-cloudflare) — pick Claude Code, Codex, or Grok Build in the UI, with session resume, the harness tool timeline, and tool bridging, wired into a TanStack Start app on Workers. For the same wiring on plain Node with durable, refresh-surviving runs (Claude Code on Docker), see [`examples/sandbox-web`](https://github.com/TanStack/ai/tree/main/examples/sandbox-web) — swapping in this adapter is a one-line change (`src/sandbox-agent.ts`).
+OpenCode reads the key from the environment of its process. The sandbox in step 3 passes it in as a workspace secret. This adapter has no `authMode` option.
-## Models
+## 3. Run the agent from the server
-OpenCode is provider-agnostic: it resolves any `provider/model` id its configured providers support. Address models as `provider/model` (the adapter splits on the first `/`):
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { opencodeText } from "@tanstack/ai-opencode";
+Define the sandbox once. It clones your repo into a Docker container and installs the `opencode` CLI:
-const stream = chat({
- adapter: opencodeText("anthropic/claude-sonnet-4-5", {
- directory: "/path/to/project",
- permissionMode: "acceptEdits",
+```ts
+// sandbox.ts
+import {
+ createSecrets,
+ defineSandbox,
+ defineWorkspace,
+ githubRepo,
+} from "@tanstack/ai-sandbox";
+import { dockerSandbox } from "@tanstack/ai-sandbox-docker";
+
+export const sandbox = defineSandbox({
+ id: "opencode-agent",
+ // The host talks to `opencode serve` on port 4096, so publish it.
+ provider: dockerSandbox({ image: "node:22", publishPorts: [4096] }),
+ workspace: defineWorkspace({
+ source: githubRepo({ repo: "owner/app" }),
+ setup: ["npm install -g opencode-ai --include=optional && opencode --version"],
+ secrets: createSecrets({
+ OPENAI_API_KEY: process.env.OPENAI_API_KEY ?? "",
+ }),
}),
- messages: [{ role: "user", content: "Fix the failing test in utils.test.ts" }],
});
```
-## Configuration
+Then pass the adapter and the sandbox to `chat()` in your route:
+
+```ts
+// api/agent.ts
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
+import { opencodeText } from "@tanstack/ai-opencode";
+import { withSandbox } from "@tanstack/ai-sandbox";
+import { sandbox } from "./sandbox";
+
+export async function POST(request: Request) {
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
+
+ const stream = chat({
+ adapter: opencodeText("openai/gpt-5.2", {
+ permissionMode: "acceptEdits",
+ }),
+ messages,
+ threadId,
+ runId,
+ middleware: [withSandbox(sandbox)],
+ });
+
+ return toServerSentEventsResponse(stream);
+}
+```
-| Option | Description |
-| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| `directory` | Working directory for the harness session. Defaults to `process.cwd()`. |
-| `baseUrl` | Attach to an already-running `opencode serve` (e.g. `http://127.0.0.1:4096`) instead of spawning a new server per turn. |
-| `hostname` | Hostname for the spawned server. Defaults to the SDK default (`127.0.0.1`). |
-| `port` | Port for the spawned server. Defaults to the SDK default (`4096`). |
-| `permissionMode` | `'default'` (bridged tools run, everything else that prompts is rejected), `'acceptEdits'` (also auto-approves file edits), or `'bypassPermissions'` (allow all). |
-| `onPermissionRequest` | Custom permission handler; replaces the default policy entirely. |
-| `config` | Extra OpenCode config merged with the adapter's MCP and permission config. |
+- Replace `owner/app` with your repo.
+- Model ids are `provider/model`. The adapter splits the id at the first `/`.
+- `permissionMode: "acceptEdits"` lets the agent edit files. See [Permissions](#permissions).
-Per-call overrides — `sessionId`, `permissionMode`, `directory` — go through `modelOptions`.
+## 4. Render the agent's work
-## Reasoning
+The client is the same as for any chat. Render the text, and show each tool that the agent ran:
-Set `reasoning` on `chat()`. The adapter writes it into the model's `options` in the OpenCode server config:
+```tsx
+import { useState } from "react";
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
-```typescript
-import { chat } from "@tanstack/ai";
-import { opencodeText } from "@tanstack/ai-opencode";
+export function CodingAgent() {
+ const [input, setInput] = useState("");
+ const { messages, sendMessage } = useChat({
+ connection: fetchServerSentEvents("/api/agent"),
+ threadId: "support-chat",
+ });
-const stream = chat({
- adapter: opencodeText("anthropic/claude-sonnet-4-5"),
- messages: [{ role: "user", content: "Refactor the auth module" }],
- reasoning: { level: "high", budgetTokens: 8000 },
-});
+ return (
+
+ );
+}
```
-- Claude models get `thinking` with a token budget. `off` disables thinking. `anthropic/claude-sonnet-4-5` and `opencode/claude-sonnet-4-5` take `off` to `high`. `anthropic/claude-opus-4-5` takes `low`, `medium`, `high`.
-- The other models get `reasoningEffort`, plus `reasoningSummary: "auto"` when the summary is on. `openai/gpt-5.2` takes `off`, `low` to `xhigh`. The `gpt-5.1-codex` models take `low`, `medium`, `high`.
+Ask it "Fix the failing test in utils.test.ts". The text streams token by token, and you see a line for each tool the agent used: `read`, `edit`, `bash`, and more. The agent edited the files inside the container.
+
+## How it works
+
+- `opencodeText` declares that it needs a sandbox. `withSandbox(sandbox)` gives it one. Without a sandbox, `chat()` fails at the call site.
+- The adapter starts `opencode serve` inside the sandbox and connects to it over HTTP with the `@opencode-ai/sdk` client.
+- Each `chat()` call runs one full agent turn. OpenCode runs its own loop and its own tools.
+- Text and reasoning stream token by token. The tools of the agent stream back as `tool-call` parts with the results attached. Your code does not run them.
+- The adapter holds each message part until it knows the role of the message. It streams assistant parts and skips user parts.
+- OpenCode can send a tool part before the tool input is ready. The adapter waits for the next update, so each tool call streams with its arguments.
+- The running todo plan of the agent arrives as an `opencode.todo` custom event.
+
+To learn more about sandboxes, see [Sandboxes](../sandbox/overview).
## Permissions
-OpenCode asks for permission before mutating files or running commands. A headless server has no one to answer those prompts, so the adapter applies a policy automatically — it never hangs a turn:
+OpenCode asks for permission before it edits files or runs commands. A headless server has no person to answer, so the adapter answers each request from `permissionMode`. A turn never waits for an answer.
-- **`'default'`** — bridged TanStack tools run; anything else that would prompt (edits, shell, web fetch) is rejected.
-- **`'acceptEdits'`** — additionally auto-approves file-mutation requests (edit / write / patch).
-- **`'bypassPermissions'`** — approves everything. Only use this against a sandbox or scratch directory.
+| Mode | What runs |
+| --- | --- |
+| `'default'` | Your bridged tools run. Every other request that asks (edits, shell, web fetch) is rejected. |
+| `'acceptEdits'` | Also allows file edits (edit, write, patch). |
+| `'bypassPermissions'` | Allows every request. Use it only in a sandbox or a scratch directory. |
-Provide `onPermissionRequest` to implement your own policy (e.g. allow-list specific commands).
+To write your own rules, for example to allow only some commands, pass `onPermissionRequest`. It replaces the default policy.
-## Stateful Sessions
+## Resume a session
-OpenCode sessions are stateful — the harness keeps the full working context (files read, commands run, conclusions reached) between turns. The adapter surfaces the session id of every fresh run as a custom stream event named `opencode.session-id`; thread it back via `modelOptions.sessionId` to resume. When resuming, only the latest user message is sent — the harness already holds the prior context.
+OpenCode keeps its working context between turns: the files it read, the commands it ran, and what it found. The adapter sends the session id of each new run as a custom event named `opencode.session-id`. Send it back in `modelOptions.sessionId` to continue the session.
-Server endpoint:
+On the server, read the id from the request:
-```typescript
+```ts
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from "@tanstack/ai";
import { opencodeText } from "@tanstack/ai-opencode";
+import { withSandbox } from "@tanstack/ai-sandbox";
+import { sandbox } from "./sandbox";
export async function POST(request: Request) {
const params = await chatParamsFromRequest(request);
@@ -140,11 +200,11 @@ export async function POST(request: Request) {
: undefined;
const stream = chat({
- adapter: opencodeText("anthropic/claude-sonnet-4-5", {
- directory: "/path/to/project",
- permissionMode: "acceptEdits",
- }),
+ adapter: opencodeText("openai/gpt-5.2", { permissionMode: "acceptEdits" }),
messages: params.messages,
+ threadId: params.threadId,
+ runId: params.runId,
+ middleware: [withSandbox(sandbox)],
modelOptions: { sessionId },
});
@@ -152,20 +212,20 @@ export async function POST(request: Request) {
}
```
-Client (React) — capture the session id from the custom event and send it back on subsequent requests:
+On the client, keep the id from the custom event and send it with each request:
-```typescript
+```tsx
import { useState } from "react";
-import { useChat } from "@tanstack/ai-react";
-import { fetchServerSentEvents } from "@tanstack/ai-client";
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
-function CodingAssistant() {
+export function useCodingAgent() {
const [sessionId, setSessionId] = useState(undefined);
- const { messages, sendMessage } = useChat({
- connection: fetchServerSentEvents("/api/chat", () => ({
+ return useChat({
+ connection: fetchServerSentEvents("/api/agent", () => ({
body: { sessionId },
})),
+ threadId: "support-chat",
onCustomEvent: (name, value) => {
if (
name === "opencode.session-id" &&
@@ -178,28 +238,22 @@ function CodingAssistant() {
}
},
});
-
- // ... render messages; harness tool activity (bash, edit, read, ...)
- // arrives as regular tool-call parts with results.
}
```
-Sessions live on the server that ran them, so resuming only works against the same server instance (or a shared `baseUrl`).
+- On a resumed session, the adapter sends only the latest user message. The agent already has the rest.
+- OpenCode saves the session inside the sandbox. Resume works only when the next call runs in the same sandbox. See [Lifecycle & Snapshots](../sandbox/lifecycle).
-## Tools
+## Give the agent your own tools
-Two kinds of tools flow through this adapter:
+The agent has its own tools for files and the shell. It cannot reach your database or your secrets. Pass your server tools to `chat()`, and the adapter bridges them into the sandbox:
-1. **Built-in harness tools** are executed by OpenCode itself and stream back as tool-call events with results already attached: `bash`, `edit`, `write`, `read`, `grep`, and the agent's running todo plan (surfaced as an `opencode.todo` custom event). Your code never executes them.
-
-2. **Your TanStack tools** are bridged *into* the harness: the adapter starts a short-lived Streamable-HTTP MCP server on `127.0.0.1` for the duration of the turn and registers it with OpenCode. Define tools as usual with `toolDefinition().server()`; tool-call events come back under the names you registered (OpenCode prefixes MCP tools `tanstack_…` internally, which the adapter strips).
-
-OpenCode can send a tool part in `pending` state before the tool input is ready. The adapter waits for the next update, so a tool call always streams with its arguments.
-
-```typescript group=tools
+```ts group=tools
import { z } from "zod";
import { chat, toolDefinition } from "@tanstack/ai";
import { opencodeText } from "@tanstack/ai-opencode";
+import { withSandbox } from "@tanstack/ai-sandbox";
+import { sandbox } from "./sandbox";
const lookupTicket = toolDefinition({
name: "lookup_ticket",
@@ -210,24 +264,33 @@ const lookupTicket = toolDefinition({
});
const stream = chat({
- adapter: opencodeText("anthropic/claude-sonnet-4-5"),
+ adapter: opencodeText("openai/gpt-5.2"),
messages: [{ role: "user", content: "What's the status of ticket T-123?" }],
+ threadId: "thread-1",
tools: [lookupTicket],
+ middleware: [withSandbox(sandbox)],
});
```
-**Client-side and approval-gated tools are not supported.** The harness executes tools inside a live process, which cannot pause across HTTP requests to wait for a browser round-trip or a human approval. Passing a tool without a server `execute()` implementation — or one marked `needsApproval` — fails fast with a descriptive error. Run those tools outside the harness with a regular provider adapter.
+- Each tool runs on your server, with its closures and secrets. The result goes back into the sandbox.
+- OpenCode names the tool `tanstack_lookup_ticket` internally. The adapter removes the prefix, so the events use the name you gave.
+- Your bridged tools always run, in every permission mode.
+- Only server tools work here. A client tool has no server `execute()`, so the agent cannot run it.
+
+For how the bridge reaches your server from each provider, see [Sandbox Tools](../sandbox/tools).
## Tool choice
-You want OpenCode to see just one of your tools, or none of them. Pass `toolChoice` to `chat()`:
+You want OpenCode to see only one of your tools, or none of them. Pass `toolChoice` to `chat()`:
-```typescript group=tools
+```ts group=tools
const onlyLookup = chat({
- adapter: opencodeText("anthropic/claude-opus-4-5"),
+ adapter: opencodeText("openai/gpt-5.2"),
messages: [{ role: "user", content: "What's the status of ticket T-123?" }],
+ threadId: "thread-1",
tools: [lookupTicket],
toolChoice: { type: "tool", name: "lookup_ticket" },
+ middleware: [withSandbox(sandbox)],
});
```
@@ -244,70 +307,145 @@ Each value except `'auto'` logs a warning the first time an adapter instance get
For the values on other adapters, see [Choose when the model calls a tool](../tools/tools#choose-when-the-model-calls-a-tool).
-## Structured Output
+## Structured output
-Pass `outputSchema` on `chat()`. OpenCode has no native schema flag. The adapter adds the JSON Schema to the prompt and parses the last assistant text (markdown fences are stripped). Tool activity still streams. The object arrives as `structured-output.complete`.
+You want a typed report after the agent reads the repo. Pass `outputSchema` on the same `chat()` call:
```ts
-import { chat } from "@tanstack/ai"
-import { opencodeText } from "@tanstack/ai-opencode"
-import { defineSandbox, withSandbox } from "@tanstack/ai-sandbox"
-import { dockerSandbox } from "@tanstack/ai-sandbox-docker"
-import { z } from "zod"
+import { chat } from "@tanstack/ai";
+import { opencodeText } from "@tanstack/ai-opencode";
+import { withSandbox } from "@tanstack/ai-sandbox";
+import { z } from "zod";
+import { sandbox } from "./sandbox";
const Report = z.object({
summary: z.string(),
filesChanged: z.array(z.string()),
-})
-
-const sandbox = defineSandbox({
- id: "repo-report",
- provider: dockerSandbox({ image: "node:22" }),
-})
+});
const report = await chat({
- adapter: opencodeText("anthropic/claude-opus-4-5"),
+ adapter: opencodeText("openai/gpt-5.2"),
messages: [{ role: "user", content: "Review this repo." }],
+ threadId: "thread-1",
outputSchema: Report,
middleware: [withSandbox(sandbox)],
-})
+});
-report.summary
+console.log(report.summary);
```
-This path parses JSON from the last assistant message. If extract-only is the job, use a model adapter such as `@tanstack/ai-openai`.
-
On the client, pass the same schema to `useChat` and read `final`. `partial` stays empty until the end.
```tsx
-import { fetchServerSentEvents, useChat } from "@tanstack/ai-react"
-import { z } from "zod"
+import { fetchServerSentEvents, useChat } from "@tanstack/ai-react";
+import { z } from "zod";
const Report = z.object({
summary: z.string(),
filesChanged: z.array(z.string()),
-})
+});
-function ReportView() {
+export function ReportView() {
const { final, isLoading } = useChat({
connection: fetchServerSentEvents("/api/repo-report"),
+ threadId: "support-chat",
outputSchema: Report,
- })
+ });
- if (isLoading) return
The agent is inspecting the repo.
- if (!final) return null
- return
{final.summary}
+ if (isLoading) return
The agent is inspecting the repo.
;
+ if (!final) return null;
+ return
{final.summary}
;
}
```
-Full walkthrough, including the client: [Harness Agents](../structured-outputs/harnesses).
+- OpenCode has no schema flag. The adapter adds the JSON Schema to the prompt.
+- The adapter parses the last assistant text as JSON. It removes markdown fences first.
+- Tool activity still streams. The object arrives as `structured-output.complete`.
+
+Do you only need JSON from a prompt, with no repo? Use a model adapter such as `@tanstack/ai-openai`. For the full walkthrough, see [Harness Agents](../structured-outputs/harnesses).
+
+## Models and options
+
+### Models
+
+| Model | Reasoning levels | What `reasoning` sets |
+| --- | --- | --- |
+| `anthropic/claude-opus-4-5` | `low`, `medium`, `high` | `thinking` with a token budget |
+| `anthropic/claude-sonnet-4-5`, `opencode/claude-sonnet-4-5` | `off`, `minimal`, `low`, `medium`, `high` | `thinking` with a token budget |
+| `openai/gpt-5.2` | `off`, `low`, `medium`, `high`, `xhigh` | `reasoningEffort` |
+| `openai/gpt-5.1-codex`, `opencode/gpt-5.1-codex` | `low`, `medium`, `high` | `reasoningEffort` |
+| `google/gemini-3-pro-preview` | none | none |
+
+The package exports this list as `OPENCODE_MODELS`. The adapter also takes any other `provider/model` id that your OpenCode providers support.
+
+### Reasoning
+
+Set `reasoning` on `chat()`. The adapter writes it into the `options` of the model in the OpenCode server config:
+
+```ts
+import { chat } from "@tanstack/ai";
+import { opencodeText } from "@tanstack/ai-opencode";
+import { withSandbox } from "@tanstack/ai-sandbox";
+import { sandbox } from "./sandbox";
+
+const stream = chat({
+ adapter: opencodeText("anthropic/claude-sonnet-4-5"),
+ messages: [{ role: "user", content: "Refactor the auth module" }],
+ threadId: "thread-1",
+ reasoning: { level: "high", budgetTokens: 8000 },
+ middleware: [withSandbox(sandbox)],
+});
+```
+
+- Claude models get `thinking` with a token budget. `off` turns thinking off.
+- The other models get `reasoningEffort`, plus `reasoningSummary: "auto"` when the summary is on.
+
+See [Reasoning](../chat/reasoning).
+
+### Adapter options
+
+Pass these as the second argument of `opencodeText`:
+
+| Option | What it does |
+| --- | --- |
+| `directory` | The working directory inside the sandbox. Default `/workspace`. |
+| `port` | The port of `opencode serve` inside the sandbox. Default `4096`. On Docker, publish it with `publishPorts`. |
+| `hostname` | The hostname that the server binds in the sandbox. Default `0.0.0.0`. |
+| `permissionMode` | `'default'` (default), `'acceptEdits'`, or `'bypassPermissions'`. See [Permissions](#permissions). |
+| `onPermissionRequest` | Your own permission handler. It replaces the default policy. |
+
+### Per-call options
+
+Pass these in `modelOptions` on `chat()`:
+
+- `sessionId`: resume a session. See [Resume a session](#resume-a-session).
+- `permissionMode`: the permission mode for this call.
+- `directory`: the working directory for this call.
+
+## Advanced
+
+### Ports on other providers
+
+The host connects to `opencode serve` over HTTP, so the sandbox must expose the port:
+
+- Docker: pass `publishPorts: [4096]` to `dockerSandbox()`.
+- Vercel: expose the port with the `ports` option of the provider.
+- Local-process and Daytona: nothing to do.
+
+### No run journal
+
+OpenCode does not write NDJSON to stdout, so this adapter writes no run journal, also on a durable run. See [Harnesses](../sandbox/harnesses#harness-output-can-go-to-a-journal).
+
+### Limitations
-## Limitations
+- **A sandbox is required.** Every call needs `withSandbox(...)`.
+- **The agent owns the loop.** TanStack agent-loop strategies and per-iteration middleware do not apply inside a turn.
+- **No sampling options.** Options such as `temperature` do not exist here.
+- **Sessions stay in the sandbox.** Resume needs the same sandbox.
+- **Cold starts.** Each call starts the OpenCode server in the sandbox and stops it after the turn. So the first token arrives later than with an HTTP adapter.
+- **No `wrapFetch`.** This adapter ignores [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call), because the OpenCode server sends the model requests.
-- **Server-only (Node).** The adapter spawns or attaches to an `opencode serve` process.
-- **The harness owns the agent loop.** TanStack's agent-loop strategies and per-iteration middleware don't apply inside a harness turn.
-- **No sampling controls.** `temperature`-style options don't exist here.
-- **Sessions are server-local.** Resume requires hitting the same server instance (or a shared `baseUrl`).
-- **Cold starts.** Spawning a server per turn adds first-token latency; point the adapter at a long-lived `baseUrl` to avoid it.
+### Example apps
-This adapter ignores [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call), because the harness process sends the model requests.
+- [`examples/sandbox-cloudflare`](https://github.com/TanStack/ai/tree/main/examples/sandbox-cloudflare): a coding agent at the edge, with session resume, the tool timeline, and tool bridging.
+- [`examples/sandbox-web`](https://github.com/TanStack/ai/tree/main/examples/sandbox-web): Claude Code on Docker, with durable runs that continue after a page refresh. To use OpenCode there, change the adapter in `src/sandbox-agent.ts`.
diff --git a/docs/adapters/openrouter-advanced.md b/docs/adapters/openrouter-advanced.md
new file mode 100644
index 0000000000..1342012d9a
--- /dev/null
+++ b/docs/adapters/openrouter-advanced.md
@@ -0,0 +1,653 @@
+---
+title: "OpenRouter: Advanced"
+id: openrouter-advanced
+description: "Advanced OpenRouter setup in TanStack AI: retries, the Responses adapter, provider routing, prompt caching, tools with structured output, cost tracking, video, Sign in with OpenRouter, and the API reference."
+keywords:
+ - tanstack ai
+ - openrouter
+ - provider routing
+ - retries
+ - prompt caching
+ - cost tracking
+ - sign in with openrouter
+ - adapter
+---
+
+This page has the details of the OpenRouter adapter. To get a first chat working, start at [OpenRouter](./openrouter).
+
+## Configuration
+
+```typescript
+import { createOpenRouterText } from "@tanstack/ai-openrouter";
+
+const adapter = createOpenRouterText(
+ "openai/gpt-6.1-sol",
+ process.env.OPENROUTER_API_KEY!,
+ {
+ serverURL: "https://openrouter.ai/api/v1", // Optional
+ httpReferer: "https://your-app.com", // Optional, for rankings
+ appTitle: "Your App Name", // Optional, for rankings
+ },
+);
+```
+
+Need a header on each request, or a log of each model call? Both text adapters support [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). If you set an `httpClient`, each wrapped request still goes through it.
+
+## Retry rate limits
+
+A busy upstream provider can reply with HTTP 429. By default, the adapter retries only 5XX errors. To retry rate limits too, add `"429"` to `retryCodes`:
+
+```typescript
+import { createOpenRouterText } from "@tanstack/ai-openrouter";
+
+const adapter = createOpenRouterText(
+ "openai/gpt-6.1-sol",
+ process.env.OPENROUTER_API_KEY!,
+ {
+ retryConfig: {
+ strategy: "backoff",
+ backoff: {
+ initialInterval: 500,
+ maxInterval: 60000,
+ exponent: 1.5,
+ maxElapsedTime: 120000,
+ },
+ retryConnectionErrors: true,
+ },
+ retryCodes: ["429", "5XX"],
+ },
+);
+```
+
+- `retryCodes`: the HTTP status codes to retry. `"5XX"` matches every 5xx code.
+- `retryConfig`: how long to wait between tries. If the response has a `Retry-After` header, the SDK waits that long.
+
+Now a 429 waits, then retries, and the chat continues with the next response.
+
+## Retry an error in the stream
+
+Most provider errors come as an HTTP status, and the SDK retries them as shown above. But OpenRouter can send status 200 first. Then a provider error comes as an error chunk in the stream, and the SDK cannot retry it.
+
+To retry that error, pass `retry` to `chat()`:
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { createOpenRouterText } from "@tanstack/ai-openrouter";
+
+const adapter = createOpenRouterText(
+ "openai/gpt-6.1-sol",
+ process.env.OPENROUTER_API_KEY!,
+ { retryCodes: ["429", "5XX"] },
+);
+
+const stream = chat({
+ adapter,
+ messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
+ retry: { maxRetries: 3 },
+});
+```
+
+When `chat()` retries:
+
+- Only for an error code in `retryCodes`. The default is 5XX only, so add `"429"` to retry a rate limit.
+- Only when the call streamed no output yet. An error after part of the answer reaches you as a `RUN_ERROR`.
+- Not when `retryConfig` has `strategy: "none"`.
+- Only with the Chat Completions adapter (`openRouterText`). The Responses adapter does not do this.
+
+The wait is the `initialInterval` of `retryConfig` (default 500 ms). It is the same for each try and does not grow. See [Let `chat()` retry for you](../chat/stream-events#let-chat-retry-for-you).
+
+## Chat Completions vs Responses (beta)
+
+OpenRouter has two OpenAI-compatible wire formats. The package has one adapter for each:
+
+| Adapter | Endpoint | Status | When to use |
+| --- | --- | --- | --- |
+| `openRouterText` | `/v1/chat/completions` | Stable | The default for almost everything. The most models and tools. |
+| `openRouterResponsesText` | `/v1/responses` | Beta | The OpenAI Responses request and response shape, with more multi-turn state on OpenAI-style models. |
+
+The wire format is how your app talks to OpenRouter. It does not limit which provider answers. Both adapters work with any model that OpenRouter has (`anthropic/...`, `google/...`, `meta-llama/...`, and more).
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { openRouterResponsesText } from "@tanstack/ai-openrouter";
+
+const stream = chat({
+ adapter: openRouterResponsesText("anthropic/claude-sonnet-5.5"),
+ messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
+});
+```
+
+Limits of the Responses adapter while it is in beta:
+
+- Function tools work. The OpenRouter server tools (web search, web fetch) do not. For those, use `openRouterText`.
+- The Chat Completions endpoint covers more providers and features. If you are not sure, use `openRouterText`.
+
+## Provider preferences
+
+`modelOptions.provider` sets which upstream providers can serve the request, and in which order. The adapter sends it to OpenRouter as it is. This sample pins two providers and turns off fallbacks:
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { openRouterText } from "@tanstack/ai-openrouter";
+
+const stream = chat({
+ adapter: openRouterText("meta-llama/llama-4-maverick"),
+ messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
+ modelOptions: {
+ provider: {
+ order: ["groq", "together"], // provider slugs, in priority order
+ allowFallbacks: false, // fail instead of routing elsewhere
+ },
+ },
+});
+```
+
+Provider slugs are the lowercase ids on the model pages of OpenRouter, for example `"anthropic"`, `"openai"`, or `"groq"`.
+
+Fields that limit which providers can serve the request:
+
+- `only` and `ignore`: an allow list or a deny list of provider slugs. OpenRouter merges them with the provider settings of your account for this request.
+- `requireParameters`: send the request only to providers that support every parameter in it. Without it, OpenRouter sends each provider only the parameters it supports, and drops the rest with no error. So a `responseFormat` or a sampling option can be ignored.
+- `quantizations`: only providers that serve the model at the given quantization levels, for example `"fp8"` or `"int4"`.
+- `dataCollection`: `"deny"` uses only providers that do not collect user data.
+- `zdr`: `true` uses only zero-data-retention endpoints.
+
+Fields that set the order and the price:
+
+- `sort`: `"price"`, `"throughput"`, or `"latency"`, or an object with `by` and `partition`. It applies when `order` is not set. It turns off load balancing.
+- `maxPrice`: the highest price you accept. It takes `prompt` and `completion` in USD for each million tokens. It also takes `request` (price for each request) and `image` (price for each image) where a provider has them. OpenRouter skips providers above the limit.
+
+> [!NOTE]
+> If you stream with `requireParameters: true`, also set `modelOptions.streamOptions: { includeUsage: false }`. Otherwise the adapter sends `stream_options`, and OpenRouter returns a 404 for providers that do not support that parameter.
+
+## Model variants
+
+`variant` adds the `:variant` suffix of OpenRouter to the model id. The adapter does not send it in the request body:
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { openRouterText } from "@tanstack/ai-openrouter";
+
+const stream = chat({
+ adapter: openRouterText("deepseek/deepseek-v4-pro"),
+ messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
+ modelOptions: {
+ variant: "free", // requests deepseek/deepseek-v4-pro:free
+ },
+});
+```
+
+The values are `"free"`, `"nitro"`, `"online"`, `"exacto"`, `"extended"`, and `"thinking"`. The OpenRouter catalog sets which variants a model has.
+
+## Plugins
+
+`plugins` turns on the request plugins of OpenRouter. Each entry has an `id`, plus the options of that plugin:
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { openRouterText } from "@tanstack/ai-openrouter";
+
+const stream = chat({
+ adapter: openRouterText("openai/gpt-6-astra"),
+ messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
+ modelOptions: {
+ plugins: [{ id: "web", maxResults: 5 }],
+ },
+});
+```
+
+Plugin ids include `web`, `file-parser`, `response-healing`, `moderation`, and `auto-router`. For the options of each plugin, see the OpenRouter documentation. Web fetch is a server tool (`openrouter:web_fetch`), not a plugin. For web search and fetch as tools that the model can call, see [Provider tools](#provider-tools).
+
+## Session and metadata
+
+The adapter sends these fields to OpenRouter with no change:
+
+- `user`: a unique id for the user.
+- `sessionId`: the sticky routing key. Requests with the same `sessionId` go to the same provider, which gives more prompt-cache hits. OpenRouter also groups them for observability.
+- `metadata`: up to 16 key-value pairs.
+
+## Prompt caching
+
+By default, `chat()` sets `sessionId` to the cache key, which is the `threadId` that you pass. So one conversation stays on one provider and reads its cache.
+
+- `anthropic/*` models also get cache markers on the system message, the last tool, and the last message.
+- `promptCache: 'long'` asks for the 1-hour cache.
+- `promptCache: 'none'` sends nothing.
+
+Your own `modelOptions.sessionId` wins. If you add a cache marker of your own, the adapter adds no automatic markers to that request. See [Prompt Caching](../advanced/prompt-caching).
+
+## JSON mode
+
+Some OpenRouter models reject strict JSON schema. For these models, `chat({ outputSchema })` sends `json_object` and adds your schema to the system prompt.
+
+To send JSON mode to any other model, set `modelOptions.responseFormat`:
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { openRouterText } from "@tanstack/ai-openrouter";
+import { z } from "zod";
+
+const person = await chat({
+ adapter: openRouterText("openai/gpt-5.6-sol"),
+ messages: [{ role: "user", content: "Return Ada Lovelace, age 36." }],
+ threadId: "thread-1",
+ modelOptions: { responseFormat: { type: "json_object" } },
+ outputSchema: z.object({ name: z.string(), age: z.number() }),
+});
+```
+
+Read [When strict JSON schema is rejected](../structured-outputs/overview.md#when-strict-json-schema-is-rejected).
+
+## Tools and structured output in one call
+
+You can pass both `tools` and `outputSchema` on one `chat()` call. For some upstream models, OpenRouter returns the typed object in that same streaming request. Then the engine does not make a second call to get the final object.
+
+The rules:
+
+- This happens only when every model that can get the request is in `OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS`.
+- Each model sync makes this set from the OpenRouter catalog. It holds every chat model whose `supported_parameters` include `structured_outputs`, `tools`, and `tool_choice`. Examples are Claude 4.5 and later, Gemini 2.5 and later, GPT-4o and later, Grok 4, DeepSeek V3 and later, and Llama 3.1 and later.
+- A model that OpenRouter does not flag, such as `anthropic/claude-opus-4.1`, uses two calls.
+- If a fallback in `modelOptions.models` is not in the set, OpenRouter uses two calls.
+- Routing suffixes such as `:nitro` do not change the result.
+
+Import the set from `@tanstack/ai-openrouter/model-meta` to check a model before you send:
+
+```typescript
+import { OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS } from "@tanstack/ai-openrouter/model-meta";
+
+console.log(OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS.has("openai/gpt-5.5")); // true
+```
+
+Both text adapters attach the schema on this path. The server with the Chat Completions adapter:
+
+```typescript
+import { chat, toServerSentEventsResponse, toolDefinition } from "@tanstack/ai";
+import { openRouterText } from "@tanstack/ai-openrouter";
+import { z } from "zod";
+
+const getWeather = toolDefinition({
+ name: "get_weather",
+ description: "Get the current weather",
+ inputSchema: z.object({ location: z.string() }),
+}).server(async ({ location }) => {
+ return { temperature: 72, conditions: "sunny", location };
+});
+
+const AnswerSchema = z.object({
+ summary: z.string(),
+ location: z.string(),
+});
+
+export async function POST(request: Request) {
+ const { messages, threadId } = await request.json();
+
+ const stream = chat({
+ adapter: openRouterText("openai/gpt-5.5"),
+ messages,
+ threadId,
+ tools: [getWeather],
+ outputSchema: AnswerSchema,
+ stream: true,
+ });
+
+ return toServerSentEventsResponse(stream);
+}
+```
+
+The server with the Responses adapter has the same `tools` and `outputSchema`:
+
+```typescript
+import { chat, toServerSentEventsResponse, toolDefinition } from "@tanstack/ai";
+import { openRouterResponsesText } from "@tanstack/ai-openrouter";
+import { z } from "zod";
+
+const getWeather = toolDefinition({
+ name: "get_weather",
+ description: "Get the current weather",
+ inputSchema: z.object({ location: z.string() }),
+}).server(async ({ location }) => {
+ return { temperature: 72, conditions: "sunny", location };
+});
+
+const AnswerSchema = z.object({
+ summary: z.string(),
+ location: z.string(),
+});
+
+export async function POST(request: Request) {
+ const { messages, threadId } = await request.json();
+
+ const stream = chat({
+ adapter: openRouterResponsesText("openai/gpt-5.5"),
+ messages,
+ threadId,
+ tools: [getWeather],
+ outputSchema: AnswerSchema,
+ stream: true,
+ });
+
+ return toServerSentEventsResponse(stream);
+}
+```
+
+The client does not change. `useChat({ outputSchema })` still reads `partial` and `final`:
+
+```tsx
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
+import { z } from "zod";
+
+const AnswerSchema = z.object({
+ summary: z.string(),
+ location: z.string(),
+});
+
+export function useAnswer() {
+ const { sendMessage, partial, final } = useChat({
+ connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
+ outputSchema: AnswerSchema,
+ });
+
+ return { sendMessage, partial, final };
+}
+```
+
+See [Structured Outputs with tools](../structured-outputs/with-tools) for the event order, and [Middleware](../advanced/middleware) for how the `structuredOutput` phase works on this path.
+
+To try this in a browser, run `examples/ts-react-chat` and open `/generations/openrouter-combined`. The page shows the tool call, the typed object, and the call counts of the adapter. `structuredOutputStream` must stay at 0.
+
+## Provider tools
+
+OpenRouter runs web search and web fetch for any chat model. Import the tools from `@tanstack/ai-openrouter/tools`. For how provider tools compare across adapters, see [Provider Tools](../tools/provider-tools.md).
+
+### `webSearchTool`
+
+`webSearchTool` adds web search to any OpenRouter chat model. It takes the `WebSearchConfig` of OpenRouter:
+
+- `engine`: `auto`, `native`, `exa`, `firecrawl`, `parallel`, or `perplexity`.
+- `maxResults` and `maxTotalResults`: the number of results.
+- `allowedDomains` and `excludedDomains`: which sites can be in the results.
+- `searchContextSize` and `userLocation`: more control of the search.
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { openRouterText } from "@tanstack/ai-openrouter";
+import { webSearchTool } from "@tanstack/ai-openrouter/tools";
+
+const stream = chat({
+ adapter: openRouterText("openai/gpt-6.1-sol"),
+ messages: [{ role: "user", content: "What's new in AI this week?" }],
+ threadId: "thread-1",
+ tools: [
+ webSearchTool({
+ engine: "exa",
+ maxResults: 5,
+ allowedDomains: ["arxiv.org", "openai.com"],
+ }),
+ ],
+});
+```
+
+Supported models: all OpenRouter chat models. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+
+### `webFetchTool`
+
+`webFetchTool` lets any OpenRouter chat model read the full content of a URL that the model picks. It takes the `WebFetchServerToolConfig` of OpenRouter:
+
+- `engine`: `auto` (the default), `native`, `openrouter`, `exa`, `firecrawl`, or `parallel`.
+- `maxContentTokens`: how much page content the model gets.
+- `maxUses`: how many fetches the model can make in one request.
+- `allowedDomains` and `blockedDomains`: which URLs the model can fetch.
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { openRouterText } from "@tanstack/ai-openrouter";
+import { webFetchTool } from "@tanstack/ai-openrouter/tools";
+
+const stream = chat({
+ adapter: openRouterText("openai/gpt-6.1-sol"),
+ messages: [
+ { role: "user", content: "Summarize https://example.com/article" },
+ ],
+ threadId: "thread-1",
+ tools: [
+ webFetchTool({
+ engine: "openrouter",
+ maxContentTokens: 4000,
+ allowedDomains: ["example.com"],
+ }),
+ ],
+});
+```
+
+> [!NOTE]
+> The `native` engine uses the fetch of the upstream provider, for example the Anthropic `web_fetch` on Claude models. Native fetch is not the same on each provider, so it can ignore `allowedDomains` and `blockedDomains`. For the same behavior on all models, use `openrouter`, `exa`, or `firecrawl`.
+
+Supported models: all OpenRouter chat models. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+
+## Cost tracking
+
+OpenRouter reports the real cost of each request in the streamed response. The adapter puts it on the last `RUN_FINISHED` event:
+
+- `usage.cost`: the cost of the request.
+- `usage.costDetails`: the breakdown of that cost from OpenRouter.
+
+TanStack AI does not calculate this cost from token counts. It comes from OpenRouter, so it includes routing, fallback providers, BYOK, and cached-token prices. For the meaning and units of the fields, see [Usage Accounting](https://openrouter.ai/docs/use-cases/usage-accounting) in the OpenRouter docs.
+
+```typescript ignore
+import { chat, type RunFinishedEvent, type StreamChunk } from "@tanstack/ai";
+import { openRouterText } from "@tanstack/ai-openrouter";
+
+function isRunFinished(chunk: StreamChunk): chunk is RunFinishedEvent {
+ return "finishReason" in chunk;
+}
+
+for await (const chunk of chat({
+ adapter: openRouterText("openai/gpt-6.1-sol"),
+ messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
+})) {
+ if (isRunFinished(chunk)) {
+ console.log("cost:", chunk.usage?.cost);
+ console.log("breakdown:", chunk.usage?.costDetails);
+ }
+}
+```
+
+- Middleware gets the same `usage`, with `cost` and `costDetails`, in the `onUsage` and `onFinish` hooks.
+- If OpenRouter reports no cost, the fields are not there, and the stream completes as usual.
+- Both `openRouterText` and `openRouterResponsesText` fill in the cost when OpenRouter returns it.
+
+How usage works when a call fails:
+
+- The `structuredOutputStream()` method of `openRouterText` puts the usage that it got on its last `RUN_ERROR` event. This covers parse failures, cut-off responses, empty responses, and SDK errors.
+- When `chat()` uses this path, middleware `onChunk` gets the error event, also when the promise rejects.
+- The `onUsage` hook reports successful calls. Each adapter call reports usage on one last event.
+- A reported `completionTokensDetails.reasoningTokens` value of `0` stays zero. A missing count stays missing.
+- An error before any usage arrives has no `usage` field. If the consumer stops reading the stream, it cannot get later events.
+
+## Video generation
+
+`openRouterVideo` uses the async video API of OpenRouter (`POST /api/v1/videos`). You get Seedance, Veo 3.1, Wan, Kling, and more with your one OpenRouter key. Video generation is experimental.
+
+Like every TanStack AI video adapter, it creates a job, then you poll the job. OpenRouter returns the finished video as a stream, not a URL. Persistence stores the stream and sets `url`, so pass the same options to both calls.
+
+The server:
+
+```typescript
+import { generateVideo, getVideoJobStatus } from "@tanstack/ai";
+import { openRouterVideo } from "@tanstack/ai-openrouter";
+import { withGenerationPersistence } from "@tanstack/ai-persistence";
+import { persistence } from "./persistence";
+
+const adapter = openRouterVideo("bytedance/seedance-2.5");
+
+const hosting = {
+ threadId: "videos",
+ middleware: [
+ withGenerationPersistence(persistence, {
+ artifactUrl: (ref) => `/api/artifacts/${ref.artifactId}`,
+ }),
+ ],
+};
+
+const { jobId } = await generateVideo({
+ adapter,
+ ...hosting,
+ prompt: [
+ { type: "text", content: "Animate this product shot, slow push-in" },
+ {
+ type: "image",
+ source: { type: "url", value: "https://your-cdn.com/product.png" },
+ metadata: { role: "start_frame" },
+ },
+ ],
+ size: "1280x720",
+ // `duration` is typed per model. Snap raw seconds with adapter.snapDuration(),
+ // or list the valid values with adapter.availableDurations().
+ duration: 8,
+});
+
+let status = await getVideoJobStatus({ adapter, jobId, ...hosting });
+while (status.status !== "completed" && status.status !== "failed") {
+ await new Promise((r) => setTimeout(r, 5000));
+ status = await getVideoJobStatus({ adapter, jobId, ...hosting });
+}
+
+// status.url is your artifact route.
+// status.usage?.cost is the real cost that OpenRouter billed.
+console.log(status.url, status.usage?.cost);
+```
+
+The client tracks the job with `useGenerateVideo`:
+
+```tsx
+import { useGenerateVideo, fetchServerSentEvents } from "@tanstack/ai-react";
+
+export function VideoResult() {
+ const { result } = useGenerateVideo({
+ connection: fetchServerSentEvents("/api/generate/video"),
+ threadId: "video-studio",
+ });
+
+ return result?.url ? : null;
+}
+```
+
+The types check sizes, durations, and the options of each model (`resolution`, `aspectRatio`, `generateAudio`, `seed`, and more) against the video model metadata of OpenRouter. See [Video Generation](../media/video-generation.md) for the full lifecycle, streaming mode, and the image-to-video role table.
+
+## Sign in with OpenRouter (BYOK)
+
+OpenRouter can make an API key that the user owns, with [OAuth PKCE](https://openrouter.ai/docs/guides/overview/auth/oauth). Import the helpers from `@tanstack/ai-openrouter/pkce`. The key is stored under `openrouterByok.id` (`openrouter`) and sent as `x-byok-openrouter`.
+
+On the client, start the login. When the user comes back, save the key into `defineByok`:
+
+```tsx
+import { useEffect } from "react";
+import { defineByok, defaultByokStorage } from "@tanstack/ai-client/byok";
+import {
+ completeOpenRouterPkceIntoByok,
+ startOpenRouterPkceLogin,
+} from "@tanstack/ai-openrouter/pkce";
+
+const byok = defineByok({
+ storage: defaultByokStorage(),
+});
+
+export function OpenRouterSignIn() {
+ useEffect(() => {
+ void completeOpenRouterPkceIntoByok(byok);
+ }, []);
+
+ return (
+ {
+ void startOpenRouterPkceLogin();
+ }}
+ >
+ Sign in with OpenRouter
+
+ );
+}
+```
+
+On the server, read the key with the same slug that the PKCE helper wrote:
+
+```typescript
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
+import { byokMissing, getByokKey } from "@tanstack/ai/byok/server";
+import { createOpenRouterText } from "@tanstack/ai-openrouter";
+import { openrouterByok } from "@tanstack/ai-openrouter/byok";
+
+export async function POST(request: Request) {
+ const params = await chatParamsFromRequest(request);
+ const apiKey = getByokKey(request, openrouterByok);
+ if (!apiKey) return byokMissing(openrouterByok);
+
+ const stream = chat({
+ adapter: createOpenRouterText("openai/gpt-6.1-sol", apiKey),
+ messages: params.messages,
+ threadId: params.threadId,
+ runId: params.runId,
+ });
+ return toServerSentEventsResponse(stream);
+}
+```
+
+See [Bring Your Own Key](../advanced/byok) for the keyring and passkey storage.
+
+### Sign in from a CLI
+
+A CLI or a desktop app has no web page for the redirect, so the user must copy a key by hand. `openrouterSignIn()` signs the user in with the browser and gives your app the key:
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { createOpenRouterText } from "@tanstack/ai-openrouter";
+import { openrouterSignIn } from "@tanstack/ai-openrouter/pkce";
+
+const signIn = openrouterSignIn();
+const apiKey = await signIn({
+ open: (url) => console.log(`Open this URL to sign in: ${url}`),
+});
+
+const stream = chat({
+ adapter: createOpenRouterText("openai/gpt-6.1-sol", apiKey),
+ messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
+});
+```
+
+1. `signIn` starts a one-time listener on `127.0.0.1`, on a random port.
+2. It calls `open` with the OpenRouter sign-in URL. Open that URL in the browser.
+3. The user signs in and approves. The browser shows `You are signed in to OpenRouter. You can close this tab.`
+4. `signIn` resolves with the API key, and the listener closes.
+
+The sign-in uses PKCE and a random state. It stops after 10 minutes. To change the time, pass `openrouterSignIn({ timeoutMs })`. To cancel it, pass `signal` to `signIn`.
+
+The listener runs on the computer of your app. So the sign-in works only when your app and the browser are on the same computer. In a hosted app, use the browser flow above.
+
+## API reference
+
+Each factory pair has the same shape. The short factory reads `OPENROUTER_API_KEY` from the environment. The `create*` factory takes an explicit API key. Both take `model` as the first argument.
+
+| Factories | Creates |
+| --- | --- |
+| `openRouterText(model, config?)` / `createOpenRouterText(model, apiKey, config?)` | Chat adapter (Chat Completions) |
+| `openRouterResponsesText(model, config?)` / `createOpenRouterResponsesText(model, apiKey, config?)` | Chat adapter (Responses, beta) |
+| `openRouterSummarize(model, config?)` / `createOpenRouterSummarize(model, apiKey, config?)` | Summarization adapter |
+| `openRouterImage(model, config?)` / `createOpenRouterImage(model, apiKey, config?)` | Image adapter |
+| `openRouterVideo(model, config?)` / `createOpenRouterVideo(model, apiKey, config?)` | Video adapter |
+| `openRouterRerank(model, config?)` / `createOpenRouterRerank(model, apiKey, config?)` | Rerank adapter |
+| `openRouterDecider(model, config?)` / `createOpenRouterDecider(model, apiKey, config?)` | Evaluate adapter |
+
+The rerank adapter forwards the optional `httpReferer` and `appTitle` config fields as attribution headers, the same as the chat adapter.
diff --git a/docs/adapters/openrouter.md b/docs/adapters/openrouter.md
index 1d1a4491bd..9367065d6d 100644
--- a/docs/adapters/openrouter.md
+++ b/docs/adapters/openrouter.md
@@ -1,7 +1,7 @@
---
title: OpenRouter Adapter
id: openrouter-adapter
-description: "Access 300+ LLMs from OpenAI, Anthropic, Google, Meta, Mistral, and more through a single API with OpenRouter in TanStack AI."
+description: "Use 300+ models from OpenAI, Anthropic, Google, Meta, Mistral, and more in TanStack AI with one OpenRouter API key."
keywords:
- tanstack ai
- openrouter
@@ -14,9 +14,9 @@ keywords:
- jev
---
-OpenRouter is TanStack AI's first official AI partner and the recommended starting point for most projects. It provides access to 300+ models from OpenAI, Anthropic, Google, Meta, Mistral, and many more — all through a single API key and unified interface.
+`openRouterText` calls 300+ models from OpenAI, Anthropic, Google, Meta, Mistral, and others with one API key.
-## Installation
+## 1. Install
@@ -31,591 +31,225 @@ octane: @tanstack/ai-openrouter
-## Basic Usage
+## 2. Set the API key
-```typescript
-import { chat } from "@tanstack/ai";
-import { openRouterText } from "@tanstack/ai-openrouter";
-
-const stream = chat({
- adapter: openRouterText("openai/gpt-5"),
- messages: [{ role: "user", content: "Hello!" }],
-});
-```
-
-## Configuration
-
-```typescript
-import { createOpenRouterText } from "@tanstack/ai-openrouter";
-
-const adapter = createOpenRouterText(
- "openai/gpt-5",
- process.env.OPENROUTER_API_KEY!,
- {
- serverURL: "https://openrouter.ai/api/v1", // Optional
- httpReferer: "https://your-app.com", // Optional, for rankings
- appTitle: "Your App Name", // Optional, for rankings
- },
-);
-```
-
-Need a header on each request, or a log of each model call? Both text adapters support [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). If you set an `httpClient`, each wrapped request still goes through it.
-
-### Retry rate limits
-
-A busy upstream provider can reply with HTTP 429. The adapter retries only 5XX errors by default. Add `"429"` to `retryCodes` to retry rate limits too:
-
-```typescript
-import { createOpenRouterText } from "@tanstack/ai-openrouter";
+Put the key in the environment of your server:
-const adapter = createOpenRouterText(
- "openai/gpt-5",
- process.env.OPENROUTER_API_KEY!,
- {
- retryConfig: {
- strategy: "backoff",
- backoff: {
- initialInterval: 500,
- maxInterval: 60000,
- exponent: 1.5,
- maxElapsedTime: 120000,
- },
- retryConnectionErrors: true,
- },
- retryCodes: ["429", "5XX"],
- },
-);
+```bash
+OPENROUTER_API_KEY=sk-or-...
```
-- `retryCodes`: the HTTP status codes to retry. `"5XX"` matches every 5xx code.
-- `retryConfig`: how long to wait between tries. When the response has a `Retry-After` header, the SDK waits that long.
-
-Now a 429 waits, then retries, and the chat continues with the next response.
-
-### Retry an error in the stream
-
-Most provider errors come as an HTTP status, and the SDK retries them as shown above. But OpenRouter can send status 200 first. Then a provider error comes as an error chunk in the stream, and the SDK cannot retry it.
-
-To retry that error, pass `retry` to `chat()`:
+`openRouterText` and the other short factories read `OPENROUTER_API_KEY`. To pass the key in code, use the `create*` factory of the same adapter:
```typescript
-import { chat } from "@tanstack/ai";
import { createOpenRouterText } from "@tanstack/ai-openrouter";
-const adapter = createOpenRouterText(
- "openai/gpt-5.5",
- process.env.OPENROUTER_API_KEY!,
- { retryCodes: ["429", "5XX"] },
-);
-
-const stream = chat({
- adapter,
- messages: [{ role: "user", content: "Hello!" }],
- retry: { maxRetries: 3 },
-});
-```
-
-When `chat()` retries:
-
-- Only for an error code in `retryCodes`. The default is 5XX only, so add `"429"` to retry a rate limit.
-- Only when the call streamed no output yet. An error after part of the answer reaches you as a `RUN_ERROR`.
-- Not when `retryConfig` has `strategy: "none"`.
-- Only with the Chat Completions adapter (`openRouterText`). The Responses adapter does not do this.
-
-The wait is the `initialInterval` of `retryConfig` (default 500 ms). It is the same for each try and does not grow. See [Let `chat()` retry for you](../chat/stream-events#let-chat-retry-for-you).
-
-## Available Models
-
-OpenRouter provides access to 300+ models from various providers. Models use the format `provider/model-name`:
-
-```text
-model: "openai/gpt-5.1"
-model: "anthropic/claude-sonnet-4.5"
-model: "google/gemini-3.1-pro-preview"
-model: "meta-llama/llama-4-maverick"
-model: "deepseek/deepseek-v3.2"
-```
-
-See the full list at [openrouter.ai/models](https://openrouter.ai/models).
-
-`adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts).
-
-## Example: Chat Completion
-
-```typescript
-import { chat, toServerSentEventsResponse } from "@tanstack/ai";
-import { openRouterText } from "@tanstack/ai-openrouter";
-
-export async function POST(request: Request) {
- const { messages } = await request.json();
-
- const stream = chat({
- adapter: openRouterText("openai/gpt-5"),
- messages,
- });
-
- return toServerSentEventsResponse(stream);
-}
-```
-
-## Example: With Tools
-
-```typescript
-import { chat, toServerSentEventsResponse, toolDefinition } from "@tanstack/ai";
-import { openRouterText } from "@tanstack/ai-openrouter";
-import { z } from "zod";
-
-const getWeatherDef = toolDefinition({
- name: "get_weather",
- description: "Get the current weather",
- inputSchema: z.object({
- location: z.string(),
- }),
-});
-
-const getWeather = getWeatherDef.server(async ({ location }) => {
- return { temperature: 72, conditions: "sunny" };
-});
-
-export async function POST(request: Request) {
- const { messages } = await request.json();
-
- const stream = chat({
- adapter: openRouterText("openai/gpt-5"),
- messages,
- tools: [getWeather],
- });
-
- return toServerSentEventsResponse(stream);
-}
+const adapter = createOpenRouterText("openai/gpt-6.1-sol", process.env.OPENROUTER_API_KEY!);
```
-## JSON mode
-
-Some OpenRouter models reject strict JSON schema. For these models, `chat({ outputSchema })` sends `json_object` and adds your schema to the system prompt.
-
-To send JSON mode to any other model, set `modelOptions.responseFormat`:
+## 3. Stream a chat from the server
```typescript
-import { chat } from "@tanstack/ai";
-import { openRouterText } from "@tanstack/ai-openrouter";
-import { z } from "zod";
-
-const person = await chat({
- adapter: openRouterText("openai/gpt-5.6-sol"),
- messages: [{ role: "user", content: "Return Ada Lovelace, age 36." }],
- modelOptions: { responseFormat: { type: "json_object" } },
- outputSchema: z.object({ name: z.string(), age: z.number() }),
-});
-```
-
-Read [When strict JSON schema is rejected](../structured-outputs/overview.md#when-strict-json-schema-is-rejected).
-
-## Tools and structured output together
-
-You can pass both `tools` and `outputSchema` on one `chat()` call. For some
-upstream models OpenRouter can return the typed object in that same streaming
-request, so the engine does not make a second finalization call.
-
-That happens only when **every** model that can receive the request is in
-`OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS`. The set is generated from
-OpenRouter's catalog on every model sync: every chat model whose
-`supported_parameters` include `structured_outputs`, `tools` and `tool_choice`
-(Claude 4.5+, Gemini 2.5+, GPT-4o+, Grok 4, DeepSeek V3+, Llama 3.1+, and so
-on). Models OpenRouter does not flag, such as `anthropic/claude-opus-4.1`, stay
-on the legacy two-call path.
-
-If any fallback in `modelOptions.models` is outside that set, OpenRouter keeps
-the two-call path. Routing suffixes such as `:nitro` do not change the gate.
-
-Import the set from `@tanstack/ai-openrouter/model-meta` if you need to check a
-model before you send:
-
-```typescript
-import { OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS } from "@tanstack/ai-openrouter/model-meta";
-
-OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS.has("openai/gpt-5.5");
-```
-
-Chat Completions (`openRouterText`) and Responses (`openRouterResponsesText`)
-both attach the schema on this path. The client does not change: `useChat({
-outputSchema })` still reads `partial` and `final`.
-
-Server (Chat Completions):
-
-```typescript
-import { chat, toServerSentEventsResponse, toolDefinition } from "@tanstack/ai";
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
import { openRouterText } from "@tanstack/ai-openrouter";
-import { z } from "zod";
-
-const getWeather = toolDefinition({
- name: "get_weather",
- description: "Get the current weather",
- inputSchema: z.object({ location: z.string() }),
-}).server(async ({ location }) => {
- return { temperature: 72, conditions: "sunny", location };
-});
-
-const AnswerSchema = z.object({
- summary: z.string(),
- location: z.string(),
-});
export async function POST(request: Request) {
- const { messages } = await request.json();
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
const stream = chat({
- adapter: openRouterText("openai/gpt-5.5"),
+ adapter: openRouterText("openai/gpt-6.1-sol"),
messages,
- tools: [getWeather],
- outputSchema: AnswerSchema,
- stream: true,
+ threadId,
+ runId,
});
return toServerSentEventsResponse(stream);
}
```
-Server (Responses). Same `tools` and `outputSchema` as the Chat Completions
-example, with `openRouterResponsesText`:
+To use a different model, change the id. Ids have the shape `provider/model-name`, for example `anthropic/claude-sonnet-5.5` or `google/gemini-3.1-pro-preview`.
-```typescript
-import { chat, toServerSentEventsResponse, toolDefinition } from "@tanstack/ai";
-import { openRouterResponsesText } from "@tanstack/ai-openrouter";
-import { z } from "zod";
-
-const getWeather = toolDefinition({
- name: "get_weather",
- description: "Get the current weather",
- inputSchema: z.object({ location: z.string() }),
-}).server(async ({ location }) => {
- return { temperature: 72, conditions: "sunny", location };
-});
+## 4. Render the chat
-const AnswerSchema = z.object({
- summary: z.string(),
- location: z.string(),
-});
-
-export async function POST(request: Request) {
- const { messages } = await request.json();
-
- const stream = chat({
- adapter: openRouterResponsesText("openai/gpt-5.5"),
- messages,
- tools: [getWeather],
- outputSchema: AnswerSchema,
- stream: true,
- });
-
- return toServerSentEventsResponse(stream);
-}
-```
-
-Client:
+The client is the same for every provider. Point `useChat` at your route:
```tsx
+import { useState } from "react";
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
-import { z } from "zod";
-
-const AnswerSchema = z.object({
- summary: z.string(),
- location: z.string(),
-});
-const { sendMessage, partial, final } = useChat({
- connection: fetchServerSentEvents("/api/chat"),
- outputSchema: AnswerSchema,
-});
-```
-
-See [Structured Outputs with tools](../structured-outputs/with-tools) for the
-event order, and [Middleware](../advanced/middleware) for how
-`structuredOutput` phase behaves on this path.
-
-To try this in a browser, run `examples/ts-react-chat` and open
-`/generations/openrouter-combined`. The page shows the tool call, the typed
-object, and the adapter call counts. `structuredOutputStream` must stay at 0.
-
-## Environment Variables
-
-Set your API key in environment variables:
-
-```bash
-OPENROUTER_API_KEY=sk-or-...
-```
-
-## Model Routing
-
-OpenRouter can automatically route requests to the best available provider:
-
-```typescript
-import { chat, toServerSentEventsResponse } from "@tanstack/ai";
-import { openRouterText } from "@tanstack/ai-openrouter";
-
-export async function POST(request: Request) {
- const { messages } = await request.json();
-
- const stream = chat({
- adapter: openRouterText("openrouter/auto"),
- messages,
- modelOptions: {
- models: [
- "openai/gpt-5.5",
- "anthropic/claude-sonnet-4.5",
- "google/gemini-3.1-pro-preview",
- ],
- },
+export function Chat() {
+ const [input, setInput] = useState("");
+ const { messages, sendMessage } = useChat({
+ connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
});
- return toServerSentEventsResponse(stream);
+ return (
+
+ );
}
```
-`models` is the fallback list: OpenRouter tries them in order when the primary model is unavailable. The rest of OpenRouter's routing surface is also exposed through `modelOptions` and forwarded to the request as-is. The option names are the camelCase ones from the OpenRouter SDK.
-
-### Provider preferences
-
-`provider` controls which upstream providers may serve the request and in what order. Pin one provider and disable fallbacks:
+For Vue, Solid, Svelte, Angular, and the other frameworks, see [Quick Start](../getting-started/quick-start#3-render-the-chat).
-```typescript
-import { chat } from "@tanstack/ai";
-import { openRouterText } from "@tanstack/ai-openrouter";
-
-const stream = chat({
- adapter: openRouterText("meta-llama/llama-4-maverick"),
- messages: [{ role: "user", content: "Hello!" }],
- modelOptions: {
- provider: {
- order: ["groq", "together"], // provider slugs, in priority order
- allowFallbacks: false, // fail instead of routing elsewhere
- },
- },
-});
-```
+You now have a streaming chat on OpenRouter. [Tools](../tools/tools) and [Structured Outputs](../structured-outputs/overview) work the same as with every adapter.
-Provider slugs are the lowercase ids shown on OpenRouter's model pages (`"anthropic"`, `"openai"`, `"groq"`, …). Other fields on `provider`:
+## Model routing
-- `only` / `ignore`: allow-list or deny-list of provider slugs, merged with your account-wide provider settings for this request.
-- `requireParameters`: only route to providers that support every parameter in the request. Without it, OpenRouter sends each provider only the parameters it supports and silently drops the rest, so a `responseFormat` or a sampling option can be ignored without an error. When you stream with `requireParameters: true`, also set `modelOptions.streamOptions: { includeUsage: false }`. Without it, the adapter sends `stream_options`, and OpenRouter returns a 404 for providers that do not support that parameter.
-- `sort`: `"price"`, `"throughput"`, or `"latency"`, or an object with `by` and `partition`, applied when `order` is not set. Setting it disables load balancing.
-- `maxPrice`: the highest pricing you accept. It takes `prompt` and `completion` in USD per million tokens, plus `request` (per-request pricing) and `image` (per image) where a provider offers them. Providers above the limit are skipped.
-- `quantizations`: restrict to providers serving the model at given quantization levels (e.g. `"fp8"`, `"int4"`).
-- `dataCollection`: `"deny"` to use only providers that do not collect user data.
-- `zdr`: `true` to restrict routing to zero-data-retention endpoints.
-
-### Model variants
-
-`variant` appends OpenRouter's `:variant` suffix to the model id. It is adapter metadata only and is never sent in the request body:
+A model can be down, or slow on one provider. Give OpenRouter a fallback list in `models`. When the primary model is not available, OpenRouter tries the models in order:
```typescript
import { chat } from "@tanstack/ai";
import { openRouterText } from "@tanstack/ai-openrouter";
const stream = chat({
- adapter: openRouterText("deepseek/deepseek-v4-pro"),
+ adapter: openRouterText("openrouter/auto"),
messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
modelOptions: {
- variant: "free", // requests deepseek/deepseek-v4-pro:free
+ models: [
+ "openai/gpt-6.1-sol",
+ "anthropic/claude-sonnet-5.5",
+ "google/gemini-3.1-pro-preview",
+ ],
},
});
```
-Accepted values are `"free"`, `"nitro"`, `"online"`, `"exacto"`, `"extended"`, and `"thinking"`. Which of them a model actually offers is up to OpenRouter's catalog.
-
-### Plugins
-
-`plugins` enables OpenRouter's request-level plugins. Each entry is identified by `id`, with plugin-specific options alongside:
+To pick which upstream providers serve a model, set `modelOptions.provider`:
```typescript
import { chat } from "@tanstack/ai";
import { openRouterText } from "@tanstack/ai-openrouter";
const stream = chat({
- adapter: openRouterText("openai/gpt-6-astra"),
+ adapter: openRouterText("meta-llama/llama-4-maverick"),
messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
modelOptions: {
- plugins: [{ id: "web", maxResults: 5 }],
+ provider: {
+ order: ["groq", "together"], // provider slugs, in priority order
+ allowFallbacks: false, // fail instead of routing elsewhere
+ },
},
});
```
-Plugin ids include `web`, `file-parser`, `response-healing`, `moderation`, and `auto-router`; see the OpenRouter documentation for each plugin's options. Web fetching is not a plugin but a server tool (`openrouter:web_fetch`); for web search and fetch as _tools_ the model can call, see [Provider Tools](#provider-tools) below.
+For all the `provider` fields, model variants such as `:free`, and plugins, see [OpenRouter: Advanced](./openrouter-advanced#provider-preferences).
-### Reasoning
+## Provider tools
-Set `reasoning` on `chat()`. OpenRouter gets it as `reasoning.effort`:
+OpenRouter runs web search and web fetch for any chat model. Import the tools from `@tanstack/ai-openrouter/tools`:
```typescript
import { chat } from "@tanstack/ai";
import { openRouterText } from "@tanstack/ai-openrouter";
+import { webSearchTool } from "@tanstack/ai-openrouter/tools";
const stream = chat({
- adapter: openRouterText("anthropic/claude-opus-5.5"),
- messages: [{ role: "user", content: "Hello!" }],
- reasoning: "high",
+ adapter: openRouterText("openai/gpt-6.1-sol"),
+ messages: [{ role: "user", content: "What's new in AI this week?" }],
+ threadId: "thread-1",
+ tools: [
+ webSearchTool({
+ engine: "exa",
+ maxResults: 5,
+ allowedDomains: ["arxiv.org", "openai.com"],
+ }),
+ ],
});
```
-- `off` sends `effort: "none"`.
-- `openRouterText` takes no `budgetTokens`. `openRouterResponsesText` sends `budgetTokens` as `reasoning.maxTokens`, and adds `summary: "auto"` so the thinking text streams.
-- The types take the levels of each model. Your editor shows them when you type `reasoning`.
-
-### Session and metadata
-
-`user` (a unique user identifier), `sessionId`, and `metadata` (up to 16 key-value pairs) are forwarded unchanged. `sessionId` doubles as OpenRouter's sticky routing key: requests that share it are routed to the same provider to maximize prompt-cache hits, and grouped together for observability.
-
-### Prompt caching
+- `webSearchTool`: search the web.
+- `webFetchTool`: read the full page at a URL that the model picks.
-`chat()` sets `sessionId` to the cache key by default, which is the `threadId` that you pass. So one conversation stays on one provider and reads its cache. `anthropic/*` models also get cache markers on the system message, the last tool, and the last message. `promptCache: 'long'` asks for the 1-hour cache, and `promptCache: 'none'` sends nothing. See [Prompt Caching](../advanced/prompt-caching).
+Both work on all OpenRouter chat models. For every option of each tool, see [OpenRouter: Advanced](./openrouter-advanced#provider-tools). For how provider tools compare across adapters, see [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
-Your own `modelOptions.sessionId` wins. A cache marker of your own turns the automatic markers off for that request.
+## Tools and structured output together
-## Model Options
+You can pass `tools` and `outputSchema` on one `chat()` call. On some models, OpenRouter returns the typed object in the same streaming request. Then no second call is necessary.
-OpenRouter supports various provider-specific options. Sampling parameters live here too — `temperature`, `topP`, and `maxCompletionTokens` (OpenRouter's token-limit key for the chat adapter) — rather than as root-level props on `chat()`:
+This happens only when every model that can get the request is in `OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS`. To check a model before you send:
```typescript
-import { chat, toServerSentEventsResponse } from "@tanstack/ai";
-import { openRouterText } from "@tanstack/ai-openrouter";
-
-export async function POST(request: Request) {
- const { messages } = await request.json();
-
- const stream = chat({
- adapter: openRouterText("anthropic/claude-sonnet-4.5"),
- messages,
- modelOptions: {
- temperature: 0.7,
- topP: 0.9,
- maxCompletionTokens: 1024,
- },
- });
+import { OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS } from "@tanstack/ai-openrouter/model-meta";
- return toServerSentEventsResponse(stream);
-}
+console.log(OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS.has("openai/gpt-6.1-sol")); // true
```
-> If you previously passed `temperature` / `topP` / `maxTokens` at the root of `chat()`, see [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options).
-
-## Chat Completions vs Responses (beta)
-
-OpenRouter exposes two OpenAI-compatible wire formats, and the adapter
-package ships one of each:
-
-| Adapter | Endpoint | Status | When to use |
-| ------------------------- | ---------------------- | ------ | ----------------------------------------------------------------------------------------- |
-| `openRouterText` | `/v1/chat/completions` | Stable | Default for almost everything. Broadest model + tool support. |
-| `openRouterResponsesText` | `/v1/responses` | Beta | OpenAI Responses-shaped request/response; richer multi-turn state on OpenAI-style models. |
+For the full server and client code and the rules of the set, see [OpenRouter: Advanced](./openrouter-advanced#tools-and-structured-output-in-one-call).
-Both adapters route to any underlying model OpenRouter supports
-(`anthropic/...`, `google/...`, `meta-llama/...`, etc.) — the wire format
-describes how your client talks to OpenRouter, not which provider answers.
-`/v1/responses` is OpenAI's newer API surface; OpenRouter implements it so
-clients that prefer that wire format can use it across the same 300+
-model catalogue.
+## Images
```typescript
-import { chat } from "@tanstack/ai";
-import { openRouterResponsesText } from "@tanstack/ai-openrouter";
+import { generateImage } from "@tanstack/ai";
+import { openRouterImage } from "@tanstack/ai-openrouter";
-const stream = chat({
- adapter: openRouterResponsesText("anthropic/claude-sonnet-4.5"),
- messages: [{ role: "user", content: "Hello!" }],
+const result = await generateImage({
+ adapter: openRouterImage("google/gemini-nano-banana-2.1"),
+ prompt: "A watercolor lighthouse at dusk",
+ threadId: "thread-1",
+ size: "1344x768", // sent as image_config.aspect_ratio ("16:9")
+ modelOptions: {
+ image_size: "2K", // resolution (Gemini models)
+ },
});
-```
-Caveats while the Responses adapter is in beta:
-
-- Function tools are supported; OpenRouter's branded server-tools (web
- search, file search, …) are not yet wired through this path — use
- `openRouterText` if you need those.
-- If in doubt, prefer `openRouterText`. The Chat Completions endpoint has
- broader provider coverage and feature parity today.
-
-## Cost Tracking
-
-OpenRouter reports the actual cost of each request inline on the streamed
-response. When present, the adapter forwards it on the terminal `RUN_FINISHED`
-event under `usage.cost`, with OpenRouter's per-request breakdown under
-`usage.costDetails`. This is the cost OpenRouter itself reports for the
-request — it is **not** computed locally from token counts, so it already
-accounts for routing, fallback providers, BYOK, and cached-token pricing. See
-OpenRouter's [Usage Accounting](https://openrouter.ai/docs/use-cases/usage-accounting)
-docs for the meaning and units of these fields.
-
-```typescript ignore
-import { chat, type RunFinishedEvent, type StreamChunk } from "@tanstack/ai";
-import { openRouterText } from "@tanstack/ai-openrouter";
-
-function isRunFinished(chunk: StreamChunk): chunk is RunFinishedEvent {
- return "finishReason" in chunk;
-}
-
-for await (const chunk of chat({
- adapter: openRouterText("openai/gpt-5"),
- messages: [{ role: "user", content: "Hello!" }],
-})) {
- if (isRunFinished(chunk)) {
- console.log("cost:", chunk.usage?.cost);
- console.log("breakdown:", chunk.usage?.costDetails);
- }
-}
+console.log(result.images);
```
-The same `usage` (including `cost` / `costDetails`) is passed to middleware via
-the `onUsage` and `onFinish` hooks. When OpenRouter does not report a cost, the
-fields are simply absent and the stream completes normally. Both
-`openRouterText` and `openRouterResponsesText` populate cost when OpenRouter
-returns it.
+- You get one image for each request. `numberOfImages` more than 1 throws an error.
+- `size` takes one of ten `WIDTHxHEIGHT` values. Other values throw an error that lists the valid sizes.
+- The prompt can mix text and image parts, for image-to-image work. `modelOptions.strength` sets how much the input image controls the result, on models that support it.
-The `openRouterText` adapter's `structuredOutputStream()` method includes
-received usage on its terminal `RUN_ERROR` event. This covers parse failures,
-truncated responses, empty responses, and SDK errors. When `chat()` uses this
-path, middleware `onChunk` receives the error event even if the promise rejects.
-The `onUsage` hook reports successful calls. Each adapter call reports usage on
-one terminal event.
+For a React and TanStack Start walkthrough with `useGenerateImage`, see [Generate Image](../tutorials/generate-image). See also [Image Generation](../media/image-generation).
-A reported `completionTokensDetails.reasoningTokens` value of `0` stays zero.
-A missing count stays absent. Errors before any usage arrives have no `usage`
-field. If the consumer stops reading the stream, it cannot receive later events.
+## Video
+
+`openRouterVideo` makes videos with Seedance, Veo 3.1, Wan, Kling, and more, through the async video API of OpenRouter (`POST /api/v1/videos`). Video generation is experimental. For the full server and client code, see [OpenRouter: Advanced](./openrouter-advanced#video-generation).
## Reranking
-OpenRouter exposes rerank models through its unified `/v1/rerank` endpoint
-(served via the `@openrouter/sdk` SDK). Any rerank model OpenRouter offers works
-by passing its slug — for example `cohere/rerank-v3.5`, `cohere/rerank-4-fast`,
-`cohere/rerank-4-pro`, or `nvidia/llama-nemotron-rerank-vl-1b-v2`. Use
-`openRouterRerank` with the `rerank()` activity to reorder candidate documents
-by relevance to a query:
+`openRouterRerank` puts documents in order of how well they match a query:
```typescript
import { rerank } from "@tanstack/ai";
import { openRouterRerank } from "@tanstack/ai-openrouter";
const { rerankedDocuments } = await rerank({
- adapter: openRouterRerank("cohere/rerank-v3.5"),
+ adapter: openRouterRerank("cohere/rerank-4-pro"),
query: "talk about rain",
documents: ["sunny day at the beach", "rainy afternoon in the city"],
topN: 2,
});
-console.log(rerankedDocuments[0]); // 'rainy afternoon in the city'
+console.log(rerankedDocuments[0]); // "rainy afternoon in the city"
```
-`openRouterRerank` reads `OPENROUTER_API_KEY` from the environment; pass a key
-explicitly with `createOpenRouterRerank("cohere/rerank-v3.5", "sk-or-...")`. The
-optional `httpReferer` / `appTitle` config fields are forwarded as OpenRouter
-attribution headers, just like the chat adapter.
-
-See the [Reranking guide](../rerank/rerank) for object documents, RAG
-pipelines, options, and the result shape.
+Any rerank model on OpenRouter works. Known ids: `cohere/rerank-v3.5`, `cohere/rerank-4-fast`, `cohere/rerank-4-pro`, `nvidia/llama-nemotron-rerank-vl-1b-v2`. See the [Reranking guide](../rerank/rerank).
## Evaluate
-OpenRouter exposes TypeSafe Jev through `POST /api/alpha/decisions`.
-Use `openRouterDecider` with `decide()` to ask typed questions about a
-shared `state`:
+Sometimes you need an answer that your code can branch on, not chat text. Use `openRouterDecider` with `decide()` to ask typed questions about one shared `state`. The adapter calls TypeSafe Jev through `POST /api/alpha/decisions`:
```typescript
import { decide, choice, score, boolean } from "@tanstack/ai";
@@ -654,299 +288,110 @@ console.log(result.queue.confidence);
console.log(result.meta.usage);
```
-To bill per request, read `result.meta.usage.cost`. To look the request up later with `GET /api/v1/generation?id=`, read `result.meta.id`. `result.meta.provider` names the upstream provider that served the request.
-
-`openRouterDecider` reads `OPENROUTER_API_KEY` from the environment. Pass a
-key explicitly with `createOpenRouterDecider("~typesafe/jev-latest", "sk-or-...")`.
-
-Known slugs:
-
-- `~typesafe/jev-latest`
-- `typesafe/jev-1.13`
-- `typesafe/jev-1.13.0`
-
-See the [Evaluate guide](../evaluate/evaluate) for question helpers, the result
-shape, abort, and middleware.
-
-## Image Generation
-
-For a React + Start walkthrough with `useGenerateImage`, open [Generate Image](../tutorials/generate-image).
-
-`openRouterImage` routes image generation through OpenRouter's
-chat-completions surface (`modalities: ['image']`). Multimodal prompts are
-supported — text and image parts are forwarded in order for
-image-conditioned generation:
-
-```typescript
-import { generateImage } from "@tanstack/ai";
-import { openRouterImage } from "@tanstack/ai-openrouter";
-
-const result = await generateImage({
- adapter: openRouterImage("google/gemini-2.5-flash-image"),
- prompt: "A watercolor lighthouse at dusk",
- size: "1344x768", // mapped to image_config.aspect_ratio ('16:9')
- modelOptions: {
- image_size: "2K", // resolution (Gemini models)
- strength: 0.35, // image-to-image influence, i2i-capable models only
- },
-});
-```
-
-Notes:
-
-- The pathway returns **exactly one image per request** — `numberOfImages > 1`
- throws instead of silently under-delivering. Make multiple requests if you
- need multiple candidates.
-- `size` must be one of the ten supported `WIDTHxHEIGHT` values (it is
- converted to `image_config.aspect_ratio`); anything else throws with the
- supported list.
+- `result.meta.usage.cost`: the cost of the request, to bill per request.
+- `result.meta.id`: the id to look the request up later with `GET /api/v1/generation?id=`.
+- `result.meta.provider`: the upstream provider that served the request.
-## Video Generation (Experimental)
+Known ids: `~typesafe/jev-latest`, `typesafe/jev-1.13`, `typesafe/jev-1.13.0`. See the [Evaluate guide](../evaluate/evaluate).
-`openRouterVideo` targets OpenRouter's dedicated **async video API**
-(`POST /api/v1/videos`) — Seedance, Veo 3.1, Wan, Kling, and Sora 2 Pro
-through your one OpenRouter key. It follows the jobs/polling architecture
-shared by all TanStack AI video adapters:
+## Summarization
```typescript
-// Server: create the job, then poll
-import { generateVideo, getVideoJobStatus } from "@tanstack/ai";
-import { openRouterVideo } from "@tanstack/ai-openrouter";
-import { withGenerationPersistence } from "@tanstack/ai-persistence";
-import { persistence } from "./persistence";
-
-const adapter = openRouterVideo("bytedance/seedance-2.0");
-
-// OpenRouter returns the finished video as a stream, not a URL. Persistence
-// stores the stream and sets `url`. Pass the same options to both calls.
-const hosting = {
- threadId: "videos",
- middleware: [
- withGenerationPersistence(persistence, {
- artifactUrl: (ref) => `/api/artifacts/${ref.artifactId}`,
- }),
- ],
-};
-
-const { jobId } = await generateVideo({
- adapter,
- ...hosting,
- prompt: [
- { type: "text", content: "Animate this product shot, slow push-in" },
- {
- type: "image",
- source: { type: "url", value: "https://your-cdn.com/product.png" },
- metadata: { role: "start_frame" },
- },
- ],
- size: "1280x720",
- // `duration` is typed per model from the published metadata; coerce raw
- // seconds with adapter.snapDuration() or enumerate via adapter.availableDurations().
- duration: 8,
+import { summarize } from "@tanstack/ai";
+import { openRouterSummarize } from "@tanstack/ai-openrouter";
+
+const result = await summarize({
+ adapter: openRouterSummarize("google/gemini-3.8-flash"),
+ text: "Your long text to summarize...",
+ threadId: "thread-1",
+ maxLength: 100,
+ style: "concise", // "concise" | "bullet-points" | "paragraph"
});
-let status = await getVideoJobStatus({ adapter, jobId, ...hosting });
-while (status.status !== "completed" && status.status !== "failed") {
- await new Promise((r) => setTimeout(r, 5000));
- status = await getVideoJobStatus({ adapter, jobId, ...hosting });
-}
-// status.url is your artifact route. status.usage?.cost is the real billed
-// cost that the gateway reports.
+console.log(result.summary);
```
-```tsx
-// Client: track the job with the useGenerateVideo hook
-import { useGenerateVideo, fetchServerSentEvents } from "@tanstack/ai-react";
+## Cost tracking
-const { generate, result, videoStatus, isLoading } = useGenerateVideo({
- connection: fetchServerSentEvents("/api/generate/video"),
-});
-// result?.url renders directly:
-```
-
-Sizes, durations, and per-model options (`resolution`, `aspectRatio`,
-`generateAudio`, `seed`, …) are typed and validated per model from
-OpenRouter's video model metadata. See
-[Video Generation](../media/video-generation.md) for the full lifecycle,
-streaming mode, and the image-to-video role-mapping table.
-
-## Next Steps
-
-- [Getting Started](../getting-started/quick-start) - Learn the basics
-- [Tools Guide](../tools/tools) - Learn about tools
-- [Reranking](../rerank/rerank) - Reorder documents by relevance
-- [Evaluate](../evaluate/evaluate) - Ask typed questions about shared state
+OpenRouter reports the real cost of each request. The adapter puts it on the last `RUN_FINISHED` event as `usage.cost`, with the breakdown in `usage.costDetails`. This cost includes routing, fallback providers, BYOK, and cached-token prices. See [Cost tracking](./openrouter-advanced#cost-tracking) for a sample.
-## Provider Tools
-
-> **Migrated from `createWebSearchTool`?** This factory was renamed to
-> `webSearchTool` and moved to the `/tools` subpath in this release.
-> See [Migration Guide §6](../migration/migration.md#6-provider-tools-moved-to-tools-subpath)
-> for the exact before/after.
+## Sign in with OpenRouter (BYOK)
-OpenRouter's gateway exposes web search via a plugin that works across
-any proxied chat model. Import it from `@tanstack/ai-openrouter/tools`.
+Your users can sign in with their own OpenRouter account. Then the requests use their key and their credit. The helpers are in `@tanstack/ai-openrouter/pkce`. For the browser and the CLI flows, see [OpenRouter: Advanced](./openrouter-advanced#sign-in-with-openrouter-byok).
-> For the full concept, a comparison matrix, and type-gating details, see
-> [Provider Tools](../tools/provider-tools.md).
+## Models and options
-### `webSearchTool`
+### Model options
-Adds web search capability to any OpenRouter-proxied chat model. The factory
-accepts OpenRouter's `WebSearchConfig` directly — pick the `engine`
-(`auto`, `native`, `exa`, `firecrawl`, or `parallel`), cap results with
-`maxResults` / `maxTotalResults`, restrict which sites can appear in results
-with `allowedDomains` / `excludedDomains`, and optionally pass
-`searchContextSize` or `userLocation` for finer control.
+Provider options go in `modelOptions`. This includes sampling options such as `temperature` and `maxCompletionTokens` (the token limit of the chat adapter). The type only allows the options that the selected model supports. For example, Claude Sonnet 5.5 does not take `topP`, so `topP` is a type error for that model:
```typescript
import { chat } from "@tanstack/ai";
import { openRouterText } from "@tanstack/ai-openrouter";
-import { webSearchTool } from "@tanstack/ai-openrouter/tools";
const stream = chat({
- adapter: openRouterText("openai/gpt-5"),
- messages: [{ role: "user", content: "What's new in AI this week?" }],
- tools: [
- webSearchTool({
- engine: "exa",
- maxResults: 5,
- allowedDomains: ["arxiv.org", "openai.com"],
- }),
- ],
+ adapter: openRouterText("anthropic/claude-sonnet-5.5"),
+ messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
+ modelOptions: {
+ temperature: 0.7,
+ maxCompletionTokens: 1024,
+ },
});
```
-**Supported models:** all OpenRouter chat models. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
+The option names are the camelCase names of the OpenRouter SDK. Sampling options do not go at the root of `chat()`. See [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options).
-### `webFetchTool`
-
-Lets any OpenRouter-proxied chat model fetch the full contents of a URL the
-model chooses, instead of running a search. The factory accepts OpenRouter's
-`WebFetchServerToolConfig` directly — pick the fetch `engine` (`auto` — the
-default, `native`, `openrouter`, `exa`, or `firecrawl`), cap how much page
-content the model receives with `maxContentTokens`, cap how many fetches the
-model can make per request with `maxUses`, and restrict which URLs the model
-can fetch with `allowedDomains` / `blockedDomains`.
+### Reasoning
-> The `native` engine routes to the underlying provider's own fetch (for
-> example, Anthropic's `web_fetch` on Claude models). Native fetch
-> capabilities vary, so `allowedDomains` and `blockedDomains` may be
-> ignored. Use `openrouter`, `exa`, or `firecrawl` if you need consistent
-> behaviour across models.
+Set `reasoning` on `chat()`. OpenRouter gets it as `reasoning.effort`:
```typescript
import { chat } from "@tanstack/ai";
import { openRouterText } from "@tanstack/ai-openrouter";
-import { webFetchTool } from "@tanstack/ai-openrouter/tools";
const stream = chat({
- adapter: openRouterText("openai/gpt-5"),
- messages: [
- { role: "user", content: "Summarize https://example.com/article" },
- ],
- tools: [
- webFetchTool({
- engine: "openrouter",
- maxContentTokens: 4000,
- allowedDomains: ["example.com"],
- }),
- ],
+ adapter: openRouterText("anthropic/claude-opus-5.5"),
+ messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
+ reasoning: "high",
});
```
-**Supported models:** all OpenRouter chat models. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools).
-
-## Sign in with OpenRouter (BYOK)
-
-OpenRouter can mint a user-owned API key via [OAuth PKCE](https://openrouter.ai/docs/guides/overview/auth/oauth). Import the helpers from `@tanstack/ai-openrouter/pkce`. The key is stored under `openrouterByok.id` (`openrouter`) and sent as `x-byok-openrouter`.
-
-Client: start login, then on return save the key into `defineByok`.
+- `off` sends `effort: "none"`.
+- `openRouterText` takes no `budgetTokens`. `openRouterResponsesText` sends `budgetTokens` as `reasoning.maxTokens`, and adds `summary: "auto"` so that the thinking text streams.
+- The types know the levels of each model. Your editor shows them when you type `reasoning`.
-```tsx
-import { useEffect } from "react";
-import { defineByok, defaultByokStorage } from "@tanstack/ai-client/byok";
-import {
- completeOpenRouterPkceIntoByok,
- startOpenRouterPkceLogin,
-} from "@tanstack/ai-openrouter/pkce";
+See [Reasoning](../chat/reasoning).
-const byok = defineByok({
- storage: defaultByokStorage(),
-});
+### Models
-export function OpenRouterSignIn() {
- useEffect(() => {
- void completeOpenRouterPkceIntoByok(byok);
- }, []);
+Model ids have the shape `provider/model-name`:
- return (
- {
- void startOpenRouterPkceLogin();
- }}
- >
- Sign in with OpenRouter
-
- );
-}
-```
-
-Server: read the same slug the PKCE helper wrote.
-
-```typescript
-import {
- chat,
- chatParamsFromRequest,
- toServerSentEventsResponse,
-} from "@tanstack/ai";
-import { byokMissing, getByokKey } from "@tanstack/ai/byok/server";
-import { createOpenRouterText } from "@tanstack/ai-openrouter";
-import { openrouterByok } from "@tanstack/ai-openrouter/byok";
-
-export async function POST(request: Request) {
- const params = await chatParamsFromRequest(request);
- const apiKey = getByokKey(request, openrouterByok);
- if (!apiKey) return byokMissing(openrouterByok);
-
- const stream = chat({
- adapter: createOpenRouterText("openai/gpt-5.5", apiKey),
- messages: params.messages,
- threadId: params.threadId,
- runId: params.runId,
- });
- return toServerSentEventsResponse(stream);
-}
+```text
+openai/gpt-6.1-sol
+anthropic/claude-sonnet-5.5
+google/gemini-3.1-pro-preview
+meta-llama/llama-4-maverick
+deepseek/deepseek-v4-pro
```
-See [Bring Your Own Key](../advanced/byok) for the keyring and passkey storage.
-
-### Sign in from a CLI
-
-A CLI or a desktop app has no web page for the redirect, so the user would have to copy a key by hand. `openrouterSignIn()` signs the user in with the browser and gives your app the key:
+See the full list at [openrouter.ai/models](https://openrouter.ai/models). The package exports the known ids:
-```typescript
-import { chat } from "@tanstack/ai";
-import { createOpenRouterText } from "@tanstack/ai-openrouter";
-import { openrouterSignIn } from "@tanstack/ai-openrouter/pkce";
-
-const signIn = openrouterSignIn();
-const apiKey = await signIn({
- open: (url) => console.log(`Open this URL to sign in: ${url}`),
-});
+- `@tanstack/ai-openrouter/model-meta`: `OPENROUTER_CHAT_MODELS`, `OPENROUTER_IMAGE_MODELS`, `OPENROUTER_VIDEO_MODELS`.
+- `@tanstack/ai-openrouter`: `OPENROUTER_RERANK_MODELS`, `OPENROUTER_EVALUATE_MODELS`.
-const stream = chat({
- adapter: createOpenRouterText("openai/gpt-6.1-sol", apiKey),
- messages: [{ role: "user", content: "Hello!" }],
-});
-```
+`adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts).
-1. `signIn` starts a one-time listener on `127.0.0.1`, on a random port.
-2. It calls `open` with the OpenRouter sign-in URL. Open that URL in the browser.
-3. The user signs in and approves. The browser shows `You are signed in to OpenRouter. You can close this tab.`
-4. `signIn` resolves with the API key, and the listener closes.
+## Advanced
-The sign-in uses PKCE and a random state. It stops after 10 minutes. To change the time, pass `openrouterSignIn({ timeoutMs })`. To cancel it, pass `signal` to `signIn`.
+These topics are on [OpenRouter: Advanced](./openrouter-advanced):
-The listener runs on the computer of your app. So the sign-in works only when your app and the browser are on the same computer. In a hosted app, use the browser flow above.
+- Configuration: base URL, app attribution headers, and `wrapFetch`.
+- Retries for rate limits, and for an error in the stream.
+- The Responses adapter (`openRouterResponsesText`, beta).
+- Provider preferences, model variants, plugins, and session metadata.
+- Prompt caching and JSON mode.
+- Tools and structured output in one call, with the full code.
+- `webFetchTool`, cost tracking, video generation, and Sign in with OpenRouter.
+- The API reference.
diff --git a/docs/adapters/perplexity.md b/docs/adapters/perplexity.md
index 9e54299597..e5ad3ac480 100644
--- a/docs/adapters/perplexity.md
+++ b/docs/adapters/perplexity.md
@@ -2,7 +2,7 @@
title: Perplexity Search
id: perplexity-adapter
order: 10
-description: "Ground TanStack AI agents on the live web with the Perplexity Search API via @tanstack/ai-perplexity."
+description: "Give a TanStack AI agent live web results with the Perplexity Search API and @tanstack/ai-perplexity, or chat with Sonar."
keywords:
- tanstack ai
- perplexity
@@ -11,129 +11,212 @@ keywords:
- grounding
---
-`@tanstack/ai-perplexity` is a **Search API** package. It wraps `POST https://api.perplexity.ai/search` as a TanStack AI tool (and a low-level HTTP client) so an agent can fetch ranked web results for citation and grounding.
+`@tanstack/ai-perplexity` exposes the Perplexity Search API as a tool. Pair it with a chat adapter that calls tools, such as `openaiText` or `anthropicText`.
-It does **not** ship a TanStack text adapter. Pair the search tool with a function-calling adapter such as `openaiText` or `anthropicText`. Sonar `chat()` still goes through [`openaiCompatible`](./openai-compatible.md) — Sonar already searches the web and does not accept custom tools.
+Want Sonar, the Perplexity chat model, instead? See [Chat with Sonar](#chat-with-sonar).
-## Installation
+## 1. Install
+
+This page uses OpenAI for the chat.
-react: @tanstack/ai @tanstack/ai-openai @tanstack/ai-perplexity
-vue: @tanstack/ai @tanstack/ai-openai @tanstack/ai-perplexity
-solid: @tanstack/ai @tanstack/ai-openai @tanstack/ai-perplexity
-svelte: @tanstack/ai @tanstack/ai-openai @tanstack/ai-perplexity
-preact: @tanstack/ai @tanstack/ai-openai @tanstack/ai-perplexity
-angular: @tanstack/ai @tanstack/ai-openai @tanstack/ai-perplexity
-vanilla: @tanstack/ai @tanstack/ai-openai @tanstack/ai-perplexity
-octane: @tanstack/ai @tanstack/ai-openai @tanstack/ai-perplexity
+react: @tanstack/ai @tanstack/ai-react @tanstack/ai-openai @tanstack/ai-perplexity
+vue: @tanstack/ai @tanstack/ai-vue @tanstack/ai-openai @tanstack/ai-perplexity
+solid: @tanstack/ai @tanstack/ai-solid @tanstack/ai-openai @tanstack/ai-perplexity
+svelte: @tanstack/ai @tanstack/ai-svelte @tanstack/ai-openai @tanstack/ai-perplexity
+preact: @tanstack/ai @tanstack/ai-preact @tanstack/ai-openai @tanstack/ai-perplexity
+angular: @tanstack/ai @tanstack/ai-angular @tanstack/ai-openai @tanstack/ai-perplexity
+vanilla: @tanstack/ai @tanstack/ai-client @tanstack/ai-openai @tanstack/ai-perplexity
+octane: @tanstack/ai @tanstack/ai-octane @tanstack/ai-openai @tanstack/ai-perplexity octane
-Set your API key (get one at ):
+## 2. Set the API key
+
+Get a key in the [Perplexity console](https://console.perplexity.ai/group/keys). Put it in the environment of your server, next to the key of your chat provider:
```bash
-export PERPLEXITY_API_KEY=...
-# PPLX_API_KEY is also accepted
+PERPLEXITY_API_KEY=pplx-...
+OPENAI_API_KEY=sk-...
```
-## Search tool
+The tool reads `PERPLEXITY_API_KEY` first, then `PPLX_API_KEY`.
-Use the tool with a first-class function-calling adapter. Do not pass it to Sonar — Sonar Chat Completions does not register custom tools.
+## 3. Stream a chat with web search from the server
-```ts
-import { chat } from '@tanstack/ai'
-import { openaiText } from '@tanstack/ai-openai'
-import { perplexitySearchTool } from '@tanstack/ai-perplexity'
+Add the search tool to `tools`. The model calls it when it needs fresh facts:
-const search = perplexitySearchTool({
- defaultMaxResults: 5,
-})
+```typescript
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
+import { perplexitySearchTool } from "@tanstack/ai-perplexity";
-const stream = chat({
- adapter: openaiText('gpt-5.2'),
- tools: [search],
- messages: [
- { role: 'user', content: 'What were the top AI papers this week?' },
- ],
-})
-```
+const search = perplexitySearchTool({ defaultMaxResults: 5 });
+
+export async function POST(request: Request) {
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
-Swap `openaiText` for `anthropicText` (or any other function-calling adapter) the same way.
+ const stream = chat({
+ adapter: openaiText("gpt-6.1-sol"),
+ tools: [search],
+ messages,
+ threadId,
+ runId,
+ });
-The tool input schema accepts:
+ return toServerSentEventsResponse(stream);
+}
+```
-| Field | Type | Notes |
-| --------------------------- | ------------------------------------------------ | ------------------------------------------------------------------- |
-| `query` | `string` (required) | The search query. |
-| `max_results` | `integer` (1–20) | Defaults to `defaultMaxResults` when set, otherwise the API default (10). |
-| `search_domain_filter` | `string[]` | Max 20. Allowlist (`"nytimes.com"`) **or** denylist (`"-pinterest.com"`) — never both. Hostnames, optional paths, or TLDs. |
-| `search_recency_filter` | `"hour" \| "day" \| "week" \| "month" \| "year"` | Recency window. |
-| `search_after_date_filter` | `string` | `m/d/yyyy` — only results on/after this date. |
-| `search_before_date_filter` | `string` | `m/d/yyyy` — only results on/before this date. |
+To use Claude, swap `openaiText` for `anthropicText`. Any adapter that calls tools works the same way.
+
+## 4. Render the chat
+
+The client is the same for every provider. Point `useChat` at your route:
+
+```tsx
+import { useState } from "react";
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
+
+export function Chat() {
+ const [input, setInput] = useState("");
+ const { messages, sendMessage } = useChat({
+ connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
+ });
+
+ return (
+
+ );
+}
+```
-Output: `{ results: Array<{ title, url, snippet, date?, last_updated? }> }`. The wrapper keeps those citation fields and the optional response `id` on `client.search()`; it does not surface `server_time`.
+For Vue, Solid, Svelte, Angular, and the other frameworks, see [Quick Start](../getting-started/quick-start#3-render-the-chat).
-The tool exposes a subset of Search API filters (`query` is a single string). `PerplexitySearchClient` also accepts `max_tokens_per_page` and up to 5 queries as `string[]`.
+Ask "What were the top AI papers this week?" and the answer now uses live web results. For more on tools, see [Tools](../tools/tools).
-### Direct client
+## Search without an agent
-If you want to call the Search API outside an agent loop:
+To call the Search API in your own code, use `PerplexitySearchClient`:
```ts
-import { PerplexitySearchClient } from '@tanstack/ai-perplexity'
+import { PerplexitySearchClient } from "@tanstack/ai-perplexity";
-const client = new PerplexitySearchClient()
+const client = new PerplexitySearchClient();
const { results } = await client.search({
- query: 'mars sample return mission',
+ query: "mars sample return mission",
max_results: 5,
- search_recency_filter: 'month',
-})
+ search_recency_filter: "month",
+});
```
-## Configuration
+The client also takes these, which the tool does not:
-```ts
-import { PerplexitySearchClient } from '@tanstack/ai-perplexity'
-
-const client = new PerplexitySearchClient({
- apiKey: process.env.PERPLEXITY_API_KEY, // explicit key (optional)
- baseURL: 'https://api.perplexity.ai', // override (optional)
- fetch: globalThis.fetch, // custom fetch (optional)
-})
-```
+- `max_tokens_per_page`: the most content tokens for each page.
+- `query` as an array of up to 5 queries.
-## Chat (Sonar)
+## Chat with Sonar
-Sonar already grounds answers on the web. Use [`openaiCompatible`](./openai-compatible.md) from `@tanstack/ai-openai/compatible` — this package does not wrap that adapter, and you should not pass `perplexitySearchTool` here.
+Sonar is the Perplexity chat model. It searches the web by itself and does not take custom tools, so do not give it `perplexitySearchTool`. This package has no Sonar adapter. Use [`openaiCompatible`](./openai-compatible.md):
```ts
-import { chat } from '@tanstack/ai'
-import { openaiCompatible } from '@tanstack/ai-openai/compatible'
-import { getPerplexityIntegrationHeaders } from '@tanstack/ai-perplexity'
+import { chat } from "@tanstack/ai";
+import { openaiCompatible } from "@tanstack/ai-openai/compatible";
+import { getPerplexityIntegrationHeaders } from "@tanstack/ai-perplexity";
const perplexity = openaiCompatible({
- name: 'perplexity',
- baseURL: 'https://api.perplexity.ai',
+ name: "perplexity",
+ baseURL: "https://api.perplexity.ai",
apiKey: process.env.PERPLEXITY_API_KEY!,
- models: ['sonar', 'sonar-pro'],
+ models: ["sonar", "sonar-pro"],
defaultHeaders: getPerplexityIntegrationHeaders(),
-})
+});
const stream = chat({
- adapter: perplexity('sonar'),
- messages: [{ role: 'user', content: 'What is the latest on the Mars rover?' }],
-})
+ adapter: perplexity("sonar"),
+ messages: [{ role: "user", content: "What is the latest on the Mars rover?" }],
+ threadId: "thread-1",
+});
+```
+
+The OpenAI SDK then calls `POST https://api.perplexity.ai/chat/completions`, the OpenAI-compatible path of Sonar.
+
+`getPerplexityIntegrationHeaders()` is optional. It adds the Perplexity attribution header `X-Pplx-Integration` (`tanstack/`). The Search client sends this header by itself.
+
+## Models and options
+
+### Tool options
+
+`perplexitySearchTool(config?)` takes:
+
+- `defaultMaxResults`: the result count when the model does not send one. An integer from 1 to 20.
+- `name`: the tool name. The default is `perplexity_search`.
+- `description`: the tool description that the model sees.
+- `apiKey`, `baseURL`, `fetch`: the same as on `PerplexitySearchClient`. See [Configuration](#configuration).
+
+### Tool input
+
+The model fills these fields when it calls the tool:
+
+| Field | Type | Notes |
+| --- | --- | --- |
+| `query` | `string` (required) | The search query. |
+| `max_results` | `integer` (1 to 20) | The default is `defaultMaxResults` when set, else the API default (10). |
+| `search_domain_filter` | `string[]` | Max 20. An allowlist (`"nytimes.com"`) or a denylist (`"-pinterest.com"`), never both. Hostnames, optional paths, or TLDs. |
+| `search_recency_filter` | `"hour" \| "day" \| "week" \| "month" \| "year"` | The recency window. |
+| `search_after_date_filter` | `string` | `m/d/yyyy`. Only results on or after this date. |
+| `search_before_date_filter` | `string` | `m/d/yyyy`. Only results on or before this date. |
+
+### Tool output
+
+The tool returns `{ results: Array<{ title, url, snippet, date?, last_updated? }> }`.
+
+- `client.search()` also returns the optional response `id`.
+- Neither returns `server_time`.
+
+## Advanced
+
+### Configuration
+
+```ts
+import { PerplexitySearchClient } from "@tanstack/ai-perplexity";
+
+const client = new PerplexitySearchClient({
+ apiKey: process.env.PERPLEXITY_API_KEY, // optional, explicit key
+ baseURL: "https://api.perplexity.ai", // optional, override
+ fetch: globalThis.fetch, // optional, custom fetch
+});
```
-`getPerplexityIntegrationHeaders()` is optional. It adds Perplexity's `X-Pplx-Integration` attribution header (`tanstack/`). The Search client sends it automatically; pass it into `openaiCompatible` if you want the same header on Sonar chat requests.
+The client calls `POST {baseURL}/search` with a bearer token.
+
+### Bring your own key
-The OpenAI SDK then calls `POST https://api.perplexity.ai/chat/completions` (Perplexity's OpenAI-compatible alias for Sonar).
+`perplexityByok` from `@tanstack/ai-perplexity/byok` declares Perplexity for the BYOK store, with `PERPLEXITY_API_KEY` and `PPLX_API_KEY` as its env vars. See [Bring Your Own Key](../advanced/byok).
-## References
+### Perplexity references
-- Search quickstart:
-- Search API reference:
-- Domain filters:
-- Date / recency filters:
-- [OpenAI-compatible adapter](./openai-compatible.md) — Sonar `chat()`
+- [Search quickstart](https://docs.perplexity.ai/docs/search/quickstart)
+- [Search API reference](https://docs.perplexity.ai/api-reference/search-post)
+- [Domain filters](https://docs.perplexity.ai/docs/search/filters/domain-filter)
+- [Date and recency filters](https://docs.perplexity.ai/docs/search/filters/date-time-filters)
diff --git a/docs/adapters/reactor.md b/docs/adapters/reactor.md
index 60e4e9368d..4941c9607f 100644
--- a/docs/adapters/reactor.md
+++ b/docs/adapters/reactor.md
@@ -1,7 +1,7 @@
---
title: Reactor
id: reactor-adapter
-description: "Generate live worlds and video with Reactor models in TanStack AI via the @tanstack/ai-reactor adapter."
+description: "Stream live worlds and live video from Reactor models in TanStack AI with @tanstack/ai-reactor. The server mints a session token, and the browser plays and steers the stream."
keywords:
- tanstack ai
- reactor
@@ -14,48 +14,57 @@ keywords:
- adapter
---
-Reactor hosts live world and video models. You describe a scene. Then you open a session and stream video. You can steer the stream with a new prompt.
+The Reactor adapter opens a live world or live video session. The server mints a session token. The browser plays and steers the stream. It has no chat.
-Use `reactorWorld()` with `generateWorld()` for navigable worlds. Use `reactorVideo()` with `generateLiveVideo()` for live video models. Both mint a session token. Neither supports `chat()`.
+- `reactorWorld()` with `generateWorld()`: a world the user can look at and move through.
+- `reactorVideo()` with `generateLiveVideo()`: a live video session.
-## Installation
+## 1. Install
+
+The server needs `@tanstack/ai-reactor`. The browser needs `@reactor-team/js-sdk` to connect and play the stream.
-react: @tanstack/ai-reactor
-vue: @tanstack/ai-reactor
-solid: @tanstack/ai-reactor
-svelte: @tanstack/ai-reactor
-preact: @tanstack/ai-reactor
-angular: @tanstack/ai-reactor
-vanilla: @tanstack/ai-reactor
-octane: @tanstack/ai-reactor
+react: @tanstack/ai @tanstack/ai-reactor @reactor-team/js-sdk
+vue: @tanstack/ai @tanstack/ai-reactor @reactor-team/js-sdk
+solid: @tanstack/ai @tanstack/ai-reactor @reactor-team/js-sdk
+svelte: @tanstack/ai @tanstack/ai-reactor @reactor-team/js-sdk
+preact: @tanstack/ai @tanstack/ai-reactor @reactor-team/js-sdk
+angular: @tanstack/ai @tanstack/ai-reactor @reactor-team/js-sdk
+vanilla: @tanstack/ai @tanstack/ai-reactor @reactor-team/js-sdk
+octane: @tanstack/ai @tanstack/ai-reactor @reactor-team/js-sdk
-Peer dependency: `@tanstack/ai`. The browser also needs `@reactor-team/js-sdk` to connect and play the stream. See [World Generation](../media/world-generation) and [Live Generation](../media/live-generation).
+## 2. Set the API key
-A full working app is in [`examples/ts-react-media`](https://github.com/TanStack/ai/tree/main/examples/ts-react-media). Open the World or Live tab.
+Create a key in the [Reactor dashboard](https://www.reactor.inc/dashboard). Keys start with `rk_`. Put the key in the environment of your server:
+
+```bash
+REACTOR_API_KEY=rk_...
+```
-## API key
+`reactorWorld` and `reactorVideo` read `REACTOR_API_KEY`. To pass the key in code, use `createReactorWorld` or `createReactorVideo`:
-Create a key in the [Reactor dashboard](https://www.reactor.inc/dashboard). Keys start with `rk_`.
+```ts
+import { createReactorWorld } from '@tanstack/ai-reactor'
+
+const adapter = createReactorWorld('visko-orbis-stable', process.env.REACTOR_API_KEY!)
+```
-The example app uses [Bring Your Own Key](../advanced/byok). The browser pastes the key. The relay reads `x-byok-reactor`, then `REACTOR_API_KEY`.
+## 3. Mint a world session on the server
+
+Add a `POST` route at `/api/world`:
```ts
import { generateWorld } from '@tanstack/ai'
-import { byokMissing, getByokKey } from '@tanstack/ai/byok/server'
import { reactorWorld } from '@tanstack/ai-reactor'
-import { reactorByok } from '@tanstack/ai-reactor/byok'
-
-export async function POST(request: Request) {
- const apiKey = getByokKey(request, reactorByok)
- if (!apiKey) return byokMissing(reactorByok)
+export async function POST() {
const world = await generateWorld({
- adapter: reactorWorld('visko-orbis-stable', { apiKey }),
+ adapter: reactorWorld('visko-orbis-stable'),
prompt: 'A neon cyberpunk city at night, slow aerial drift',
+ threadId: 'thread-1',
})
return Response.json({
@@ -67,46 +76,94 @@ export async function POST(request: Request) {
}
```
-The adapter mints a **session-scoped** token for that model only. Hand `world.token`, `world.model`, and `world.prompt` to the browser. Do not put the API key in the JSON body.
+The token works for this one model only. The API key stays on the server. `world.model` is the Reactor connect slug, here `reactor/visko-orbis-stable`.
-## Models
+## 4. Play the world in the browser
-Pass a string literal so TypeScript can narrow options.
+Connect with the token and play the stream in a `
))}
-
- Send
+ setInput(event.target.value)} />
);
}
```
-`useChat` does not know this is Vertex. It only consumes the SSE stream from your server.
+For Vue, Solid, Svelte, Angular, and the other frameworks, see [Quick Start](../getting-started/quick-start#3-render-the-chat).
+
+You now have Gemini on Vertex, in the region that you chose.
## Other Gemini activities
-Every factory uses the same auth object.
+Each factory takes the same auth object:
```typescript
import {
@@ -216,59 +144,104 @@ vertexAudio("lyria-3-pro-preview", auth);
vertexVideo("veo-3.1-generate-preview", auth);
```
-Model ids and provider options are the same as `@tanstack/ai-gemini`. Vertex-only image options (for example `9:21`) are not unlocked in this release.
+- `vertexSpeech`, `vertexAudio`, and `vertexVideo` are experimental.
+- `vertexText` ignores [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call), because the `@google/genai` SDK does not take a custom fetch.
-## Environment variables
+## Models and options
-| Variable | Purpose |
-| --- | --- |
-| `GOOGLE_CLOUD_PROJECT` | GCP project id |
-| `GOOGLE_VERTEX_PROJECT` | Alias for the project id |
-| `GOOGLE_CLOUD_LOCATION` | Region, for example `europe-west1` |
-| `GOOGLE_VERTEX_LOCATION` | Alias for the region |
-| `GOOGLE_VERTEX_API_KEY` | Vertex express API key |
-| `GOOGLE_APPLICATION_CREDENTIALS` | Path to a service account JSON file |
+The model ids and the provider options are the same as in [`@tanstack/ai-gemini`](./gemini#models-and-options). Image options that only Vertex has (for example `9:21`) do not work in this release.
-## API reference
+## Advanced
-### `vertexText(model, config?)`
+### Authentication
-Creates a Gemini chat adapter on Vertex.
+Vertex factories accept every auth option that `@google/genai` accepts.
-### `vertexSummarize(model, config?)`
+#### Application Default Credentials
-Creates a Gemini summarize adapter on Vertex.
+This is the usual Google Cloud path. Sign in with `gcloud auth application-default login`, or set `GOOGLE_APPLICATION_CREDENTIALS` to a service account JSON file. Then pass `project` and `location` on the factory, or set them in the environment:
-### `vertexImage(model, config?)`
+```typescript
+import { vertexText } from "@tanstack/ai-vertex";
-Creates a Gemini image adapter on Vertex.
+const adapter = vertexText("gemini-3.7-flash", {
+ project: "my-project",
+ location: "europe-west1",
+});
+```
-### `vertexEmbedding(model, config?)`
+#### Service account fields
-Creates a Gemini embedding adapter on Vertex.
+```typescript
+import { vertexText } from "@tanstack/ai-vertex";
-### `vertexSpeech(model, config?)`
+const adapter = vertexText("gemini-3.7-flash", {
+ project: "my-project",
+ location: "europe-west1",
+ googleAuthOptions: {
+ credentials: {
+ client_email: "sa@my-project.iam.gserviceaccount.com",
+ private_key: "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n",
+ },
+ },
+});
+```
-Creates a Gemini text-to-speech adapter on Vertex. Experimental.
+#### Express API key
-### `vertexAudio(model, config?)`
+Vertex express mode uses an API key. It needs no project and no location.
-Creates a Gemini Lyria audio adapter on Vertex. Experimental.
+```typescript
+import { vertexText } from "@tanstack/ai-vertex";
-### `vertexVideo(model, config?)`
+const adapter = vertexText("gemini-3.7-flash", {
+ apiKey: "vertex-express-key",
+});
+```
-Creates a Gemini video adapter on Vertex. Experimental. `config.allowUrlFetch` is the same opt-in as the Gemini video adapter.
+You can also set `GOOGLE_VERTEX_API_KEY`.
-`config` accepts `project`, `location`, `apiKey`, `googleAuthOptions`, `httpOptions`, and the other `@google/genai` client fields. The factory always sets `vertexai: true`.
+### Environment variables
-## Claude on Vertex
+| Variable | Purpose |
+| --- | --- |
+| `GOOGLE_CLOUD_PROJECT` | GCP project id |
+| `GOOGLE_VERTEX_PROJECT` | Alias for the project id |
+| `GOOGLE_CLOUD_LOCATION` | Region, for example `europe-west1` |
+| `GOOGLE_VERTEX_LOCATION` | Alias for the region |
+| `GOOGLE_VERTEX_API_KEY` | Vertex express API key |
+| `GOOGLE_APPLICATION_CREDENTIALS` | Path to a service account JSON file |
+
+### Claude on Vertex
+
+Use [`anthropicVertexText`](./anthropic-advanced#claude-on-vertex) from `@tanstack/ai-anthropic/vertex`.
-Use [`anthropicVertexText`](./anthropic#claude-on-vertex) from `@tanstack/ai-anthropic/vertex`.
+### Grok on Vertex
-## Grok on Vertex
+Use [`grokVertexText`](./grok#grok-on-vertex) from `@tanstack/ai-grok/vertex`.
-Use [`grokVertexText`](./grok#grok-on-vertex) from `@tanstack/ai-grok/vertex`. Vertex Grok uses the OpenAI-compatible Responses endpoint. Use `grokVertexSummarize` from the same entry for summarize. The factory accepts only the Grok chat models in the Vertex catalog (`grok-4.3`, `grok-4.20-reasoning`, `grok-4.20-non-reasoning`, `grok-4.1-fast-reasoning`, `grok-4.1-fast-non-reasoning`).
+- Vertex Grok uses the OpenAI-compatible Responses endpoint.
+- For summarize, use `grokVertexSummarize` from the same entry.
+- The factory accepts only the Grok chat models in the Vertex catalog: `grok-4.3`, `grok-4.20-reasoning`, `grok-4.20-non-reasoning`, `grok-4.1-fast-reasoning`, and `grok-4.1-fast-non-reasoning`.
-## Mistral on Vertex
+### Mistral on Vertex
-Use [`mistralVertexText`](./mistral#mistral-on-vertex) from `@tanstack/ai-mistral/vertex`. Vertex Mistral uses the publisher `rawPredict` path. It is regional only (`us-central1` or `europe-west4`). The factory accepts only the Mistral chat models in the Vertex catalog (`mistral-medium-3`, `mistral-small-2503`, `codestral-2`).
+Use [`mistralVertexText`](./mistral#mistral-on-vertex) from `@tanstack/ai-mistral/vertex`.
+
+- Vertex Mistral uses the publisher `rawPredict` path.
+- It is regional only: `us-central1` or `europe-west4`.
+- The factory accepts only the Mistral chat models in the Vertex catalog: `mistral-medium-3`, `mistral-small-2503`, and `codestral-2`.
+
+### API reference
+
+| Factory | Creates |
+| --- | --- |
+| `vertexText(model, config?)` | Gemini chat adapter on Vertex |
+| `vertexSummarize(model, config?)` | Gemini summarize adapter on Vertex |
+| `vertexImage(model, config?)` | Gemini image adapter on Vertex |
+| `vertexEmbedding(model, config?)` | Gemini embedding adapter on Vertex |
+| `vertexSpeech(model, config?)` | Gemini text-to-speech adapter on Vertex. Experimental |
+| `vertexAudio(model, config?)` | Gemini Lyria audio adapter on Vertex. Experimental |
+| `vertexVideo(model, config?)` | Gemini video adapter on Vertex. Experimental. `config.allowUrlFetch` is the same opt-in as on the Gemini video adapter |
+
+`config` accepts `project`, `location`, `apiKey`, `googleAuthOptions`, `httpOptions`, and the other `@google/genai` client fields. The factory always sets `vertexai: true`.
diff --git a/docs/adapters/worldlabs.md b/docs/adapters/worldlabs.md
index fc363db54b..1a9b83ba7e 100644
--- a/docs/adapters/worldlabs.md
+++ b/docs/adapters/worldlabs.md
@@ -1,7 +1,7 @@
---
title: World Labs
id: worldlabs-adapter
-description: "Generate persistent 3D Marble worlds with World Labs in TanStack AI via the @tanstack/ai-worldlabs adapter."
+description: "Make finished 3D Marble worlds with World Labs in TanStack AI with @tanstack/ai-worldlabs: a viewer URL, splat files, and a mesh from a prompt, an image, or a video."
keywords:
- tanstack ai
- worldlabs
@@ -12,75 +12,96 @@ keywords:
- adapter
---
-You want a finished 3D world from a prompt, an image, or a video. World Labs Marble generates splat files, a mesh, and a viewer URL. Generation often takes several minutes.
+`generateWorld()` calls World Labs Marble. A prompt, image, or video returns a viewer URL, and often splat files, a mesh, and a panorama. The adapter has no chat.
-Use `worldlabsWorld()` with `generateWorld()`. This adapter does not support `chat()`.
-
-## Installation
+## 1. Install
-react: @tanstack/ai-worldlabs
-vue: @tanstack/ai-worldlabs
-solid: @tanstack/ai-worldlabs
-svelte: @tanstack/ai-worldlabs
-preact: @tanstack/ai-worldlabs
-angular: @tanstack/ai-worldlabs
-vanilla: @tanstack/ai-worldlabs
-octane: @tanstack/ai-worldlabs
+react: @tanstack/ai @tanstack/ai-worldlabs
+vue: @tanstack/ai @tanstack/ai-worldlabs
+solid: @tanstack/ai @tanstack/ai-worldlabs
+svelte: @tanstack/ai @tanstack/ai-worldlabs
+preact: @tanstack/ai @tanstack/ai-worldlabs
+angular: @tanstack/ai @tanstack/ai-worldlabs
+vanilla: @tanstack/ai @tanstack/ai-worldlabs
+octane: @tanstack/ai @tanstack/ai-worldlabs
-Peer dependency: `@tanstack/ai`. A full working app is in [`examples/ts-react-media`](https://github.com/TanStack/ai/tree/main/examples/ts-react-media). Open the World tab and pick a Marble model.
+## 2. Set the API key
+
+Create a key in the [World Labs platform](https://platform.worldlabs.ai/api-keys). Put it in the environment of your server:
-## API key
+```bash
+WORLDLABS_API_KEY=your-worldlabs-key
+```
-Create a key in the [World Labs platform](https://platform.worldlabs.ai/api-keys). Set `WORLDLABS_API_KEY`, or pass `apiKey`.
+`worldlabsWorld` reads `WORLDLABS_API_KEY`. To pass the key in code, use `createWorldLabsWorld`:
```ts
-import { generateWorld } from '@tanstack/ai'
-import { worldlabsWorld } from '@tanstack/ai-worldlabs'
+import { createWorldLabsWorld } from '@tanstack/ai-worldlabs'
-const world = await generateWorld({
- adapter: worldlabsWorld('marble-1.1'),
- prompt: 'A mystical forest with glowing mushrooms',
-})
+const adapter = createWorldLabsWorld('marble-1.1', process.env.WORLDLABS_API_KEY!)
```
-To pass a key explicitly:
+## 3. Generate a world on the server
+
+Add a `POST` route at `/api/world`. The call waits until the world is ready, so give it a long `timeout`:
```ts
+import { generateWorld } from '@tanstack/ai'
import { worldlabsWorld } from '@tanstack/ai-worldlabs'
-const adapter = worldlabsWorld('marble-1.1', {
- apiKey: process.env.WORLDLABS_API_KEY!,
-})
+export async function POST() {
+ const world = await generateWorld({
+ adapter: worldlabsWorld('marble-1.1'),
+ prompt: 'A mystical forest with glowing mushrooms',
+ threadId: 'thread-1',
+ timeout: 10 * 60 * 1000,
+ })
+
+ return Response.json({
+ url: world.url,
+ thumbnailUrl: world.assets?.thumbnailUrl,
+ })
+}
```
-`world.url` is the Marble viewer URL (`https://marble.worldlabs.ai/world/{id}`). Do not iframe it. `world.assets` is optional. Splat, mesh, panorama, and thumbnail links appear only when the World Labs response includes them. Those URLs are often signed CDN links.
+World Labs bills in credits for each generation.
-World Labs bills in credits per generation. The call waits until the world is ready (often several minutes). Pass a long `timeout` on serverless, or set `wait: false` and poll later.
+## 4. Show the world in the browser
-## Models
-
-Pass a string literal so TypeScript can narrow options.
+`world.url` is the Marble viewer (`https://marble.worldlabs.ai/world/{id}`). Open it in a new tab. Do not put it in an iframe:
```ts
-import { worldlabsWorld } from '@tanstack/ai-worldlabs'
-
-const adapter = worldlabsWorld('marble-1.1')
+const world = await fetch('/api/world', { method: 'POST' }).then((response) =>
+ response.json(),
+)
+
+if (typeof world.url === 'string') {
+ const link = document.createElement('a')
+ link.href = world.url
+ link.target = '_blank'
+ link.textContent = 'Open in Marble'
+ document.body.append(link)
+}
+
+if (typeof world.thumbnailUrl === 'string') {
+ const image = document.createElement('img')
+ image.src = world.thumbnailUrl
+ image.alt = 'Preview of the generated world'
+ document.body.append(image)
+}
```
-| Id | Notes |
-| --- | --- |
-| `marble-1.1-plus` | Dynamic world sizing |
-| `marble-1.1` | World Labs default model id |
-| `marble-1.0` | Marble 1.0 |
-| `marble-1.0-draft` | Faster draft quality |
+You now have a 3D world and a link to explore it.
+
+A full app is in [`examples/ts-react-media`](https://github.com/TanStack/ai/tree/main/examples/ts-react-media). Open the World tab and pick a Marble model. The example loads one SPZ splat file in Spark (it prefers `500k`) through `/api/marble-splat`. If there is no splat URL, it shows a thumbnail and an Open in Marble link.
## Image, multi-image, and video
-The `prompt` argument is the text. Put media on `modelOptions`.
+`prompt` is the text. Put the media in `modelOptions`:
```ts
import { generateWorld } from '@tanstack/ai'
@@ -89,6 +110,7 @@ import { worldlabsWorld } from '@tanstack/ai-worldlabs'
const world = await generateWorld({
adapter: worldlabsWorld('marble-1.1'),
prompt: 'A cozy living room',
+ threadId: 'thread-1',
modelOptions: {
image: { uri: 'https://example.com/room.jpg' },
isPano: 'auto',
@@ -96,21 +118,70 @@ const world = await generateWorld({
})
```
-Pass only one of `image`, `images`, or `video`.
+Pass only one of `image`, `images`, or `video`. See [Media options](#media-options) for each field.
+
+## Models and options
+
+Pass the id as a string literal, so TypeScript can narrow the options.
+
+| Id | Notes |
+| --- | --- |
+| `marble-1.1-plus` | Dynamic world sizing |
+| `marble-1.1` | The World Labs default model id |
+| `marble-1.0` | Marble 1.0 |
+| `marble-1.0-draft` | Faster, at draft quality |
+
+The package exports the list as `WORLDLABS_WORLD_MODELS`.
+
+### Media options
| Option | Meaning |
| --- | --- |
-| `image` | One image. `uri`, `mediaAssetId`, or `dataBase64` |
-| `images` | Several images of the same scene, each with optional `azimuth` |
-| `video` | One video. Same source fields as `image` |
-| `isPano` | `auto`, `true`, or `false` for a single image |
-| `wait` | Default `true`. Set `false` to return `operationId` at once |
-| `pollIntervalMs` | Poll delay when `wait` is true. Default 2000 |
+| `image` | One image. Give `uri`, `mediaAssetId`, or `dataBase64` |
+| `images` | Several images of the same scene, each with an optional `azimuth` |
+| `video` | One video. The same source fields as `image` |
+| `isPano` | `auto`, `true`, or `false`, for a single image |
+
+### Job options
+
+| Option | Meaning |
+| --- | --- |
+| `wait` | The default is `true`. Set `false` to get the `operationId` at once |
+| `pollIntervalMs` | The delay between polls when `wait` is `true`. The default is 2000 |
+
+Optional metadata: `displayName`, `seed`, `tags`, `disableRecaption`, and `permission`.
-Optional metadata: `displayName`, `seed`, `tags`, `disableRecaption`, `permission`.
+## Advanced
-When `wait` is `false`, `world.status` is `waiting` and `world.operationId` is set. This adapter cannot resume that id. Call `generateWorld` again with `wait: true` only starts a new job. Poll `GET /marble/v1/operations/{operationId}` yourself, or keep `wait` at the default.
+### What the result holds
+
+- `world.url`: the Marble viewer URL.
+- `world.assets`: optional. It has splat, mesh, panorama, and thumbnail links only when the World Labs response includes them. These URLs are often signed CDN links.
+- `world.assets.splats.spzUrls`: splat URLs by quality key, for example `100k`, `500k`, and `full_res`.
+
+### Jobs that do not wait
+
+On serverless, a job that runs for minutes can hit the limit of your platform. You have two choices:
+
+- Pass a long `timeout` to `generateWorld()`, as in step 3.
+- Set `modelOptions: { wait: false }` and poll the job yourself.
+
+With `wait: false`, `world.status` is `waiting` and `world.operationId` is set. The adapter cannot resume that id. A new `generateWorld()` call with `wait: true` starts a new job. To get the result, poll `GET /marble/v1/operations/{operationId}` yourself.
+
+### Bring your own key
+
+`worldlabsByok` from `@tanstack/ai-worldlabs/byok` declares World Labs for the BYOK store, with `WORLDLABS_API_KEY` as its env var. See [Bring Your Own Key](../advanced/byok).
+
+### API reference
+
+| Factory | Creates |
+| --- | --- |
+| `worldlabsWorld(model, config?)` | A world adapter. Reads `WORLDLABS_API_KEY` when `config.apiKey` is not set. |
+| `createWorldLabsWorld(model, apiKey, config?)` | The same adapter with an explicit API key. |
-## What you have now
+The `config` fields:
-A server call that generates a Marble world and returns the viewer URL plus optional asset links. The media example picks one SPZ (prefers `500k`) and loads it in Spark through `/api/marble-splat`. If no splat URL is present, it shows a thumbnail and an Open in Marble link.
+- `apiKey`: the World Labs API key.
+- `baseUrl`: the API base URL. The default is `https://api.worldlabs.ai`.
+- `defaultHeaders`: extra request headers.
+- `fetch`: your own `fetch` function.
diff --git a/docs/advanced/built-in-middleware.md b/docs/advanced/built-in-middleware.md
index 752df1f572..bbf71fe857 100644
--- a/docs/advanced/built-in-middleware.md
+++ b/docs/advanced/built-in-middleware.md
@@ -2,7 +2,7 @@
title: Built-in Middleware
id: built-in-middleware
order: 2
-description: "Ready-made TanStack AI chat() middleware — toolCacheMiddleware for caching tool results, contentGuardMiddleware for redacting streamed text, and otelMiddleware for OpenTelemetry tracing."
+description: "Ready-made chat() middleware: toolCacheMiddleware caches tool results, contentGuardMiddleware redacts streamed text, and otelMiddleware sends OpenTelemetry traces."
keywords:
- tanstack ai
- middleware
@@ -13,23 +13,70 @@ keywords:
- opentelemetry
---
-TanStack AI ships ready-made middleware so you don't have to hand-roll the common cases. Each one is an ordinary [`ChatMiddleware`](./middleware) — drop it into the `middleware` array of any `chat()` call. This page documents every built-in.
+Built-in middleware caches a tool result and redacts text from a reply. Use it when the same tool call repeats, or when a reply must hide a value.
+
+TanStack AI ships ready-made middleware. Add one to the `middleware` array of `chat()`:
| Middleware | Import | What it does |
-|------------|--------|--------------|
-| `toolCacheMiddleware` | `@tanstack/ai/middlewares` | Cache tool-call results by name + arguments |
-| `contentGuardMiddleware` | `@tanstack/ai/middlewares` | Redact / transform / block streamed text content |
-| `otelMiddleware` | `@tanstack/ai/middlewares/otel` | Emit OpenTelemetry spans + GenAI metrics |
+| --- | --- | --- |
+| `toolCacheMiddleware` | `@tanstack/ai/middlewares` | Caches tool results by tool name and arguments |
+| `contentGuardMiddleware` | `@tanstack/ai/middlewares` | Redacts, changes, or blocks streamed text |
+| `otelMiddleware` | `@tanstack/ai/middlewares/otel` | Sends OpenTelemetry spans and GenAI metrics |
-> `toolCacheMiddleware` and `contentGuardMiddleware` are exported from the main `@tanstack/ai/middlewares` barrel. `otelMiddleware` lives on its own subpath (`@tanstack/ai/middlewares/otel`) so that importing the barrel never eagerly pulls in `@opentelemetry/api` (an optional peer dependency).
+## Quick setup
-For app-owned policies (for example tool-call budgets), see the [tool-call budget recipe](../chat/agentic-cycle#tool-call-budgets-middleware-recipe) — those stay in your code, not in `@tanstack/ai/middlewares`.
+Cache the results of a tool for 60 seconds:
-## toolCacheMiddleware
+```typescript
+// api/chat.ts
+import {
+ chat,
+ chatParamsFromRequest,
+ toolDefinition,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
+import { toolCacheMiddleware } from "@tanstack/ai/middlewares";
+import { openaiText } from "@tanstack/ai-openai";
+import { z } from "zod";
+
+const getWeather = toolDefinition({
+ name: "getWeather",
+ description: "Get the current weather for a city",
+ inputSchema: z.object({ city: z.string() }),
+}).server(async ({ city }) => {
+ console.log(`getWeather ran for ${city}`);
+ return { city, temperature: 21 };
+});
-Caches tool call results based on tool name and arguments. When a tool is called with the same name and arguments as a previous call, the cached result is returned immediately without re-executing the tool.
+// Create the cache once, so all requests share it.
+const weatherCache = toolCacheMiddleware({
+ ttl: 60_000,
+ toolNames: ["getWeather"],
+});
-Identical calls in one model turn run at the same time, so both of them miss the cache. To let the second call use the first result, set `toolExecution: 'sequential'` on `chat()`.
+export async function POST(request: Request) {
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
+
+ const stream = chat({
+ adapter: openaiText("gpt-6.1-sol"),
+ messages,
+ threadId,
+ runId,
+ tools: [getWeather],
+ middleware: [weatherCache],
+ });
+
+ return toServerSentEventsResponse(stream);
+}
+```
+
+Ask about the weather in Paris. Then start a new chat and ask again. The server console shows `getWeather ran for Paris` only one time. The second answer comes from the cache.
+
+Each built-in is a normal [`ChatMiddleware`](./middleware). You can combine them with your own middleware in the same array.
+
+## toolCacheMiddleware
+
+`toolCacheMiddleware` caches tool results by tool name and arguments. When the model calls a tool with the same name and arguments again, the middleware returns the cached result. The tool does not run again.
```typescript
import { chat } from "@tanstack/ai";
@@ -38,37 +85,33 @@ import { toolCacheMiddleware } from "@tanstack/ai/middlewares";
import { weatherTool, stockTool } from "./tools";
const stream = chat({
- adapter: openaiText("gpt-5.5"),
+ adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "What's the weather in Paris?" }],
+ threadId: "thread-1",
tools: [weatherTool, stockTool],
middleware: [
toolCacheMiddleware({
- ttl: 60_000, // Cache entries expire after 60 seconds
+ ttl: 60_000, // Entries expire after 60 seconds
maxSize: 50, // Keep at most 50 entries (LRU eviction)
- toolNames: ["getWeather"], // Only cache specific tools
+ toolNames: ["getWeather"], // Cache only these tools
}),
],
});
```
-**Options:**
+What it does:
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `maxSize` | `number` | `100` | Maximum cache entries. Oldest evicted first (LRU). Only applies to the default in-memory storage. |
-| `ttl` | `number` | `Infinity` | Time-to-live in milliseconds. Expired entries are not served. |
-| `toolNames` | `string[]` | All tools | Only cache these tools. Others pass through. |
-| `keyFn` | `(toolName, args) => string` | `JSON.stringify([toolName, args])` | Custom cache key derivation. |
-| `storage` | `ToolCacheStorage` | In-memory Map | Custom storage backend. When provided, `maxSize` is ignored — the storage manages its own capacity. |
+- It caches only successful tool calls. It never stores errors.
+- A cache hit returns `{ type: 'skip', result }` from `onBeforeToolCall`.
+- When the default storage reaches `maxSize`, it removes the oldest entry (LRU).
+- A cache hit moves the entry to the most recently used position.
-**Behaviors:**
+> [!NOTE]
+> Identical calls in one model turn run at the same time, so both of them miss the cache. To let the second call use the first result, set `toolExecution: 'sequential'` on `chat()`.
-- Only successful tool calls are cached — errors are never stored
-- Cache hits trigger `{ type: 'skip', result }` via `onBeforeToolCall`
-- LRU eviction: when `maxSize` is reached, the oldest entry is removed (default storage only)
-- Cache hits refresh the entry's LRU position (moved to most-recently-used)
+### Ignore some arguments in the cache key
-**Custom key function** — useful when you want to ignore certain arguments:
+Use `keyFn` when some arguments must not change the key. Here the cache ignores the page number:
```typescript
import { toolCacheMiddleware } from "@tanstack/ai/middlewares";
@@ -79,8 +122,7 @@ function isRecord(value: unknown): value is Record {
toolCacheMiddleware({
keyFn: (toolName, args) => {
- // Ignore pagination, cache by query only. `args` is `unknown`, so
- // narrow it with a type guard before destructuring.
+ // `args` is `unknown`, so narrow it with a type guard first.
if (!isRecord(args)) return JSON.stringify([toolName, args]);
const { page, ...rest } = args;
return JSON.stringify([toolName, rest]);
@@ -88,16 +130,15 @@ toolCacheMiddleware({
});
```
-### Custom Storage
+### Custom storage
-By default the cache lives in-memory and is scoped to a single `toolCacheMiddleware()` instance. Pass a `storage` option to use an external backend like Redis, localStorage, or a database. This also enables **sharing a cache across multiple `chat()` calls**.
+The default cache lives in memory, inside one `toolCacheMiddleware()` instance. Pass a `storage` option to use another backend, like Redis, localStorage, or a database. A shared storage also shares the cache across `chat()` calls.
-The storage interface:
+Implement the `ToolCacheStorage` interface from `@tanstack/ai/middlewares`:
```typescript
import { type ToolCacheEntry, type ToolCacheStorage } from "@tanstack/ai/middlewares";
-// Implement this interface (exported from `@tanstack/ai/middlewares`):
interface MyStorage extends ToolCacheStorage {
getItem: (key: string) => ToolCacheEntry | undefined | Promise;
setItem: (key: string, value: ToolCacheEntry) => void | Promise;
@@ -107,9 +148,11 @@ interface MyStorage extends ToolCacheStorage {
// ToolCacheEntry is { result: unknown; timestamp: number }
```
-All methods may return a `Promise` for async backends. The middleware handles TTL checking — your storage just needs to store and retrieve entries.
+- Each method can return a `Promise`, for async backends.
+- The middleware checks the TTL. Your storage only stores and returns entries.
+- With a custom storage, the middleware ignores `maxSize`. The storage controls its own size.
-**Redis example:**
+A Redis storage:
```typescript
import { chat } from "@tanstack/ai";
@@ -136,41 +179,65 @@ const redisStorage: ToolCacheStorage = {
const stream = chat({
adapter,
messages,
+ threadId: "thread-1",
tools: [weatherTool],
middleware: [toolCacheMiddleware({ storage: redisStorage, ttl: 60_000 })],
});
```
-**Sharing a cache across requests:**
+To share a cache across requests in one process, create the storage once, outside the request handler:
```typescript
-import { chat, toServerSentEventsResponse } from "@tanstack/ai";
-import { toolCacheMiddleware, type ToolCacheStorage } from "@tanstack/ai/middlewares";
-import { globalCache, app, adapter } from "./server";
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
+import {
+ toolCacheMiddleware,
+ type ToolCacheEntry,
+ type ToolCacheStorage,
+} from "@tanstack/ai/middlewares";
+import { openaiText } from "@tanstack/ai-openai";
import { weatherTool } from "./tools";
-// Create storage once, reuse across chat() calls
+const entries = new Map();
+
const sharedStorage: ToolCacheStorage = {
- getItem: (key) => globalCache.get(key),
- setItem: (key, value) => { globalCache.set(key, value); },
- deleteItem: (key) => { globalCache.delete(key); },
+ getItem: (key) => entries.get(key),
+ setItem: (key, value) => {
+ entries.set(key, value);
+ },
+ deleteItem: (key) => {
+ entries.delete(key);
+ },
};
-// Both requests share the same cache
-app.post("/api/chat", async (req: { body: { messages: unknown[] } }) => {
+export async function POST(request: Request) {
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
+
const stream = chat({
- adapter,
- messages: req.body.messages,
+ adapter: openaiText("gpt-6.1-sol"),
+ messages,
+ threadId,
+ runId,
tools: [weatherTool],
middleware: [toolCacheMiddleware({ storage: sharedStorage })],
});
+
return toServerSentEventsResponse(stream);
-});
+}
```
## contentGuardMiddleware
-Filters or transforms streamed text content as it flows through `onChunk`. Use it to redact sensitive data (SSNs, emails, API keys), enforce a profanity filter, or rewrite text on the fly. Rules are applied to `TEXT_MESSAGE_CONTENT` chunks; all other chunk types pass through untouched.
+`contentGuardMiddleware` filters or changes streamed text in `onChunk`. Use it to:
+
+- Redact sensitive data (SSNs, emails, API keys).
+- Filter profanity.
+- Rewrite text while it streams.
+
+The rules apply to `TEXT_MESSAGE_CONTENT` chunks only. All other chunk types pass through unchanged.
```typescript
import { chat } from "@tanstack/ai";
@@ -178,12 +245,13 @@ import { openaiText } from "@tanstack/ai-openai";
import { contentGuardMiddleware } from "@tanstack/ai/middlewares";
const stream = chat({
- adapter: openaiText("gpt-5.5"),
+ adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "Tell me about customer 123-45-6789" }],
+ threadId: "thread-1",
middleware: [
contentGuardMiddleware({
rules: [
- // Regex + replacement
+ // Regex and replacement
{ pattern: /\b\d{3}-\d{2}-\d{4}\b/g, replacement: "[SSN REDACTED]" },
// Custom transform function
{ fn: (text) => text.replaceAll("badword", "****") },
@@ -194,31 +262,26 @@ const stream = chat({
});
```
-**Options:**
+### Matching strategies
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `rules` | `ContentGuardRule[]` | — | **Required.** Applied in order; each rule receives the previous rule's output. A rule is either `{ pattern: RegExp; replacement: string }` or `{ fn: (text: string) => string }`. |
-| `strategy` | `'delta' \| 'buffered'` | `'buffered'` | How content is matched. See below. |
-| `bufferSize` | `number` | `50` | (Buffered only) Characters held back before emitting, so patterns spanning chunk boundaries still match. Set it ≥ the longest pattern you expect. Flushed at stream end. |
-| `blockOnMatch` | `boolean` | `false` | When `true`, drop the entire chunk if any rule changes the content (instead of emitting the filtered version). |
-| `onFiltered` | `(info: ContentFilteredInfo) => void` | — | Callback fired whenever a rule changes content. Receives `{ messageId, original, filtered, strategy }`. |
+- **`'buffered'` (default).** The middleware collects the text and applies the rules to the settled part. It holds back the last `bufferSize` characters, so it still catches a pattern split across two chunks (`"...123-45"` then `"-6789..."`). It flushes the buffer when the message or the run ends. Use it for most redaction.
+- **`'delta'`.** The middleware applies the rules to each delta alone, when it arrives. This is the fastest, but a pattern split across two chunks can get through. If each pattern always fits in one delta, you can use it.
-**Matching strategies:**
+### What it does
-- **`'buffered'` (default)** — Accumulates content and applies rules to the settled portion, holding back a `bufferSize` look-behind window so a pattern split across two chunks (`"...123-45"` then `"-6789..."`) is still caught. The buffer is flushed when the message or run ends. Use this for anything that can span deltas — which is most redaction.
-- **`'delta'`** — Applies rules to each delta in isolation as it arrives. Fastest and lowest-latency, but a pattern split across a chunk boundary may slip through. Use only when your patterns are guaranteed to fit within a single delta.
+- A rule that does not change the text does nothing. The chunk passes through unchanged.
+- With `blockOnMatch: true`, the middleware drops a matched chunk (it returns `null` from `onChunk`). It does not send the redacted text.
+- `onFiltered` is for logs and audits. It gets the text before and after, but it does not change what goes out.
-**Behaviors:**
+## otelMiddleware
-- Only `TEXT_MESSAGE_CONTENT` chunks are inspected; every other chunk type passes through.
-- A rule that doesn't change the text is a no-op — the chunk passes through unchanged.
-- With `blockOnMatch: true`, a matched chunk is dropped entirely (returns `null` from `onChunk`) rather than emitting the redacted text.
-- The `onFiltered` callback is for observability/audit — it fires with the before/after text but does not alter what is emitted.
+`otelMiddleware` sends OpenTelemetry traces and metrics for each `chat()` call:
-## otelMiddleware
+- A root span for each call.
+- A child span for each agent-loop iteration.
+- A grandchild span for each tool run.
-Emits vendor-neutral OpenTelemetry traces and metrics for every `chat()` call — a root span per call, a child span per agent-loop iteration, and a grandchild span per tool execution, all tagged with [GenAI semantic-convention attributes](https://opentelemetry.io/docs/specs/semconv/gen-ai/).
+All spans have [GenAI semantic-convention attributes](https://opentelemetry.io/docs/specs/semconv/gen-ai/).
```typescript
import { chat } from "@tanstack/ai";
@@ -229,23 +292,47 @@ import { messages } from "./server";
const otel = otelMiddleware({
tracer: trace.getTracer("my-app"),
- meter: metrics.getMeter("my-app"), // optional — enables GenAI histograms
+ meter: metrics.getMeter("my-app"), // optional, turns on the GenAI histograms
});
const result = await chat({
- adapter: openaiText("gpt-5.5"),
+ adapter: openaiText("gpt-6.1-sol"),
messages,
+ threadId: "thread-1",
middleware: [otel],
});
```
-`otelMiddleware` has its own configuration surface (content capture, redaction, span-name formatting, attribute enrichment, lifecycle callbacks) and requires the optional `@opentelemetry/api` peer dependency. See the dedicated [OpenTelemetry](./otel) guide for full setup, the span/metric catalogue, and all options.
+`otelMiddleware` needs the optional `@opentelemetry/api` peer dependency. It has its own options for content capture, redaction, span names, extra attributes, and lifecycle callbacks. See [OpenTelemetry](./otel) for the setup, the list of spans and metrics, and all options.
+
+## Write your own
+
+The built-ins are normal `ChatMiddleware` objects. To write your own, see [Middleware](./middleware).
+
+## Advanced
-## Writing your own
+### Import paths
-These built-ins are just `ChatMiddleware` objects — nothing about them is privileged. To build your own, see the [Middleware](./middleware) guide for the full hook reference, the context object, and composition rules.
+`toolCacheMiddleware` and `contentGuardMiddleware` come from the `@tanstack/ai/middlewares` barrel. `otelMiddleware` has its own subpath, `@tanstack/ai/middlewares/otel`. So an import from the barrel never loads `@opentelemetry/api`, which is an optional peer dependency.
-## Next Steps
+Policies that belong to your app, like tool-call budgets, stay in your code. See the [tool-call budget recipe](../chat/agentic-cycle#tool-call-budgets-middleware-recipe).
-- [Middleware](./middleware) — the full lifecycle and hook reference
-- [OpenTelemetry](./otel) — `otelMiddleware` in depth
\ No newline at end of file
+### toolCacheMiddleware options
+
+| Option | Type | Default | Description |
+| --- | --- | --- | --- |
+| `maxSize` | `number` | `100` | The maximum number of entries. The oldest entry goes first (LRU). Applies to the default in-memory storage only. |
+| `ttl` | `number` | `Infinity` | Time to live in milliseconds. The middleware does not return expired entries. |
+| `toolNames` | `string[]` | All tools | Cache only these tools. Other tools pass through. |
+| `keyFn` | `(toolName, args) => string` | `JSON.stringify([toolName, args])` | Builds the cache key. |
+| `storage` | `ToolCacheStorage` | In-memory Map | A custom storage backend. When you set it, the middleware ignores `maxSize`. |
+
+### contentGuardMiddleware options
+
+| Option | Type | Default | Description |
+| --- | --- | --- | --- |
+| `rules` | `ContentGuardRule[]` | none | **Required.** The rules apply in order. Each rule gets the output of the rule before it. A rule is `{ pattern: RegExp; replacement: string }` or `{ fn: (text: string) => string }`. |
+| `strategy` | `'delta' \| 'buffered'` | `'buffered'` | How the middleware matches text. See [Matching strategies](#matching-strategies). |
+| `bufferSize` | `number` | `50` | Buffered only. The number of characters to hold back, so patterns across chunk boundaries still match. Set it to at least the length of your longest pattern. The middleware flushes the buffer at the end of the stream. |
+| `blockOnMatch` | `boolean` | `false` | When `true`, drop the whole chunk if a rule changes the text. |
+| `onFiltered` | `(info: ContentFilteredInfo) => void` | none | Runs each time a rule changes the text. Gets `{ messageId, original, filtered, strategy }`. |
diff --git a/docs/advanced/byok.md b/docs/advanced/byok.md
index b4f399c499..945331b756 100644
--- a/docs/advanced/byok.md
+++ b/docs/advanced/byok.md
@@ -2,7 +2,7 @@
title: Bring Your Own Key (BYOK)
id: byok
order: 9
-description: "Let users bring their own API key so you do not pay for their model calls. The key stays in the browser."
+description: "Let users bring their own API key, so you do not pay for their model calls. The key stays in the browser and goes to your server on a header for one call."
keywords:
- tanstack ai
- byok
@@ -11,45 +11,31 @@ keywords:
- defineByok
---
-You do not want to pay for provider credits for every user. Let them bring their own API key.
+`defineByok` keeps each user's provider key in the browser. The server reads one key with `getByokKey` and does not store it.
-The key stays in the browser, so you do not have to worry about storing user keys on the server.
+Each send puts the key on an `x-byok-*` header.
-Your relay uses it for one call, then forgets it. Each send puts it on an `x-byok-*` header, not in the JSON body.
+## Quick setup
-Do these four steps:
+This setup takes about 10 minutes. You need a chat that already works with `useChat`.
-1. Create a store with `defineByok`.
-2. Save a key from your UI with `byok.update`.
-3. Pass the same store into `useChat`.
-4. Read the key on the relay with `getByokKey`.
+### 1. Create a store
-## 1. Create a store
-
-If you use a framework package, import BYOK from its `/byok` subpath. You do not need `@tanstack/ai-client`. The same subpath exists on Vue, Solid, Svelte, Preact, Angular, Octane, and Remix.
+Import BYOK from the `/byok` subpath of your framework package. Vue, Solid, Svelte, Preact, Angular, Octane, and Remix have the same subpath.
```typescript group=byok
import { defineByok, defaultByokStorage } from "@tanstack/ai-react/byok";
-
export const byok = defineByok({
storage: defaultByokStorage(),
});
```
-If the browser supports passkeys, `defaultByokStorage()` uses a passkey. If not, keys stay in memory for this tab only.
-
-Passkey storage runs a WebAuthn prompt to save and to unlock a key. Some browsers (Safari, Dia) only show that prompt right after a click. So call `byok.update()`, `byok.unlock()`, or `byok.prepare()` straight from the click handler, before you `await` other work. If you run the unlock deep in a send flow, the prompt may never appear and the call throws with a "needs a fresh user action" message instead. Unlock on the click, then send.
-
-This activation check applies to existing keyrings. First-time registration can require a second PRF prompt, which the browser handles even if registration consumes activation.
-
-Passkey storage accepts 32-byte PRF arrays from password managers such as 1Password and converts them to binary data without changing the encryption key.
+If the browser supports passkeys, `defaultByokStorage()` keeps the key encrypted with a passkey. If not, the key stays in memory for this tab only.
-## 2. Save a key
+### 2. Save a key from your UI
-Call `byok.update("openai", value)` from your own UI. The library does not ship a dialog.
-
-`useByok(byok)` gives the status for saved keys.
+Call `byok.update("openai", value)` from your own form. The library does not ship a dialog. `useByok(byok)` gives you the status of each saved key.
```tsx
import { useState } from "react";
@@ -98,11 +84,11 @@ export function KeyForm() {
}
```
-The `ts-react-chat` example has a key-icon popup you can copy.
+The `ts-react-chat` example has a key-icon popup that you can copy.
-## 3. Send with `useChat`
+### 3. Send the key with `useChat`
-Pass the same store. Set `forwardedProps.provider` to `"openai"`. The client then sends only that key.
+Pass the same store to `useChat`. Set `forwardedProps.provider` to `"openai"`, so the client sends only that key:
```tsx
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
@@ -111,8 +97,9 @@ import { byok } from "./byok";
export function Chat() {
const { sendMessage, isLoading } = useChat({
connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
byok,
- forwardedProps: { provider: "openai", model: "gpt-5.6" },
+ forwardedProps: { provider: "openai" },
});
return (
@@ -129,15 +116,9 @@ export function Chat() {
}
```
-If no provider is set, the send throws. The client does not attach every stored key.
-
-Built-in fetch and XHR adapters copy the headers onto the POST.
-
-If you write a custom `connect`, copy `runContext.headers` yourself. See [Custom Transports](../transports/custom#request-scoped-adapters).
-
-## 4. Read the key on the relay
+### 4. Read the key on the server
-Use this in any API route.
+Read the header with `getByokKey`, then build the adapter with that key:
```typescript
import {
@@ -155,7 +136,7 @@ export async function POST(request: Request) {
if (!apiKey) return byokMissing(openaiByok);
const stream = chat({
- adapter: createOpenaiChat("gpt-5.6", apiKey),
+ adapter: createOpenaiChat("gpt-6.1-sol", apiKey),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
@@ -164,19 +145,44 @@ export async function POST(request: Request) {
}
```
-Import `openaiByok` from `@tanstack/ai-openai/byok`, not from the adapter main entry. The `/byok` file is safe in the browser. The main entry pulls in the provider SDK.
+Import `openaiByok` from `@tanstack/ai-openai/byok`. That file is safe in the browser. The main entry of the adapter loads the provider SDK.
-The header wins. If the header is empty, `getByokKey` reads `OPENAI_API_KEY` from the environment. If both are empty, `byokMissing` returns a 401. If one credential is made of several values, read them together with [`getByokKeys`](../api/ai#getbyokkeys).
+> [!WARNING]
+> Do not log the raw key. Use [`maskKey`](../api/ai#maskkey) on error strings.
-CAUTION: Do not log the raw key. Use [`maskKey`](../api/ai#maskkey) on error strings.
+Paste a key, send a message, and your server calls OpenAI with the key of the user.
-You can paste a key, send a message, and the relay calls OpenAI with that key.
+## How it works
-## Pick the model before the key shows up
+1. `defineByok` keeps the keys in the browser, encrypted when the browser has passkeys.
+2. On each send, the client reads the key for `forwardedProps.provider`. It puts the key on the `x-byok-` header, never in the JSON body.
+3. `getByokKey` reads that header. If the header is empty, it reads the env var of the provider, here `OPENAI_API_KEY`.
+4. If both are empty, `byokMissing` returns a 401.
-You want to set up your models in one place, for example a list that users pick from. But you only get the key with each request. Wrap the adapter in `keyedAdapter`. The route builds it when the key comes in.
+Your server uses the key for one call, then forgets it.
-On the server, define the model once:
+## Advanced
+
+### Passkey prompts
+
+Passkey storage runs a WebAuthn prompt to save a key and to unlock it. Some browsers (Safari, Dia) show that prompt only right after a click.
+
+- Call `byok.update()`, `byok.unlock()`, or `byok.prepare()` directly in the click handler, before you `await` other work.
+- If the unlock runs deep in a send flow, the prompt does not appear. The call throws with a "needs a fresh user action" message.
+- This activation check applies to existing keyrings. The first registration can need a second PRF prompt, and the browser handles it.
+- Passkey storage accepts 32-byte PRF arrays from password managers such as 1Password. It converts them to binary data and the encryption key does not change.
+
+### Send rules
+
+- If `forwardedProps.provider` is not set, the send throws. The client does not attach every stored key.
+- The built-in fetch and XHR adapters copy the headers onto the POST. If you write a custom `connect`, copy `runContext.headers` yourself. See [Custom Transports](../transports/custom#request-scoped-adapters).
+- If one credential has several values, read them together with [`getByokKeys`](../api/ai#getbyokkeys).
+
+### Pick the model before the key shows up
+
+You want to set up your models in one place, for example a list that users pick from. But the key comes in only with each request. Wrap the adapter in `keyedAdapter`, and the route builds it when the key comes in.
+
+1. Define the model once on the server:
```typescript group=keyed
import { keyedAdapter } from "@tanstack/ai/byok";
@@ -188,7 +194,7 @@ export const gpt = keyedAdapter(openaiByok, (key) =>
);
```
-Then build it in the route with the key from the request:
+2. Build it in the route with the key from the request:
```typescript group=keyed
import {
@@ -213,35 +219,14 @@ export async function POST(request: Request) {
}
```
-The client does not change. It sends the key the same way as in step 3:
-
-```tsx
-import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
-import { byok } from "./byok";
-
-export function Chat() {
- const { sendMessage } = useChat({
- connection: fetchServerSentEvents("/api/chat"),
- byok,
- forwardedProps: { provider: "openai" },
- });
-
- return (
- void sendMessage("Hello")}>
- Send
-
- );
-}
-```
-
-Good to know:
+The client is the same as in step 3.
- `keyedAdapter` works for every adapter kind: text, image, speech, and the rest.
- Your list can mix plain and keyed adapters. `isKeyedAdapter(adapter)` tells you which ones need a key.
### More than one provider
-Your users pick OpenAI or Anthropic, and each one brings their own key. Put both in `keyedAdapters`. The map key is the provider id, so a key only goes to its own provider:
+Your users pick OpenAI or Anthropic, and each user brings a key. Put both providers in `keyedAdapters`. The map key is the provider id, so a key goes only to its own provider:
```typescript group=keyed-many
import { keyedAdapter, keyedAdapters } from "@tanstack/ai/byok";
@@ -285,7 +270,7 @@ export async function POST(request: Request) {
}
```
-On the client, `forwardedProps.provider` picks which key goes out:
+On the client, `forwardedProps.provider` picks the key that goes out:
```tsx
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
@@ -294,6 +279,7 @@ import { byok } from "./byok";
export function Chat({ provider }: { provider: "openai" | "anthropic" }) {
const { sendMessage } = useChat({
connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
byok,
forwardedProps: { provider },
});
@@ -306,29 +292,30 @@ export function Chat({ provider }: { provider: "openai" | "anthropic" }) {
}
```
-How it picks:
+How `keyedAdapterFromRequest` picks:
-1. The user's own key wins. That is the `x-byok-` header, checked in map order.
+1. The key of the user wins. That is the `x-byok-` header, checked in map order.
2. Then the env keys, in map order. Only entries made with a descriptor (like `anthropicByok`) have env names. A plain factory reads the header only.
-3. No key at all? You get `null`, so answer with `byokMissing`.
+3. If there is no key at all, you get `null`. Answer with `byokMissing`.
-An entry like `anthropic: keyedAdapter(openaiByok, ...)` throws at startup, so a key cannot reach the wrong provider.
+Some more rules:
-The adapter you get back is a union of your providers. Need the exact model types, for example for `modelOptions`? Call `models.openai.create(key)` yourself.
+- An entry like `anthropic: keyedAdapter(openaiByok, ...)` throws at startup, so a key cannot go to the wrong provider.
+- The adapter that you get back is a union of your providers. If you need the exact model types, for example for `modelOptions`, call `models.openai.create(key)` yourself.
-## If the relay already has an env key
+### If the server already has an env key
-By default, a send with no browser key does not POST. If your relay has env keys, call `byok.setServerCoverage(true)`:
+By default, a send with no browser key does not POST. If your server has env keys, call `byok.setServerCoverage(true)`:
```typescript group=byok
byok.setServerCoverage(true);
```
-Then a send with no pasted key still POSTs. The relay uses the env key. If that is also empty, the relay returns `byokMissing` (401). The client sets `snapshot.prompt`.
+Then a send with no pasted key still POSTs, and the server uses the env key. If that key is also empty, the server returns `byokMissing` (401), and the client sets `snapshot.prompt`.
-## Keys inside agents
+### Keys inside agents
-Your [subagents](../chat/subagents) call models too, and those calls need the user's key. Pass the key on `subagents.keys`. Each agent builds its adapter with `ctx.keys`.
+Your [subagents](../chat/subagents) call models too, and those calls need the key of the user. Pass the key on `subagents.keys`. Each agent builds its adapter with `ctx.keys`.
1. Define the agent. Wrap its adapter in `keyedAdapter`, then build it with `ctx.keys.adapter`:
@@ -386,18 +373,16 @@ How `ctx.keys` finds a key:
- It calls the `subagents.keys` function with the provider of the adapter, here `openaiByok`. The function can read any store, for example your database.
- `subagents.keys` can also be an object by provider id, for example `{ openai: apiKey }`.
-- If there is no key, it reads the env var of the provider, here `OPENAI_API_KEY`. If that is empty too, the call throws `Missing OpenAI API key. Set OPENAI_API_KEY.`
+- If there is no key, it reads the env var of the provider, here `OPENAI_API_KEY`. If that var is empty too, the call throws `Missing OpenAI API key. Set OPENAI_API_KEY.`
- A child that calls `ctx.chat({ subagents })` passes the keys on to its own children. Keys that the child sets itself win for those providers.
- `ctx.keys.get(provider)` returns the key or `null`. `ctx.keys.require(provider)` throws when there is no key.
The writer now calls OpenAI with the key that the user pasted.
-## Image, audio, and other providers
-
-For other cases:
+### Image, audio, and other providers
- Image and audio POSTs use the same store. See [Generation Hooks](../media/generation-hooks#usegenerateaudio).
-- OpenAI users can sign in with ChatGPT and skip the key. See [Sign in with ChatGPT](../adapters/openai#sign-in-with-chatgpt-byok).
-- OpenRouter can mint a key with OAuth. See [Sign in with OpenRouter](../adapters/openrouter#sign-in-with-openrouter-byok).
+- OpenAI users can sign in with ChatGPT and skip the key. See [Sign in with ChatGPT](../adapters/openai-advanced#sign-in-with-chatgpt-byok).
+- OpenRouter can mint a key with OAuth. See [Sign in with OpenRouter](../adapters/openrouter-advanced#sign-in-with-openrouter-byok).
- Lovable uses `lovableByok` from `@tanstack/ai-lovable/byok`. See [Lovable AI Gateway](../adapters/lovable#bring-your-own-key).
-- Cloudflare needs a token plus an account id. `cloudflareByok` from `@tanstack/ai-cloudflare/byok` declares `cloudflareAccountByok` as a companion. Pass both to `defineByok({ providers })` and a send for `cloudflare` carries both headers. See [Cloudflare](../adapters/cloudflare#bring-your-own-key).
+- Cloudflare needs a token and an account id. `cloudflareByok` from `@tanstack/ai-cloudflare/byok` declares `cloudflareAccountByok` as a companion. Pass both to `defineByok({ providers })`, and a send for `cloudflare` carries both headers. See [Cloudflare](../adapters/cloudflare#bring-your-own-key).
diff --git a/docs/advanced/compaction-advanced.md b/docs/advanced/compaction-advanced.md
new file mode 100644
index 0000000000..4ec730afd0
--- /dev/null
+++ b/docs/advanced/compaction-advanced.md
@@ -0,0 +1,412 @@
+---
+title: "Compaction: Advanced"
+id: compaction-advanced
+description: "Every compaction option, custom strategies, real token counts, background summaries, DevTools events, checkpoints, and session logs."
+keywords:
+ - tanstack ai
+ - compaction
+ - context window
+ - countTokens
+ - summarizeOldest
+ - session log
+---
+
+This page covers the details of compaction that you need only for special cases. Set up the basics first with [Compaction](./compaction).
+
+## Options
+
+### withCompaction
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `maxTokens` | `number` | - | **Required.** Compact when the token count of `messages` passes this. See `countTokens`. |
+| `strategy` | `CompactionStrategy` | `evictOldest()` | How to shrink the messages. |
+| `estimateTokens` | `(message: ModelMessage) => number` | characters / 4 | The token estimate for one message. Pass a real tokenizer to get exact counts. |
+| `strategyKey` | `string` | identity of the built-in strategy | A stable identity for checkpoints. Set it for custom strategies, custom estimators, or a custom eviction marker. Change it when your `summarize` function can change. |
+| `onCompact` | `(info: CompactionInfo) => void` | - | Runs after each compaction. See [onCompact](#oncompact). |
+| `countTokens` | `'estimate' \| 'usage'` | `'estimate'` | `'usage'` counts with the usage that the provider reported for the last call. See [Count with real usage](#count-with-real-usage). |
+| `auto` | `boolean` | `true` | `false` turns off compaction at `maxTokens`. `compactNext`, the overflow retry, and the check after a run still compact. |
+| `contextWindow` | `number` | - | The context window of the model. See [Set the limit from the context window](#set-the-limit-from-the-context-window). |
+| `durable` | `boolean` | `false` | On a host with a session log, write the result into the log. See [Keep compaction in a session log](#keep-compaction-in-a-session-log). |
+| `continueOnError` | `boolean` | `false` | When the strategy fails, send the full messages and continue. By default the run fails. |
+| `background` | `{ atTokens: number }` | - | Prepare the summary when the count passes `atTokens`, and apply it at the next run. See [Prepare the summary in the background](#prepare-the-summary-in-the-background). |
+| `native` | `{ compact?: (options) => Promise }` | - | The adapter of the `chat()` call. When it has `compact`, the provider compacts in place of `strategy`. See [Let the provider compact](./compaction#let-the-provider-compact). |
+
+### Strategy options
+
+| Strategy | Options |
+|----------|---------|
+| `evictOldest` | `keepRecentTokens` (default `maxTokens / 2`), `marker` |
+| `summarizeOldest` | `summarize` (**required**), `keepRecentTokens` (default `maxTokens / 2`), `summaryRole` (default `'assistant'`), `cut` (default `'message'`) |
+| `clearToolResults` | `keepRecentToolResults` (default `3`), `stub` |
+
+The token count is a rough `characters / 4` estimate. It is good enough to start a compaction, but it is not exact. Pass `estimateTokens` to get counts that match the provider.
+
+### onCompact
+
+`onCompact` gets `{ before, after, messagesBefore, messagesAfter, reason, usage, error, stale }`:
+
+- `before` and `after`: the estimated tokens.
+- `reason`: why it ran. See [DevTools events](#devtools-events).
+- `usage`: the token usage of the summary calls.
+- `error`: set when the check after a run fails, when a background summary fails, or when `native` compaction failed and `strategy` ran in its place.
+- `stale`: set when a background summary does not fit the current messages.
+
+## Write your own strategy
+
+A strategy is a function. It gets the messages and the budget. It returns the rewritten messages, or `null` to change nothing.
+
+It runs when the count is over `maxTokens`. It also runs when `compactNext` forces it, so the count can be under `maxTokens`.
+
+```typescript
+import { withCompaction } from "@tanstack/ai-compaction";
+import type { CompactionStrategy } from "@tanstack/ai-compaction";
+
+// Keep only the last message.
+const keepLastOnly: CompactionStrategy = (messages) => {
+ if (messages.length <= 1) return null;
+ return messages.slice(-1);
+};
+
+withCompaction({
+ maxTokens: 100_000,
+ strategy: keepLastOnly,
+ strategyKey: "keep-last-v1",
+});
+```
+
+The second argument of the strategy has:
+
+- `maxTokens`: the budget.
+- `estimate`: the shared token estimator.
+- `addUsage`: report the usage of a model call that the strategy made, for example a summary.
+- `signal`: aborts when the run is cancelled. Pass it to a model call.
+
+If you use a custom strategy with persistence, set `strategyKey`. When the strategy can give a different output, change the key. Then an old checkpoint cannot use stale behavior.
+
+## Native compaction failures
+
+These rules apply when you pass `native` (see [Let the provider compact](./compaction#let-the-provider-compact)):
+
+- When `compact` fails, `strategy` runs in its place. `onCompact` and `compaction:ended` get the failure in `error`. Some providers on the OpenAI Responses base have no compaction endpoint.
+- After a 404, 405, or 501 (the provider has no compaction endpoint), that adapter object and model use `strategy` until the process restarts. Create the adapter once, outside the request handler, so that no more compaction calls go out.
+- After any other failure, such as a 400, a 429, or a network error, the next compaction tries `compact` again.
+- The compaction call gets the system prompts of the `chat()` call. It does not get the tools, because the compaction endpoint does not accept them.
+- The compacted messages can hold an encrypted provider item. The adapter sends it back unchanged to the same model. Another model does not get it.
+
+## Give the retry something to cut
+
+After a context overflow, the retry compacts even under `maxTokens`. But a strategy can still find nothing to cut:
+
+- `composeStrategies` stops when the estimate is under `maxTokens`.
+- `evictOldest` keeps `maxTokens / 2` by default.
+
+Set a small `keepRecentTokens`, so that the retry always drops something:
+
+```typescript
+import { evictOldest, withCompaction } from "@tanstack/ai-compaction";
+
+withCompaction({
+ maxTokens: 100_000,
+ strategy: evictOldest({ keepRecentTokens: 20_000 }),
+});
+```
+
+## Compact on your own schedule
+
+Call `compactNext(threadId)` to make the next call on a thread compact, even under `maxTokens`. For example, do this before you switch the thread to a model with a smaller context window.
+
+Create the middleware once, so that `compactNext` reaches the same instance:
+
+```typescript
+import { withCompaction } from "@tanstack/ai-compaction";
+
+const compaction = withCompaction({ maxTokens: 100_000 });
+
+// The next model call on this thread compacts first.
+compaction.compactNext("thread-1");
+```
+
+## Count with real usage
+
+The `characters / 4` estimate drifts on long agent runs. Code, JSON, and other languages do not split into tokens like English text. Then compaction runs too late and the call fails. Or it runs too early and drops context that you still need.
+
+Set `countTokens: 'usage'`. After each model call, compaction saves the usage that the provider reported. The next check starts from that number and estimates only the messages after it.
+
+```ts
+import { chat, toServerSentEventsResponse } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+import { withCompaction } from '@tanstack/ai-compaction'
+
+export async function POST(request: Request) {
+ const { messages, threadId } = await request.json()
+
+ const stream = chat({
+ adapter: openaiText('gpt-6.1-sol'),
+ messages,
+ threadId,
+ middleware: [withCompaction({ maxTokens: 100_000, countTokens: 'usage' })],
+ })
+
+ return toServerSentEventsResponse(stream)
+}
+```
+
+- Before the first model call, there is no usage. Compaction uses the estimate.
+- With a [`metadata` store](#checkpoints), compaction saves the usage. The next request and a restarted server start from the real count.
+- The strategies still use the estimate to choose which messages to keep.
+
+## Set the limit from the context window
+
+Most models publish a context window. Keep room for the answer: set `maxTokens` to the window minus a reserve.
+
+```ts
+import { summarize } from '@tanstack/ai'
+import { openaiSummarize } from '@tanstack/ai-openai'
+import { summarizeOldest, withCompaction } from '@tanstack/ai-compaction'
+
+const contextWindow = 200_000
+const reserveTokens = 20_000
+
+const windowed = withCompaction({
+ maxTokens: contextWindow - reserveTokens,
+ contextWindow,
+ countTokens: 'usage',
+ strategy: summarizeOldest({
+ keepRecentTokens: 8_000,
+ summarize: (messages, { threadId }) =>
+ summarize({
+ adapter: openaiSummarize('gpt-6.1-sol'),
+ text: messages
+ .map((m) => `${m.role}: ${typeof m.content === 'string' ? m.content : ''}`)
+ .join('\n'),
+ threadId: `${threadId}:compaction`,
+ }),
+ }),
+})
+```
+
+| You want | Set |
+|---|---|
+| Compact before the context passes the window minus a reserve | `maxTokens: contextWindow - reserveTokens` |
+| Keep the newest messages word for word | `keepRecentTokens` on the strategy |
+| On a host with a session log: no automatic compaction, but recovery from a silent overflow | `auto: false`, `contextWindow`, `countTokens: 'usage'`, `durable: true` |
+
+Some providers accept a request that is too large. They cut the input and answer without an error.
+
+On a host with a [session log](#keep-compaction-in-a-session-log), compaction with `countTokens: 'usage'` and `durable: true` checks once more after the last model call of a run. If the usage passed `maxTokens` or `contextWindow`, it compacts immediately, so the next run starts small.
+
+- With `auto: false`, only `contextWindow` counts.
+- The answer is not sent again. The only extra model calls are the summary calls.
+- Without a session log, there is no check after a run. The next request compacts before its first model call.
+
+## Prepare the summary in the background
+
+A `summarizeOldest` call takes time. When compaction runs at `maxTokens`, the user waits for that call before the model answers. Set `background` to prepare the summary earlier, while the chat continues:
+
+```ts
+import { chat, summarize, toServerSentEventsResponse } from '@tanstack/ai'
+import { openaiSummarize, openaiText } from '@tanstack/ai-openai'
+import { summarizeOldest, withCompaction } from '@tanstack/ai-compaction'
+
+// Create it once, so that a summary from one request reaches the next.
+const compaction = withCompaction({
+ maxTokens: 100_000,
+ background: { atTokens: 80_000 },
+ strategy: summarizeOldest({
+ summarize: (messages, { threadId }) =>
+ summarize({
+ adapter: openaiSummarize('gpt-6.1-sol'),
+ text: messages
+ .map((m) => `${m.role}: ${typeof m.content === 'string' ? m.content : ''}`)
+ .join('\n'),
+ threadId: `${threadId}:compaction`,
+ }),
+ }),
+})
+
+export async function POST(request: Request) {
+ const { messages, threadId } = await request.json()
+
+ const stream = chat({
+ adapter: openaiText('gpt-6.1-sol'),
+ messages,
+ threadId,
+ middleware: [compaction],
+ })
+
+ return toServerSentEventsResponse(stream)
+}
+```
+
+How the background summary runs:
+
+- `atTokens` must be less than `maxTokens`. If it is not, `withCompaction` throws.
+- When the count at a model call passes `atTokens`, the summary starts on a copy of the messages. The model call does not wait for it.
+- The ready summary waits in the [`metadata` store](#checkpoints), under the namespace `@tanstack/ai-compaction:background`. Without a store, it waits in memory.
+- The summary applies at the first model call of the next run on the same `threadId`, never in the middle of a run.
+- A model call over `maxTokens` while the summary runs waits for it and applies it. It compacts inline only when the messages are still over `maxTokens`. A cancel of the run stops the wait, not the summary.
+
+Sometimes a summary does not apply:
+
+- A newer compaction cut past the message that the summary keeps. The summary is dropped and reported with `stale: true`.
+- The summary call failed. Nothing applies, and the failure is reported with `error`. The next model call past `atTokens` starts a new summary.
+
+`onCompact` always gets these reports. A `compaction:ended` event for a failed summary reaches the stream only while the run that started the summary still runs.
+
+## Better summaries
+
+The basic `summarizeOldest` setup works for a chat. A coding agent needs more. It must keep the goal, the decisions, and the files that it touched across many compactions. Three opt-in parts help:
+
+1. `cut: 'turn'` on `summarizeOldest` gives a better summary when the cut falls inside a turn. The older turns get one summary. The start of the current turn gets its own short summary, under `## Turn context`.
+2. `conversationSummarizer` is a ready summarizer with a structured prompt (goal, constraints, progress, key decisions, next steps, critical context). When an older summary exists, it updates that summary. It reports the usage of each summary call.
+3. `details` on `conversationSummarizer` adds your own facts after the summary, for example the files that the agent read. The next compaction gets them back as `previousDetails`.
+
+```ts
+import { openaiText } from '@tanstack/ai-openai'
+import {
+ conversationSummarizer,
+ summarizeOldest,
+ withCompaction,
+} from '@tanstack/ai-compaction'
+
+function pathOf(args: string) {
+ const input: unknown = JSON.parse(args)
+ return typeof input === 'object' &&
+ input !== null &&
+ 'path' in input &&
+ typeof input.path === 'string'
+ ? input.path
+ : undefined
+}
+
+const agentCompaction = withCompaction({
+ maxTokens: 180_000,
+ contextWindow: 200_000,
+ countTokens: 'usage',
+ strategy: summarizeOldest({
+ cut: 'turn',
+ keepRecentTokens: 8_000,
+ summarize: conversationSummarizer({
+ adapter: openaiText('gpt-6.1-sol'),
+ details: ({ messages, previousDetails }) => {
+ const files = new Set(previousDetails?.split('\n').filter(Boolean))
+ for (const message of messages) {
+ for (const call of message.toolCalls ?? []) {
+ const path =
+ call.function.name === 'read_file'
+ ? pathOf(call.function.arguments)
+ : undefined
+ if (path) files.add(path)
+ }
+ }
+ return files.size > 0 ? [...files].join('\n') : undefined
+ },
+ }),
+ }),
+})
+```
+
+- In the text that goes to the summarizer, each tool result is cut to 2,000 characters. Set `maxToolResultChars` to change this limit.
+- Your own `summarize` function gets a second argument, `summarize(messages, input)`:
+ - `previousSummary` and `previousDetails`: the older summary and its details.
+ - `turnPrefix`: `true` when the call summarizes the start of the current turn.
+ - `turnPrefixMessages`: the start of the current turn. Only the call that writes the details gets it, so the details cover each dropped message.
+ - `signal`: aborts when the run aborts.
+
+## DevTools events
+
+After a compaction, the chat stream includes three CUSTOM events in this order:
+
+1. `compaction:started`: sent before the strategy runs.
+2. `compaction:state`: the result, with counts and previews.
+3. `compaction:ended`: why it ran and what it cost.
+
+Durability flushes each event when it is emitted. So a slow `summarizeOldest` call still shows as started on the client while it runs.
+
+`compaction:ended` has these fields:
+
+- `reason`: `'threshold'` (the count passed `maxTokens`), `'forced'` (`compactNext`, or the retry after a context overflow), or `'background'` (a [background summary](#prepare-the-summary-in-the-background)).
+- `usage`: the token usage of the summary calls.
+- `error`: the message when the strategy failed. By default the run then fails. Set `continueOnError: true` to send the full messages instead.
+- `stale`: `true` when a ready background summary did not fit the current messages and was dropped.
+
+A compaction after the last model call of a run has `reason: 'after-turn'`. The stream is closed by then, so only `onCompact` reports it. A failed strategy or `onCompact` there does not fail the run, because the answer is complete. Only a failed write to the log fails it.
+
+TanStack AI DevTools has a Compaction tab on the hook. Each compaction shows:
+
+- started, state, and ended rows
+- when it ran, with token and message counts
+- the `maxTokens` budget
+- dropped messages
+- the transcript sent to the model
+
+The conversation timeline also keeps `onCompactStart`, `onCompact`, and `onCompactEnd` steps.
+
+To see it:
+
+1. Open the AI plugin in the DevTools panel. The `ts-react-chat` example mounts it.
+2. Select the Compaction hook.
+3. Open the Compaction tab.
+
+The `/compaction` route in `examples/ts-react-chat` uses a small `maxTokens`, so compaction runs after a few turns. It also uses `withPersistence` (SQLite). Reload after a compaction: the chat still shows every message. The banner is example UI, not part of `useChat`.
+
+## Checkpoints
+
+If the persistence adapter has a `metadata` store, compaction saves a small checkpoint:
+
+- The next request makes sure that the start of the transcript did not change.
+- Then it restores the last compacted result and adds only the new messages.
+- A changed start of the transcript, or a changed strategy key, makes the checkpoint invalid.
+
+When compaction reuses a checkpoint, a later `summarizeOldest` pass sees the previous summary plus new messages. Then it merges the old summary into the new one. This merge needs a metadata store and a strategy key.
+
+These strategies get a strategy key automatically:
+
+- The default strategy and standard `evictOldest`.
+- `summarizeOldest` and `clearToolResults`.
+- Safe compositions of the strategies above.
+
+Set `strategyKey` for custom strategies, custom estimators, or custom marker functions. When your `summarize` function can change, change `strategyKey` too. Without a metadata store or a safe key, compaction stays stateless.
+
+With `durable: true` on a host with a session log, compaction writes its result into the log instead. See the next section.
+
+## Keep compaction in a session log
+
+Some hosts keep a durable log for each thread. They rebuild the model context from it when a session opens again. A metadata checkpoint can miss on that rebuild. Set `durable: true` so that the compaction goes into the log, and the rebuilt session sees the same context.
+
+1. Make sure that the host provides `LogRecordsCapability`. See [Append records to the session log](./middleware-advanced#append-records-to-the-session-log).
+2. Add `withCompaction({ durable: true })` to the same `chat()` call.
+3. Fold each compaction record into the messages with `projectCompaction`: for the next model call, and when the host rebuilds the session.
+
+```ts
+import type { ModelMessage } from '@tanstack/ai'
+import { projectCompaction, withCompaction } from '@tanstack/ai-compaction'
+
+const compaction = withCompaction({
+ maxTokens: 180_000,
+ contextWindow: 200_000,
+ countTokens: 'usage',
+ durable: true,
+})
+
+type LogEntry =
+ | { kind: 'message'; message: ModelMessage }
+ | { kind: 'record'; record: { type: string; [key: string]: unknown } }
+
+// Fold the log in order. A compaction record replaces the messages before it.
+function contextOf(log: Array): Array {
+ let messages: Array = []
+ for (const entry of log) {
+ if (entry.kind === 'message') messages = [...messages, entry.message]
+ else messages = projectCompaction({ messages, record: entry.record }) ?? messages
+ }
+ return messages
+}
+```
+
+- Each compaction writes one record with the type `'tanstack.compaction'` (`COMPACTION_RECORD_TYPE`). The host must accept this type.
+- `projectCompaction` returns `undefined` for a record of another type, and for a record that does not fit the messages. Then keep the messages as they are.
+- Without the fold for the next model call, each later call of the run compacts the full list again.
+- The log only grows, so the original messages stay in the store.
+- Without `LogRecordsCapability`, compaction uses the metadata checkpoint, and there is no check after the run.
diff --git a/docs/advanced/compaction.md b/docs/advanced/compaction.md
index 237e9920e9..06965c522a 100644
--- a/docs/advanced/compaction.md
+++ b/docs/advanced/compaction.md
@@ -2,7 +2,7 @@
title: Compaction
id: compaction
order: 3
-description: "Keep long chats under the context limit with @tanstack/ai-compaction. withCompaction runs a pluggable strategy before each model call: evict, summarize, or clear old tool output."
+description: "Keep long chats under the context limit. withCompaction from @tanstack/ai-compaction shrinks what the model sees before each call: drop old messages, summarize them, or clear old tool output."
keywords:
- tanstack ai
- compaction
@@ -12,31 +12,46 @@ keywords:
- summarize history
---
-A long chat or a multi-step agent loop keeps adding messages. At some point the transcript passes the model's context limit and the call fails. You want the conversation to keep working without hitting that wall.
+Compaction shortens a transcript before it passes the model context limit. A long chat or agent loop otherwise fails on the next call.
-`withCompaction` shrinks provider context before each model call. When the context passes `maxTokens`, a **strategy** rewrites what the model sees. The canonical transcript does not change. Add this [`ChatMiddleware`](./middleware) to the `middleware` array of any `chat()` call.
+Add `withCompaction` to your `chat()` call. It runs before each model call.
-## Install
+## Quick setup
-```bash
-pnpm add @tanstack/ai-compaction
-```
+### 1. Install the package
+
+
+
+react: @tanstack/ai-compaction
+vue: @tanstack/ai-compaction
+solid: @tanstack/ai-compaction
+svelte: @tanstack/ai-compaction
+preact: @tanstack/ai-compaction
+angular: @tanstack/ai-compaction
+octane: @tanstack/ai-compaction
+vanilla: @tanstack/ai-compaction
-## Quick start
+
-The default strategy drops the oldest messages once the transcript passes `maxTokens` and keeps the recent ones.
+### 2. Add the middleware to your chat endpoint
```typescript
-import { chat, toServerSentEventsResponse } from "@tanstack/ai";
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { withCompaction } from "@tanstack/ai-compaction";
export async function POST(request: Request) {
- const { messages } = await request.json();
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
const stream = chat({
- adapter: openaiText("gpt-5.5"),
+ adapter: openaiText("gpt-6.1-sol"),
messages,
+ threadId,
+ runId,
middleware: [withCompaction({ maxTokens: 100_000 })],
});
@@ -44,19 +59,46 @@ export async function POST(request: Request) {
}
```
+The client does not change. Your `useChat` code stays the same.
+
+### 3. Watch it run
+
+Use a small `maxTokens` and an `onCompact` log while you try it:
+
+```typescript
+import { withCompaction } from "@tanstack/ai-compaction";
+
+const compaction = withCompaction({
+ maxTokens: 2_000,
+ onCompact: (info) => {
+ console.log(`Compacted from ${info.before} to ${info.after} tokens`);
+ },
+});
+```
+
+Put `compaction` in the `middleware` array and chat for a few turns. The server logs a line each time the history shrinks, and the chat keeps answering.
+
+## How it works
+
+- `withCompaction` is a [chat middleware](./middleware). Before each model call, it estimates the token count of the messages (characters / 4).
+- When the count passes `maxTokens`, a **strategy** rewrites the messages that go to the provider.
+- The full transcript does not change. Persistence and the UI still see every message.
+- The system prompt is never dropped. `chat()` keeps it separate from `messages`.
+- Tool calls stay paired with their results, so the request stays valid.
+
## Pick a strategy
-Pass `strategy` to change how the history shrinks. Three are built in.
+Pass `strategy` to change how the history shrinks. Three are built in:
| Strategy | What it does | Cost |
|----------|--------------|------|
-| `evictOldest` (default) | Drop the oldest messages, leave a marker | No extra model call |
-| `summarizeOldest` | Replace the oldest messages with an LLM summary | One summarize call (up to two with `cut: 'turn'`) |
-| `clearToolResults` | Stub the content of old tool results, keep the messages | No extra model call |
+| `evictOldest` (default) | Drop the oldest messages and leave a marker | No extra model call |
+| `summarizeOldest` | Replace the oldest messages with a summary from a model | One summary call (up to two with `cut: 'turn'`) |
+| `clearToolResults` | Replace the content of old tool results with a stub, keep the messages | No extra model call |
### evictOldest
-Cheapest. Keeps the recent tail, drops the older head, and leaves a short marker in its place. This is the default, so you only name it to tune `keepRecentTokens`.
+This strategy is the cheapest. It keeps the recent messages, drops the older ones, and puts a short marker in their place. It is the default, so name it only to set `keepRecentTokens`:
```typescript
import { withCompaction, evictOldest } from "@tanstack/ai-compaction";
@@ -69,32 +111,47 @@ withCompaction({
### summarizeOldest
-Keeps the gist of old turns instead of dropping them, at the cost of one summarization call. Pass a `summarize` callback. It gets the messages about to be dropped and returns the summary text. Wire it to `summarize()` or any model call.
+This strategy keeps the meaning of old turns and costs one summary call. Give it a `summarize` function. The function gets the messages to drop and an input with the `threadId` of the conversation. It returns the summary. Give the summary call a thread id of its own, so it does not share state with the conversation:
```typescript
-import { chat, summarize, toServerSentEventsResponse } from "@tanstack/ai";
-import { openaiText, openaiSummarize } from "@tanstack/ai-openai";
-import { withCompaction, summarizeOldest } from "@tanstack/ai-compaction";
+import {
+ chat,
+ chatParamsFromRequest,
+ summarize,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
import type { ModelMessage } from "@tanstack/ai";
+import { openaiSummarize, openaiText } from "@tanstack/ai-openai";
+import {
+ summarizeOldest,
+ withCompaction,
+ type SummarizeInput,
+} from "@tanstack/ai-compaction";
-async function summarizeHistory(messages: Array) {
+async function summarizeHistory(
+ messages: Array,
+ { threadId }: SummarizeInput,
+) {
const text = messages
.map((m) => `${m.role}: ${typeof m.content === "string" ? m.content : ""}`)
.join("\n");
- // Return the whole result. Compaction reports its `usage`.
+ // Return the whole result, so compaction can report its usage.
return summarize({
adapter: openaiSummarize("gpt-6.1-sol"),
text,
+ threadId: `${threadId}:compaction`,
});
}
export async function POST(request: Request) {
- const { messages } = await request.json();
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages,
+ threadId,
+ runId,
middleware: [
withCompaction({
maxTokens: 100_000,
@@ -107,50 +164,29 @@ export async function POST(request: Request) {
}
```
-`summarize` can return the summary text, or an object with `summary` and `usage`. Return the `summarize()` result as it is, so compaction can report what the summary cost.
+`summarize` can return the summary text, or an object with `summary` and `usage`. Return the `summarize()` result as it is, so that compaction can report the cost of the summary.
+
+For a coding agent, use the ready summarizer in [Better summaries](./compaction-advanced#better-summaries).
### clearToolResults
-Best for agent loops. Tool output (file reads, command output) is usually most of the tokens. This strategy replaces the content of old tool results with a stub and keeps every message and its tool-call pairing in place. The conversation shape does not change.
+This strategy is the best fit for agent loops. Tool output (file reads, command output) is usually most of the tokens. The strategy replaces the content of old tool results with a stub. Every message and every tool-call pair stays in place.
```typescript
import { withCompaction, clearToolResults } from "@tanstack/ai-compaction";
withCompaction({
maxTokens: 100_000,
- // Keep the 5 most recent tool results in full, stub the older ones.
+ // Keep the 5 most recent tool results in full. Stub the older ones.
strategy: clearToolResults({ keepRecentToolResults: 5 }),
});
```
-### Write your own
-
-A strategy is a function. It gets the messages and the budget, and returns the rewritten messages, or `null` to change nothing. It runs when the count is over `maxTokens`, and when `compactNext` forces it. Then the count can be under `maxTokens`.
-
-```typescript
-import { withCompaction } from "@tanstack/ai-compaction";
-import type { CompactionStrategy } from "@tanstack/ai-compaction";
-
-// Keep only the last message.
-const keepLastOnly: CompactionStrategy = (messages) => {
- if (messages.length <= 1) return null;
- return messages.slice(-1);
-};
-
-withCompaction({
- maxTokens: 100_000,
- strategy: keepLastOnly,
- strategyKey: "keep-last-v1",
-});
-```
-
-Set `strategyKey` when you combine a custom strategy with persistence. Change
-the key when the strategy can produce different output. This prevents an old
-checkpoint from using stale behavior.
-
## Combine strategies
-`composeStrategies` runs several strategies in order and **escalates**: it stops as soon as the result is back under `maxTokens`. Put the cheap, targeted strategy first and a broad fallback last. Here it clears old tool output first, and only drops old messages if that was not enough.
+`composeStrategies` runs several strategies in order. It stops as soon as the result is under `maxTokens`. Put the cheap, targeted strategy first and a broad fallback last.
+
+This example clears old tool output first. It drops old messages only when that is not enough:
```typescript
import {
@@ -170,21 +206,28 @@ withCompaction({
Some providers can compact the conversation on their own servers. The model then keeps more of the old turns than a marker gives. It costs one compaction call.
-Pass the adapter of the `chat()` call as `native`. When the count passes `maxTokens`, the adapter's `compact` method runs in place of `strategy`.
+Pass the adapter of the `chat()` call as `native`. When the count passes `maxTokens`, the `compact` method of the adapter runs in place of `strategy`:
```typescript
-import { chat, toServerSentEventsResponse } from "@tanstack/ai";
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { withCompaction } from "@tanstack/ai-compaction";
+// Create the adapter once, outside the request handler.
const adapter = openaiText("gpt-6.1-sol");
export async function POST(request: Request) {
- const { messages } = await request.json();
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
const stream = chat({
adapter,
messages,
+ threadId,
+ runId,
middleware: [withCompaction({ maxTokens: 100_000, native: adapter })],
});
@@ -193,376 +236,45 @@ export async function POST(request: Request) {
```
- An adapter with no `compact` method uses `strategy`.
-- When `compact` fails, `strategy` runs in its place. `onCompact` and `compaction:ended` get the failure in `error`. Some providers on the OpenAI Responses base have no compaction endpoint.
-- After a 404, 405, or 501 (the provider has no compaction endpoint), that adapter object and model use `strategy` until the process restarts. Create the adapter once, outside the request handler, so that no more compaction calls go out.
-- After any other failure, such as a 400, a 429, or a network error, the next compaction tries `compact` again.
-- The compaction call gets the system prompts of the `chat()` call. It does not get the tools: the compaction endpoint does not take them.
-- The compacted messages can hold an encrypted provider item. The adapter sends it back as it is to the same model. Another model does not get it.
+- When `compact` fails, `strategy` runs in its place.
-The OpenAI Responses adapters have `compact`. See [Native compaction](../adapters/openai#native-compaction).
+The OpenAI Responses adapters have `compact`. See [Native compaction](../adapters/openai-advanced#native-compaction). For what happens on each kind of failure, see [Native compaction failures](./compaction-advanced#native-compaction-failures).
## When the chat is still too long
-The token count is an estimate. So once in a while a call is still too long for the model, and the provider rejects it.
+The token count is an estimate. So sometimes a call is still too long for the model, and the provider rejects it.
-You do not have to catch that error. When a call fails with `CONTEXT_OVERFLOW_CODE`, `withCompaction` compacts and calls the model again, in the same run. Your code only sees the answer. This works with `auto: false` too.
+You do not have to catch that error. When a call fails with `CONTEXT_OVERFLOW_CODE`, `withCompaction` compacts and calls the model again, in the same run. Your code only sees the answer. This also works with `auto: false`.
-It retries once per model call. If the compacted call is still too long, the run ends with a `RUN_ERROR`:
+It retries once for each model call. If the compacted call is still too long, the run ends with a `RUN_ERROR`:
- `code` is `CONTEXT_OVERFLOW_CODE` (`'context_overflow'`).
-- `message` is the provider's own text.
-
-The OpenAI, Anthropic, Gemini, Bedrock, OpenRouter, Grok, Cloudflare, and BytePlus adapters set this code. Other OpenAI-compatible adapters set it when the provider sends `context_length_exceeded`.
-
-### Give the retry something to cut
-
-The retry compacts even under `maxTokens`. But a strategy can still find nothing to cut:
-
-- `composeStrategies` stops when the estimate is under `maxTokens`.
-- `evictOldest` keeps `maxTokens / 2` by default.
-
-Set a small `keepRecentTokens`, so the retry always drops something:
-
-```typescript
-import { evictOldest, withCompaction } from "@tanstack/ai-compaction";
-
-withCompaction({
- maxTokens: 100_000,
- strategy: evictOldest({ keepRecentTokens: 20_000 }),
-});
-```
-
-### Compact on your own schedule
-
-Want the next call on a thread to compact, even under `maxTokens`? Call `compactNext(threadId)`. For example, do this before you switch the thread to a model with a smaller context window.
-
-Create the middleware once, so `compactNext` reaches the same instance:
-
-```typescript
-import { withCompaction } from "@tanstack/ai-compaction";
-
-const compaction = withCompaction({ maxTokens: 100_000 });
-
-// The next model call on this thread compacts first.
-compaction.compactNext("thread-1");
-```
-
-## Options
-
-### withCompaction
-
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `maxTokens` | `number` | - | **Required.** Compact when the token count of `messages` passes this. See `countTokens`. |
-| `strategy` | `CompactionStrategy` | `evictOldest()` | How to shrink the messages. |
-| `estimateTokens` | `(message: ModelMessage) => number` | characters / 4 | Per-message token estimate. Pass a real tokenizer if you need exact counts. |
-| `strategyKey` | `string` | built-in strategy identity | Stable checkpoint identity. Set it for custom strategies, custom estimators, or a custom eviction marker. Change it when your `summarize` function can change. |
-| `onCompact` | `(info: CompactionInfo) => void` | - | Runs after each compaction. `info` is `{ before, after, messagesBefore, messagesAfter, reason, usage, error, stale }`. `error` is set when the check after a run fails, a background summary fails, or `native` compaction failed and `strategy` ran in its place. `stale` is set when a background summary no longer fits. |
-| `countTokens` | `'estimate' \| 'usage'` | `'estimate'` | `'usage'` counts with the usage the provider reported for the last call. See [Count with real usage](#count-with-real-usage). |
-| `auto` | `boolean` | `true` | `false` turns off compaction at `maxTokens`. `compactNext`, the overflow retry, and the overflow check still run. |
-| `contextWindow` | `number` | - | The model's context window. With `countTokens: 'usage'` and `durable: true` on a host with a session log, compaction also runs after a run whose usage passed it, even with `auto: false`. |
-| `durable` | `boolean` | `false` | On a host with a session log, write the result into the log. See [Keep compaction in a session log](#keep-compaction-in-a-session-log). |
-| `continueOnError` | `boolean` | `false` | When the strategy fails, send the full messages and go on. By default the run fails. |
-| `background` | `{ atTokens: number }` | - | Prepare the summary when the count passes `atTokens`, and apply it at the next run. `atTokens` must be below `maxTokens`. See [Prepare the summary in the background](#prepare-the-summary-in-the-background). |
-| `native` | `{ compact?: (options) => Promise }` | - | The adapter of the `chat()` call. When it has `compact`, the provider compacts in place of `strategy`. See [Let the provider compact](#let-the-provider-compact). |
-
-### Strategy options
-
-| Strategy | Options |
-|----------|---------|
-| `evictOldest` | `keepRecentTokens` (default `maxTokens / 2`), `marker` |
-| `summarizeOldest` | `summarize` (**required**), `keepRecentTokens`, `summaryRole` (default `assistant`), `cut` (default `'message'`) |
-| `clearToolResults` | `keepRecentToolResults` (default `3`), `stub` |
-
-The token count is a rough `characters / 4` estimate. It is good enough to trigger on, not exact. Pass `estimateTokens` for provider-accurate counts.
-
-## Count with real usage
+- `message` is the text from the provider.
-The `characters / 4` estimate drifts on long agent runs. Code, JSON, and other languages do not split into tokens like English text. Then compaction runs too late and the call fails, or it runs too early and drops context you still need.
+These adapters set this code:
-Set `countTokens: 'usage'`. After each model call, compaction saves the usage that the provider reported. The next check starts from that number and estimates only the messages that came after it.
+- OpenAI, Anthropic, Gemini, and Bedrock.
+- OpenRouter, Grok, Cloudflare, and BytePlus.
+- Other OpenAI-compatible adapters, when the provider sends `context_length_exceeded`.
-```ts group=compaction-usage
-import { chat, toServerSentEventsResponse } from '@tanstack/ai'
-import { openaiText } from '@tanstack/ai-openai'
-import { withCompaction } from '@tanstack/ai-compaction'
-
-export async function POST(request: Request) {
- const { messages } = await request.json()
-
- const stream = chat({
- adapter: openaiText('gpt-6.1-sol'),
- messages,
- middleware: [withCompaction({ maxTokens: 100_000, countTokens: 'usage' })],
- })
-
- return toServerSentEventsResponse(stream)
-}
-```
-
-- Before the first model call there is no usage yet, so compaction uses the estimate.
-- With a [`metadata` store](#compaction-and-persistence), the usage is saved. The next request and a restarted server start from the real count.
-- The strategies still use the estimate to choose which messages to keep.
-
-## Set the limit from the context window
-
-Most models publish a context window. Keep room for the answer: set `maxTokens` to the window minus a reserve.
-
-```ts group=compaction-usage
-import { summarizeOldest } from '@tanstack/ai-compaction'
-import { summarize } from '@tanstack/ai'
-import { openaiSummarize } from '@tanstack/ai-openai'
-
-const contextWindow = 200_000
-const reserveTokens = 20_000
-
-const windowed = withCompaction({
- maxTokens: contextWindow - reserveTokens,
- contextWindow,
- countTokens: 'usage',
- strategy: summarizeOldest({
- keepRecentTokens: 8_000,
- summarize: (messages) =>
- summarize({
- adapter: openaiSummarize('gpt-6.1-sol'),
- text: messages
- .map((m) => `${m.role}: ${typeof m.content === 'string' ? m.content : ''}`)
- .join('\n'),
- }),
- }),
-})
-```
-
-| You want | Set |
-|---|---|
-| Compact before the context passes the window minus a reserve | `maxTokens: contextWindow - reserveTokens` |
-| Keep the newest messages word for word | `keepRecentTokens` on the strategy |
-| On a host with a session log: no automatic compaction, but recovery from a silent overflow | `auto: false`, `contextWindow`, `countTokens: 'usage'`, `durable: true` |
-
-Some providers accept a request that is too large. They cut the input and answer without an error. On a host with a [session log](#keep-compaction-in-a-session-log), compaction with `countTokens: 'usage'` and `durable: true` checks once more after the last model call of a run. If the usage passed `maxTokens` or `contextWindow`, it compacts right away, so the next run starts small. With `auto: false`, only `contextWindow` counts. The answer is not sent again. The only extra model calls are the summary calls.
-
-Without a session log, there is no check after a run. The next request compacts before its first model call.
-
-## Prepare the summary in the background
-
-A `summarizeOldest` call takes time. When compaction runs at `maxTokens`, the user waits for that call before the model answers. Set `background` to prepare the summary earlier, while the chat goes on:
-
-```ts group=compaction-background
-import { chat, summarize, toServerSentEventsResponse } from '@tanstack/ai'
-import { openaiSummarize, openaiText } from '@tanstack/ai-openai'
-import { summarizeOldest, withCompaction } from '@tanstack/ai-compaction'
-
-// Create it once, so a summary from one request reaches the next.
-const compaction = withCompaction({
- maxTokens: 100_000,
- background: { atTokens: 80_000 },
- strategy: summarizeOldest({
- summarize: (messages) =>
- summarize({
- adapter: openaiSummarize('gpt-6.1-sol'),
- text: messages
- .map((m) => `${m.role}: ${typeof m.content === 'string' ? m.content : ''}`)
- .join('\n'),
- }),
- }),
-})
-
-export async function POST(request: Request) {
- const { messages, threadId } = await request.json()
-
- const stream = chat({
- adapter: openaiText('gpt-6.1-sol'),
- messages,
- threadId,
- middleware: [compaction],
- })
-
- return toServerSentEventsResponse(stream)
-}
-```
-
-- `atTokens` must be below `maxTokens`. If it is not, `withCompaction` throws.
-- When the count at a model call passes `atTokens`, the summary starts on a copy of the messages. The model call does not wait for it.
-- The ready summary waits in the [`metadata` store](#compaction-and-persistence), under the namespace `@tanstack/ai-compaction:background`. Without a store, it waits in memory.
-- The summary applies at the first model call of the next run on the same `threadId`, never in the middle of a run.
-- A model call over `maxTokens` while the summary runs waits for it and applies it. It compacts inline only when the messages are still over `maxTokens`. A cancel of the run stops the wait, not the summary.
-
-Sometimes a summary does not apply:
-
-- A newer compaction cut past the message that the summary keeps. The summary is dropped and reported with `stale: true`.
-- The summary call failed. Nothing applies, and the failure is reported with `error`. The next model call past `atTokens` starts a new summary.
-
-`onCompact` always gets these reports. A `compaction:ended` event for a failed summary reaches the stream only while the run that started the summary still runs.
-
-## What it keeps safe
-
-- **The system prompt is never dropped.** `chat()` keeps it separate from `messages`, so compaction only touches the conversation.
-- **Tool calls stay paired with their results.** The built-in strategies never leave an orphaned tool result, so the request stays valid.
-- **It runs before every model call.** Compaction skips `init`. It runs on `beforeModel` and `structuredOutput`. Each later call can compact again.
-- **The canonical transcript stays complete.** Compaction writes provider-only context. Persistence and other middleware still read `ctx.messages`.
-
-## Better summaries
-
-The basic `summarizeOldest` setup works for a chat. A coding agent needs more. It must keep the goal, the decisions, and the files it touched across many compactions. Three opt-in parts help:
-
-1. `cut: 'turn'` on `summarizeOldest`: a better summary when the cut falls inside a turn. The older turns get one summary. The start of the current turn gets its own short summary, under `## Turn context`.
-2. `conversationSummarizer`: a ready summarizer with a structured prompt (goal, constraints, progress, key decisions, next steps, critical context). When an older summary exists, it updates that summary. It reports the usage of each summary call.
-3. `details` on `conversationSummarizer`: your own facts after the summary, for example the files the agent read. The next compaction gets them back as `previousDetails`.
-
-```ts group=compaction-better
-import { openaiText } from '@tanstack/ai-openai'
-import {
- conversationSummarizer,
- summarizeOldest,
- withCompaction,
-} from '@tanstack/ai-compaction'
-
-function pathOf(args: string) {
- const input: unknown = JSON.parse(args)
- return typeof input === 'object' &&
- input !== null &&
- 'path' in input &&
- typeof input.path === 'string'
- ? input.path
- : undefined
-}
-
-const agentCompaction = withCompaction({
- maxTokens: 180_000,
- contextWindow: 200_000,
- countTokens: 'usage',
- strategy: summarizeOldest({
- cut: 'turn',
- keepRecentTokens: 8_000,
- summarize: conversationSummarizer({
- adapter: openaiText('gpt-6.1-sol'),
- details: ({ messages, previousDetails }) => {
- const files = new Set(previousDetails?.split('\n').filter(Boolean))
- for (const message of messages) {
- for (const call of message.toolCalls ?? []) {
- const path =
- call.function.name === 'read_file'
- ? pathOf(call.function.arguments)
- : undefined
- if (path) files.add(path)
- }
- }
- return files.size > 0 ? [...files].join('\n') : undefined
- },
- }),
- }),
-})
-```
-
-- In the text sent to the summarizer, each tool result is cut to 2,000 characters. Set `maxToolResultChars` to change it.
-- A `summarize` function of your own gets a second argument, `summarize(messages, input)`:
- - `previousSummary` and `previousDetails`: the older summary and its details.
- - `turnPrefix`: `true` when the call summarizes the start of the current turn.
- - `turnPrefixMessages`: the start of the current turn. Only the call that writes the details gets it, so the details cover each dropped message.
- - `signal`: aborts when the run aborts.
-
-## DevTools
-
-After a compaction, the chat stream includes three CUSTOM events in order:
-`compaction:started`, `compaction:state`, then `compaction:ended`.
-`compaction:started` is sent before the strategy runs. Durability flushes each
-event as soon as it is emitted, so a slow `summarizeOldest` call still shows
-as started on the client while it runs. The state and ended events follow when
-the strategy returns.
-
-`compaction:ended` also says why compaction ran and what it cost:
-
-- `reason`: `'threshold'` (the count passed `maxTokens`), `'forced'` (`compactNext`, or the retry after a context overflow), or `'background'` (a [background summary](#prepare-the-summary-in-the-background)).
-- `usage`: the token usage of the summary calls.
-- `error`: the message when the strategy failed. By default the run then fails. Set `continueOnError: true` to send the full messages instead.
-- `stale`: `true` when a ready background summary no longer fit the messages and was dropped.
-
-A compaction after the last model call of a run has `reason: 'after-turn'`. The stream is closed by then, so only `onCompact` reports it. A failed strategy or `onCompact` there does not fail the run, because the answer is complete. Only a failed write to the log fails it.
-
-TanStack AI DevTools has a Compaction tab on the hook. Each compact shows:
-
-- started, state, and ended rows
-- when it ran, with token and message counts
-- the `maxTokens` budget
-- dropped messages
-- the transcript sent to the model
-
-The conversation timeline also keeps `onCompactStart`, `onCompact`, and
-`onCompactEnd` steps.
-
-Open the AI plugin in the DevTools panel (the `ts-react-chat` example mounts it). Select the Compaction hook, then open the Compaction tab.
-
-The `/compaction` route in `examples/ts-react-chat` uses a small `maxTokens` so this fires after a few turns. It also uses `withPersistence` (SQLite). Reload after a compact: the chat still shows every message. The banner is example UI, not part of `useChat`.
+To make sure that the retry always has something to drop, see [Give the retry something to cut](./compaction-advanced#give-the-retry-something-to-cut).
## Compaction and persistence
-Compaction and server-side [`withPersistence`](../persistence/chat-persistence)
-use two message views:
-
-- `messages` is the complete canonical transcript. Persistence saves this view.
-- `providerMessages` is temporary model context. Compaction rewrites this view.
-
-Middleware order does not change this split. Dropped, summarized, and stubbed
-content remains in the message store.
-
-If the persistence adapter has a `metadata` store, compaction also saves a small
-checkpoint. The next request validates the canonical prefix, restores the last
-compacted result, and adds only new messages. A changed prefix or strategy key
-invalidates the checkpoint.
+Compaction and server-side [`withPersistence`](../persistence/chat-persistence) use two views of the messages:
-When a checkpoint is reused, a later `summarizeOldest` pass sees the previous
-summary plus new messages. Then it folds the old summary into the new one.
-Folding needs a metadata store and a strategy key.
+- `messages` is the full transcript. Persistence saves this view.
+- `providerMessages` is the context for the model. Compaction rewrites this view.
-The default strategy, standard `evictOldest`, `summarizeOldest`,
-`clearToolResults`, and safe compositions get a strategy key automatically.
-Set `strategyKey` for custom strategies, custom estimators, or custom marker
-functions. Change `strategyKey` when your `summarize` function can change.
-Without a metadata store or safe key, compaction stays stateless.
+The order of the middleware does not change this split. Dropped, summarized, and stubbed content stays in the message store.
-With `durable: true` on a host with a session log, compaction writes its result into the log instead. See [Keep compaction in a session log](#keep-compaction-in-a-session-log).
-
-## Keep compaction in a session log
-
-Some hosts keep a durable log for each thread and rebuild the model context from it when a session opens again. A metadata checkpoint can miss on that rebuild. Set `durable: true` so the compaction goes into the log, and the rebuilt session sees the same context.
-
-1. The host provides `LogRecordsCapability`. See [Append records to the session log](./middleware#append-records-to-the-session-log).
-2. Add `withCompaction({ durable: true })` to the same `chat()` call.
-3. Fold each compaction record into the messages with `projectCompaction`: for the next model call, and when the host rebuilds the session.
-
-```ts group=compaction-durable
-import type { ModelMessage } from '@tanstack/ai'
-import { projectCompaction, withCompaction } from '@tanstack/ai-compaction'
-
-const compaction = withCompaction({
- maxTokens: 180_000,
- contextWindow: 200_000,
- countTokens: 'usage',
- durable: true,
-})
-
-type LogEntry =
- | { kind: 'message'; message: ModelMessage }
- | { kind: 'record'; record: { type: string; [key: string]: unknown } }
-
-// Fold the log in order. A compaction record replaces the messages before it.
-function contextOf(log: Array): Array {
- let messages: Array = []
- for (const entry of log) {
- if (entry.kind === 'message') messages = [...messages, entry.message]
- else messages = projectCompaction({ messages, record: entry.record }) ?? messages
- }
- return messages
-}
-```
+If the persistence adapter has a `metadata` store, compaction also saves a small checkpoint. The next request then starts from the last compacted result. See [Checkpoints](./compaction-advanced#checkpoints) for the details.
-- Each compaction writes one record with the type `'tanstack.compaction'` (`COMPACTION_RECORD_TYPE`). The host must accept this type.
-- `projectCompaction` returns `undefined` for a record of another type, and for a record that does not fit the messages. Then keep the messages as they are.
-- Without the fold for the next model call, each later call of the run compacts the full list again.
-- The log only grows, so the original messages stay in the store.
-- Without `LogRecordsCapability`, compaction uses the metadata checkpoint, and there is no check after the run.
+## Advanced
-## Next steps
+[Compaction: Advanced](./compaction-advanced) has the deep details:
-- [Middleware](./middleware): the full hook reference and how middleware composes
-- [Built-in Middleware](./built-in-middleware): ready-made middleware that ships in `@tanstack/ai`
+- Every option of `withCompaction` and of each strategy.
+- Your own strategy, and compaction on your own schedule.
+- Counts from the real usage of the provider, and summaries that run in the background.
+- DevTools events, checkpoints, and session logs.
diff --git a/docs/advanced/debug-logging.md b/docs/advanced/debug-logging.md
index c1e6168d68..c14a356c71 100644
--- a/docs/advanced/debug-logging.md
+++ b/docs/advanced/debug-logging.md
@@ -2,7 +2,7 @@
title: Debug Logging
id: debug-logging
order: 3
-description: "Turn on structured, category-toggleable debug logging to see every chunk, middleware transform, and tool call inside TanStack AI."
+description: "Turn on debug logging to see each chunk, middleware transform, and tool call inside TanStack AI. Pick the categories you want, or send the output to your own logger."
keywords:
- tanstack ai
- debug
@@ -14,57 +14,61 @@ keywords:
- middleware debugging
---
-You have a `chat()` that isn't behaving as expected — a missing chunk, a middleware that doesn't seem to fire, a tool call with wrong args. By the end of this guide, you'll have turned on debug logging and will see every chunk, middleware transform, and tool call flowing through your call.
+Debug logging prints each chunk, middleware step, and tool argument from `chat()`. Turn it on when a run does not match what you expect.
-## Turn it on
+Turn on debug logging. You then see each chunk, middleware transform, and tool call in your console.
-Add `debug: true` to any activity call:
+## Quick setup
+
+Add `debug: true` to the call:
```typescript
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
const stream = chat({
- adapter: openaiText("gpt-5.5"),
+ adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "Hello" }],
+ threadId: "thread-1",
debug: true,
});
```
-Every internal event now prints to the console with a `[tanstack-ai:]` prefix:
+Run it. Each internal event prints to the console with a `[tanstack-ai:]` prefix:
```
-[tanstack-ai:request] activity=chat provider=openai model=gpt-5.5 messages=1 tools=0 stream=true
+[tanstack-ai:request] activity=chat provider=openai model=gpt-6.1-sol messages=1 tools=0 stream=true
[tanstack-ai:agentLoop] run started
[tanstack-ai:provider] provider=openai type=response.output_text.delta
[tanstack-ai:output] type=TEXT_MESSAGE_CONTENT
-...
```
-## Narrow what's printed
+## Print less
-Pass a `DebugConfig` object instead of `true`. Every unspecified category defaults to `true`, so toggle by setting specific flags to `false`:
+Pass an object in place of `true`. Each category that you do not name stays on. Set a category to `false` to turn it off:
```typescript
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
chat({
- adapter: openaiText("gpt-5.5"),
+ adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "Hello" }],
+ threadId: "thread-1",
debug: { middleware: false }, // everything except middleware
});
```
-If you want to see ONLY a specific set of categories, set the rest to `false` explicitly. Errors default to `true` — keep them on unless you really want total silence:
+To see only some categories, set all the others to `false`. Keep `errors` on. Error logs are cheap and you need them.
```typescript
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
chat({
- adapter: openaiText("gpt-5.5"),
+ adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "Hello" }],
+ threadId: "thread-1",
debug: {
provider: true,
output: true,
@@ -72,48 +76,89 @@ chat({
tools: false,
agentLoop: false,
config: false,
- errors: true, // keep errors on — they're cheap and important
+ errors: true,
request: false,
+ sandbox: false,
},
});
```
-## Pipe into your own logger
+## Send logs to your own logger
-Pass a `Logger` implementation and all debug output flows through it instead of `console`:
+Pass a `Logger` as `logger`. All debug output then goes to it, not to `console`:
```typescript
import { chat, type Logger } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import pino from "pino";
-import { messages } from "./server";
const pinoLogger = pino();
const logger: Logger = {
debug: (msg, meta) => pinoLogger.debug(meta, msg),
- info: (msg, meta) => pinoLogger.info(meta, msg),
- warn: (msg, meta) => pinoLogger.warn(meta, msg),
+ info: (msg, meta) => pinoLogger.info(meta, msg),
+ warn: (msg, meta) => pinoLogger.warn(meta, msg),
error: (msg, meta) => pinoLogger.error(meta, msg),
};
chat({
- adapter: openaiText("gpt-5.5"),
- messages,
- debug: { logger }, // all categories on, piped to pino
+ adapter: openaiText("gpt-6.1-sol"),
+ messages: [{ role: "user", content: "Hello" }],
+ threadId: "thread-1",
+ debug: { logger }, // all categories on, sent to pino
});
```
-The default logger is exported as `ConsoleLogger` if you want to wrap it:
+The default logger is `ConsoleLogger`. You can import it to wrap it:
```typescript
import { ConsoleLogger } from "@tanstack/ai";
```
-### Your `Logger` is wrapped in a try/catch
+## Categories
+
+| Category | Logs | Applies to |
+|----------|------|------------|
+| `request` | The outgoing call to a provider (model, message count, tool count) | All activities |
+| `provider` | Each raw chunk or frame from a provider SDK | Streaming activities (`chat`, `realtime`, and streaming `generateAudio`, `generateSpeech`, `generateTranscription`) |
+| `output` | Each chunk or result that goes to the caller | All activities |
+| `middleware` | Inputs and outputs around each middleware hook | `chat()` only |
+| `tools` | Before and after each tool call | `chat()` only |
+| `agentLoop` | Agent-loop iterations and phase changes | `chat()` only |
+| `config` | Config changes that middleware `onConfig` hooks return | `chat()` only |
+| `errors` | Each caught error in the pipeline | All activities |
+| `sandbox` | Sandbox internals: watcher start and stop, file events, sandbox hooks, and lifecycle changes | `chat()` only |
+
+Building a middleware? `debug: { middleware: true }` shows the chunks that go through it, with no logging middleware of your own. See [Middleware](./middleware) to write one.
+
+## Advanced
+
+### Errors are always logged
+
+The logger gets errors even when you leave out `debug`:
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
+
+// On a failure, this still prints [tanstack-ai:errors] ...
+chat({
+ adapter: openaiText("gpt-6.1-sol"),
+ messages: [{ role: "user", content: "Hello" }],
+ threadId: "thread-1",
+});
+```
+
+To turn off all output, errors included, set `debug: false` or `debug: { errors: false }`.
+
+The logger is an extra output. Errors also always reach the caller, as thrown exceptions or `RUN_ERROR` stream chunks.
+
+### A failing logger does not break the call
-If your `Logger` implementation throws — a cyclic-meta `JSON.stringify`, a transport that rejects synchronously, a typo in a bound `this` — the exception is swallowed so it never masks the real error that triggered the log call (for example, a provider SDK failure inside the chat stream). You won't see the log line, but the pipeline error still surfaces through thrown exceptions and `RUN_ERROR` chunks.
+The library calls your `Logger` inside a try/catch. If your logger throws, the library drops the exception. Examples are a `JSON.stringify` on a cyclic `meta`, a transport that rejects synchronously, or a wrong `this` binding.
-If you need to know when your own logger is failing, guard inside your implementation:
+This way, a logger failure never hides the real error that caused the log call, for example a provider SDK failure in the chat stream. You do not see the log line. The pipeline error still reaches you through thrown exceptions and `RUN_ERROR` chunks.
+
+To know when your own logger fails, add a guard in your implementation:
```typescript
import { type Logger } from "@tanstack/ai";
@@ -125,45 +170,19 @@ const logger: Logger = {
try {
pinoLogger.debug(meta, msg);
} catch (err) {
- // surface to wherever you track infra errors
+ // Send it to the place where you track infrastructure errors.
process.stderr.write(`logger failed: ${String(err)}\n`);
}
},
- info: (msg, meta) => pinoLogger.info(meta, msg),
- warn: (msg, meta) => pinoLogger.warn(meta, msg),
+ info: (msg, meta) => pinoLogger.info(meta, msg),
+ warn: (msg, meta) => pinoLogger.warn(meta, msg),
error: (msg, meta) => pinoLogger.error(meta, msg),
};
```
-## Categories reference
-
-| Category | Logs | Applies to |
-|----------|------|------------|
-| `request` | Outgoing call to a provider (model, message count, tool count) | All activities |
-| `provider` | Every raw chunk/frame received from a provider SDK | Streaming activities (`chat`, `realtime`, and streaming `generateAudio`/`generateSpeech`/`generateTranscription`) |
-| `output` | Every chunk or result yielded to the caller | All activities |
-| `middleware` | Inputs and outputs around every middleware hook | `chat()` only |
-| `tools` | Before/after tool call execution | `chat()` only |
-| `agentLoop` | Agent-loop iterations and phase transitions | `chat()` only |
-| `config` | Config transforms returned by middleware `onConfig` hooks | `chat()` only |
-| `errors` | Every caught error anywhere in the pipeline | All activities |
-
-## Errors are always logged
-
-Errors flow through the logger unconditionally — even when you omit `debug`:
-
-```typescript
-import { chat } from "@tanstack/ai";
-import { adapter } from "./server";
-
-chat({ adapter, messages: [{ role: "user", content: "Hello" }] }); // still prints [tanstack-ai:errors] ... on failure
-```
-
-To fully silence (including errors), set `debug: false` or `debug: { errors: false }`. Errors also always reach the caller via thrown exceptions or `RUN_ERROR` stream chunks — the logger is additive, not the only surface.
+### Other activities
-## Non-chat activities
-
-The same `debug` option works on every activity:
+The same `debug` option works on each activity:
```typescript
import {
@@ -177,20 +196,24 @@ import {
import { adapter } from "./server";
import { logger } from "./logger";
+const threadId = "thread-1";
const text = "Long article to summarize...";
const audio = new File([""], "recording.mp3", { type: "audio/mpeg" });
-summarize({ adapter, text, debug: true });
-generateImage({ adapter, prompt: "a cat", debug: { logger } });
-generateSpeech({ adapter, text, debug: { request: true } });
-generateAudio({ adapter, prompt: "ambient piano", debug: true });
-generateTranscription({ adapter, audio, debug: { provider: true } });
+summarize({ adapter, text, debug: true, threadId });
+generateImage({ adapter, prompt: "a cat", debug: { logger }, threadId });
+generateSpeech({ adapter, text, debug: { request: true }, threadId });
+generateAudio({ adapter, prompt: "ambient piano", debug: true, threadId });
+generateTranscription({ adapter, audio, debug: { provider: true }, threadId });
```
-When streaming any of these (`generateAudio`, `generateSpeech`, `generateTranscription` with `stream: true`), the `provider` category emits the raw SDK chunks and `output` emits the AG-UI-shaped chunks yielded to the caller — useful when a media pipeline looks stuck or the bytes arriving don't match what you expected.
+When you stream `generateAudio`, `generateSpeech`, or `generateTranscription` (`stream: true`):
+
+- `provider` shows the raw SDK chunks.
+- `output` shows the AG-UI chunks that go to the caller.
-The chat-only categories (`middleware`, `tools`, `agentLoop`, `config`) simply never fire for these activities because those concepts don't exist in their pipelines.
+If a media pipeline seems stuck, or the bytes are not what you expect, read these two categories.
-## Related
+The chat-only categories (`middleware`, `tools`, `agentLoop`, `config`) never fire for these activities. Those parts do not exist in their pipelines.
-If you're building middleware and want to see chunks flow through it, `debug: { middleware: true }` is faster than writing a logging middleware. See [Middleware](./middleware) for writing your own middleware.
+For traces and metrics in production, use [OpenTelemetry](./otel).
diff --git a/docs/advanced/extend-adapter-advanced.md b/docs/advanced/extend-adapter-advanced.md
new file mode 100644
index 0000000000..8cc3a208df
--- /dev/null
+++ b/docs/advanced/extend-adapter-advanced.md
@@ -0,0 +1,262 @@
+---
+title: "Extend Adapter: Advanced"
+id: extend-adapter-advanced
+description: "Hooks for your own text adapter: input types, reasoning levels, the prompt cache, mid-conversation changes, and the order of assistant blocks."
+keywords:
+ - tanstack ai
+ - custom adapter
+ - inputModalities
+ - reasoning
+ - prompt cache
+ - mid-conversation changes
+ - orderedAssistantBlocks
+---
+
+This page is for people who write a text adapter for a new provider. To add model names to an adapter that exists, see [Extend Adapter](./extend-adapter). For the full adapter walkthrough, see the [community adapter guide](../community-adapters/guide).
+
+## Set inputModalities in your own adapter
+
+`chat()` and your app read `adapter.inputModalities` to know which content types a model can read. Set it from your model list:
+
+```typescript
+import { BaseTextAdapter } from '@tanstack/ai/adapters'
+import type { DefaultMessageMetadataByModality, Modality } from '@tanstack/ai'
+
+const INPUT_BY_MODEL: Record> = {
+ 'my-vision-model': ['text', 'image'],
+ 'my-text-model': ['text'],
+}
+
+class MyTextAdapter extends BaseTextAdapter<
+ string,
+ Record,
+ ReadonlyArray,
+ DefaultMessageMetadataByModality
+> {
+ readonly name = 'my-provider'
+ override readonly inputModalities = INPUT_BY_MODEL[this.model]
+
+ constructor(model: string) {
+ super({}, model)
+ }
+
+ async *chatStream() {
+ // Call your provider and yield its chunks here.
+ }
+
+ structuredOutput() {
+ return Promise.resolve({ data: {}, rawText: '{}' })
+ }
+}
+
+console.log(new MyTextAdapter('my-vision-model').inputModalities) // ['text', 'image']
+```
+
+For a model that is not in the list, the value is `undefined`. Apps read `undefined` as "not known".
+
+## Add reasoning to your adapter
+
+Your adapter talks to a thinking model. You want `chat({ reasoning: 'high' })` to work, and you want a type error for a level that the model does not have.
+
+1. Put a `reasoning` field on the metadata of each model.
+2. Build a type map of the levels with `ModelReasoningCapability`. Build a runtime map with the same keys.
+3. Pass the levels to the adapter type. Return the runtime data from the `modelReasoning` hook.
+
+On an adapter that extends `@tanstack/openai-base`, that is all. The base sends `reasoning_effort` on Chat Completions, or `reasoning: { effort, summary }` on Responses:
+
+```typescript
+import OpenAI from 'openai'
+import { chat } from '@tanstack/ai'
+import { OpenAIBaseChatCompletionsTextAdapter } from '@tanstack/openai-base'
+import type {
+ DefaultMessageMetadataByModality,
+ Modality,
+ ModelReasoning,
+} from '@tanstack/ai'
+import type { ModelReasoningCapability } from '@tanstack/ai/adapter-internals'
+
+// 1. The data of the model: only the levels it has, with the value for each.
+const MY_MODEL = {
+ name: 'my-model',
+ reasoning: {
+ levels: { off: 'none', low: 'low', medium: 'medium', high: 'high' },
+ budget: false,
+ },
+} as const
+
+// 2. The levels for the types, and the same data at run time.
+const MY_REASONING = {
+ [MY_MODEL.name]: MY_MODEL.reasoning,
+}
+
+type MyModelReasoningByName = {
+ [K in keyof typeof MY_REASONING]: ModelReasoningCapability<
+ (typeof MY_REASONING)[K]
+ >
+}
+
+const MY_MODEL_REASONING: Readonly> =
+ MY_REASONING
+
+type MyModel = keyof MyModelReasoningByName
+
+// 3. The levels go in the last type parameter. The hook gives the data.
+class MyTextAdapter extends OpenAIBaseChatCompletionsTextAdapter<
+ TModel,
+ Record,
+ ReadonlyArray,
+ DefaultMessageMetadataByModality,
+ ReadonlyArray,
+ MyModelReasoningByName[TModel]
+> {
+ protected override modelReasoning(model: string) {
+ return MY_MODEL_REASONING[model]
+ }
+}
+
+const adapter = new MyTextAdapter(
+ 'my-model',
+ 'my-provider',
+ new OpenAI({ apiKey: 'my-key', baseURL: 'https://api.example.com/v1' }),
+)
+
+chat({ adapter, messages: [{ role: 'user', content: 'Hi' }], reasoning: 'high', threadId: 'thread-1' })
+
+chat({
+ adapter,
+ messages: [{ role: 'user', content: 'Hi' }],
+ threadId: 'thread-1',
+ // @ts-expect-error my-model has no max level
+ reasoning: 'max',
+})
+```
+
+- A model with `budget: true` also takes `budgetTokens`.
+- A model added with `extendAdapter` has no reasoning data, so it takes no `reasoning`.
+
+Does your provider use another wire format? Then do these steps:
+
+1. Extend `BaseTextAdapter`, and pass the levels as its last type parameter.
+2. Read `options.reasoning` in your request code.
+3. Use the helpers from `@tanstack/ai/adapter-internals`:
+ - `resolveReasoning` moves the level to the nearest level that the model has, and gives its value.
+ - `reasoningBudget` gives the token budget for a level.
+
+## Send the prompt cache
+
+`chat()` asks every adapter for [prompt caching](./prompt-caching) by default. It passes the setting as `options.promptCache`:
+
+- `retention`: `'none'`, `'short'`, or `'long'`.
+- `key`: optional.
+
+When you build the request, map it to the cache fields of your provider:
+
+```typescript
+import type { TextOptions } from '@tanstack/ai'
+
+export function cacheFields(options: TextOptions) {
+ const cache = options.promptCache
+ if (!cache || cache.retention === 'none') return {}
+ return {
+ ...(cache.key ? { cache_key: cache.key } : {}),
+ ...(cache.retention === 'long' ? { cache_ttl: '1h' } : {}),
+ }
+}
+```
+
+- If `promptCache` is `undefined` (a call outside `chat()`), send no cache fields.
+- Put the cache fields first and the `modelOptions` of the user after them. Then a value that the user sets wins.
+
+## Send mid-conversation changes
+
+Tools or system prompts can grow between model calls. If your provider takes them inside the conversation, the cached start of the request can stay the same. To support it:
+
+1. Set `midConversationChannels` on the adapter: `{ tools, systemPrompts }`. Then `chat()` passes the changes as `options.midConversationChanges`.
+2. Read them with `splitMidConversationChanges`. You get the start lists, and the changes by message index. You get `undefined` when the names or counts do not match the current lists.
+3. Send the start lists at the top of the request. Send each change right before the message at its index. A change at `options.messages.length` goes at the end.
+
+Do you extend the Responses adapter of `@tanstack/openai-base`? Then set the field and you are done. The base sends `additional_tools` and `developer` messages:
+
+```typescript
+import { OpenAIBaseResponsesTextAdapter } from '@tanstack/openai-base'
+
+export class MyResponsesAdapter extends OpenAIBaseResponsesTextAdapter<'my-model'> {
+ override readonly midConversationChannels = { tools: true, systemPrompts: true }
+}
+```
+
+With another wire format, do the steps yourself:
+
+```typescript
+import { splitMidConversationChanges } from '@tanstack/ai'
+import type { ModelMessage, TextOptions } from '@tanstack/ai'
+
+type WireTool = { name: string; description: string }
+type WireItem =
+ | { kind: 'message'; message: ModelMessage }
+ | { kind: 'change'; tools: Array; prompts: Array }
+
+export function buildRequest(
+ options: TextOptions,
+ tools: Array,
+ prompts: Array,
+) {
+ const changes = options.midConversationChanges
+ const split = changes
+ ? splitMidConversationChanges({ changes, tools, systemPrompts: prompts })
+ : undefined
+ if (!split) {
+ // No changes, or names that do not match: send the full lists.
+ const items = options.messages.map((message): WireItem => ({ kind: 'message', message }))
+ return { tools, system: prompts, items }
+ }
+ const items: Array = []
+ const pushChange = (index: number) => {
+ const change = split.at.get(index)
+ if (change) items.push({ kind: 'change', tools: change.tools, prompts: change.systemPrompts })
+ }
+ options.messages.forEach((message, index) => {
+ pushChange(index)
+ items.push({ kind: 'message', message })
+ })
+ pushChange(options.messages.length)
+ return { tools: split.startTools, system: split.startSystemPrompts, items }
+}
+```
+
+- `split.addedTools` lists every added tool, in order. If your provider also wants them in its tool list, use it.
+- Some providers cannot add a kind of tool later (a hosted search tool, for example). For that request, send the full tool list. Keep the prompt changes in the conversation.
+
+## Send assistant blocks in order
+
+Your provider wants thinking, text, and tool calls in the order that the model sent them. A `ModelMessage` keeps them in three separate fields: `thinking`, `content`, and `toolCalls`. Use `orderedAssistantBlocks` to get them back in order:
+
+```typescript
+import { orderedAssistantBlocks } from '@tanstack/ai'
+import type { ModelMessage } from '@tanstack/ai'
+
+type WireBlock =
+ | { type: 'reasoning'; text: string; signature?: string }
+ | { type: 'text'; text: string }
+ | { type: 'call'; id: string; name: string; args: string }
+
+export function orderedWireBlocks(message: ModelMessage): Array | undefined {
+ const blocks = orderedAssistantBlocks(message)
+ if (!blocks) return undefined
+ return blocks.map((block): WireBlock => {
+ if (block.type === 'thinking') {
+ return { type: 'reasoning', text: block.thinking.content, signature: block.thinking.signature }
+ }
+ if (block.type === 'text') return { type: 'text', text: block.text }
+ return {
+ type: 'call',
+ id: block.toolCall.id,
+ name: block.toolCall.function.name,
+ args: block.toolCall.function.arguments,
+ }
+ })
+}
+```
+
+- The order comes from `message.blockOrder`. The library writes it only when the order is not the default. The default is all thinking, then the text, then the tool calls.
+- `orderedAssistantBlocks` returns `undefined` when the message has no map, or when the map does not match the message. Then send your default order.
diff --git a/docs/advanced/extend-adapter.md b/docs/advanced/extend-adapter.md
index 7f6b1e4d17..36a8069a1f 100644
--- a/docs/advanced/extend-adapter.md
+++ b/docs/advanced/extend-adapter.md
@@ -2,7 +2,7 @@
title: Extend Adapter
id: extend-adapter
order: 8
-description: "Extend TanStack AI adapter factories with custom model IDs and fine-tuned models while keeping full type safety for input modalities and provider options."
+description: "Add your own model names, such as fine-tuned models or proxy models, to an adapter factory. You keep type checks for input types and provider options."
keywords:
- tanstack ai
- extendAdapter
@@ -13,155 +13,119 @@ keywords:
- adapter factory
---
-The `extendAdapter` utility allows you to extend existing adapter factories (like `openaiText`, `anthropicText`) with custom model names while maintaining full type safety for input modalities and provider options.
+`extendAdapter` adds your model ids to an existing text adapter. `openaiText('my-fine-tuned-model')` type-checks after you register that id.
-## Basic Usage
+The built-in models keep their types, and your models get the types that you declare.
+
+## Quick setup
+
+### 1. Declare your models
+
+Wrap the factory with `extendAdapter`. Describe each model with `createModel`: the name, then the input types it can read.
```typescript
+// lib/ai/my-openai.ts
import { createModel, extendAdapter } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
-// Define your custom models using createModel helper
-const myOpenaiModel = createModel('my-fine-tuned-gpt4',['text', 'image']);
-const myOpenaiModelButCooler = createModel('my-fine-tuned-gpt5',['text', 'image']);
-
-
-// Create an extended adapter factory - simple API, no type parameters needed
-const myOpenai = extendAdapter(openaiText, [
- myOpenaiModel,
- myOpenaiModelButCooler
+export const myOpenai = extendAdapter(openaiText, [
+ createModel('my-fine-tuned-model', ['text', 'image']),
+ createModel('my-other-model', ['text']),
])
+```
-// Use with original models - full type inference preserved
-const gpt5Adapter = myOpenai('gpt-5.5')
+### 2. Use the new factory
-// Use with custom models - your custom types are applied
-const customAdapter = myOpenai('my-fine-tuned-gpt4')
+Use `myOpenai` where you used `openaiText`:
-// Works seamlessly with chat()
+```typescript
import { chat } from '@tanstack/ai'
+import { myOpenai } from './lib/ai/my-openai'
const stream = chat({
- adapter: myOpenai('my-fine-tuned-gpt4'),
- messages: [{ role: 'user', content: 'Hello!' }]
+ adapter: myOpenai('my-fine-tuned-model'),
+ messages: [{ role: 'user', content: 'Hello!' }],
+ threadId: 'thread-1',
})
-```
-
-## The `createModel` Helper
-The `createModel` function provides a clean way to define custom models with full type inference:
-
-```typescript
-import { createModel } from '@tanstack/ai'
-
-// Arguments define name and input modalities
-const model = createModel(
- 'my-model', // model name (literal type inferred)
- ['text', 'image'] // input modalities (tuple type inferred)
-)
+for await (const chunk of stream) {
+ console.log(chunk)
+}
```
-
-
-## Model Definition Structure
-A custom model definition (`ExtendedModelDef`) has the required properties `name`, `input`, and `modelOptions`, plus optional `features` and `tools`. The two `createModel` overloads let you fill these in two ways.
+Your model name now autocompletes, and the request goes to the provider with that name.
-### Defining Input Modalities
+## Use the built-in models and the factory config
-The positional form takes a model name and an `input` array specifying which content types your model supports:
+The new factory still takes every model of the original factory. It also takes the same config argument:
```typescript
-import { createModel } from '@tanstack/ai'
-
-const models = [
- createModel('text-only-model', ['text']),
- createModel('multimodal-model', ['text', 'image', 'audio']),
-] as const
-```
-
-Available modalities: `'text'`, `'image'`, `'audio'`, `'video'`, `'document'`
-
-### Capabilities-object form
-
-To attach typed `modelOptions`, declared `features`, or provider `tools` to a custom model, use the second `createModel` overload, which takes a capabilities object as its second argument:
+import { createModel, extendAdapter } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
-```typescript
-import { createModel } from '@tanstack/ai'
-import type { OpenAITextProviderOptions } from '@tanstack/ai-openai'
+const myOpenai = extendAdapter(openaiText, [
+ createModel('my-fine-tuned-model', ['text', 'image']),
+])
-// Type brand for provider options — the value is unused at runtime.
-const modelOptions: OpenAITextProviderOptions = {}
+// A built-in model, with its normal types
+const builtIn = myOpenai('gpt-6.1-sol')
-const reasoner = createModel('my-reasoner', {
- input: ['text'],
- features: ['reasoning', 'structured_outputs'],
- tools: ['web_search'],
- modelOptions,
+// Your model, with a config for the client
+const custom = myOpenai('my-fine-tuned-model', {
+ baseURL: 'https://my-proxy.com/v1',
+ timeout: 30000,
})
```
-- `input` — supported input modalities (same as the positional form).
-- `features` — declared feature flags (e.g. `'reasoning'`, `'structured_outputs'`).
-- `tools` — declared provider tools (e.g. `'web_search'`).
-- `modelOptions` — a type brand for the provider options accepted by this model; the value is unused at runtime, so declare an empty object typed as the provider options (e.g. `const modelOptions: OpenAITextProviderOptions = {}`).
-
-## Preserving Original Factory Behavior
+## Example: an OpenAI-compatible proxy
-`extendAdapter` fully preserves the original factory's signature, including any configuration parameters:
+Give types to the models that your proxy serves:
```typescript
import { createModel, extendAdapter } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
-const customModels = [createModel('my-fine-tuned-gpt4', ['text', 'image'])] as const
-
-const myOpenai = extendAdapter(openaiText, customModels)
+const proxyAdapter = extendAdapter(openaiText, [
+ createModel('llama-3.1-70b', ['text']),
+ createModel('mixtral-8x7b', ['text']),
+])
-// Config parameter is preserved
-const adapter = myOpenai('my-fine-tuned-gpt4', {
- baseURL: 'https://my-proxy.com/v1',
- timeout: 30000
+const adapter = proxyAdapter('llama-3.1-70b', {
+ baseURL: 'https://my-llm-proxy.com/v1',
})
```
-## Type Safety
-
-The extended adapter provides full type safety:
-
-```typescript ignore
-import { extendAdapter, createModel } from '@tanstack/ai'
-import { openaiText } from '@tanstack/ai-openai'
+## Example: a fine-tuned model on Anthropic
-const myOpenai = extendAdapter(openaiText, [createModel('custom-model', ['text'])])
+`extendAdapter` works with every adapter factory, for example `anthropicText`:
-// ✅ Original models work with their original types
-const a1 = myOpenai('gpt-5.5')
+```typescript
+import { chat, createModel, extendAdapter } from '@tanstack/ai'
+import { anthropicText } from '@tanstack/ai-anthropic'
-// ✅ Custom models work with your defined types
-const a2 = myOpenai('custom-model')
+const myAnthropic = extendAdapter(anthropicText, [
+ createModel('ft:claude-3-opus:my-org:custom-task:abc123', ['text', 'image']),
+])
-// ❌ Type error: invalid model name
-// Note: Type checking works when you assign the result to a variable
-const invalid = myOpenai('nonexistent-model') // TypeScript error!
+const stream = chat({
+ adapter: myAnthropic('ft:claude-3-opus:my-org:custom-task:abc123'),
+ messages: [{ role: 'user', content: 'Analyze this text.' }],
+ threadId: 'thread-1',
+})
```
-
-## Runtime Behavior
+## How it works
-At runtime, `extendAdapter` simply passes through to the original factory:
+`extendAdapter` changes only the types. At runtime, it returns the original factory.
-- No validation is performed on custom model names
-- The original factory receives exactly what you pass
-- This allows the original provider's API to handle the model name
-
-This design is intentional - it allows you to:
-- Use fine-tuned model names that the provider accepts but TypeScript doesn't know about
-- Proxy requests to different backends that accept custom model identifiers
-- Add type safety without runtime overhead
+- The library does not check your model names. The factory gets exactly what you pass.
+- The API of the provider decides if the model name is valid.
+- There is no runtime cost.
+- A custom model has no reasoning data, so `chat()` does not take a `reasoning` option for it.
## Check what a model accepts
-A user attaches an image, but the selected model only reads text. You want to know that before you send the request. Read `inputModalities` on the text adapter:
+A user attaches an image, but the selected model reads only text. You want to know that before you send the request. Read `inputModalities` on the text adapter:
```typescript
import { openaiText } from '@tanstack/ai-openai'
@@ -178,285 +142,78 @@ if (!adapter.inputModalities?.includes('image')) {
- The list comes from the model metadata of the provider package.
- It is `undefined` when the package does not know the model, for example a custom model from `extendAdapter`. Treat `undefined` as "not known", not as "text only".
-If you write your own adapter, set `inputModalities` from your model list:
-
-```typescript
-import { BaseTextAdapter } from '@tanstack/ai/adapters'
-import type { DefaultMessageMetadataByModality, Modality } from '@tanstack/ai'
-
-const INPUT_BY_MODEL: Record> = {
- 'my-vision-model': ['text', 'image'],
- 'my-text-model': ['text'],
-}
-
-class MyTextAdapter extends BaseTextAdapter<
- string,
- Record,
- ReadonlyArray,
- DefaultMessageMetadataByModality
-> {
- readonly name = 'my-provider'
- override readonly inputModalities = INPUT_BY_MODEL[this.model]
-
- constructor(model: string) {
- super({}, model)
- }
-
- async *chatStream() {
- // Call your provider and yield its chunks here.
- }
-
- structuredOutput() {
- return Promise.resolve({ data: {}, rawText: '{}' })
- }
-}
-
-console.log(new MyTextAdapter('my-vision-model').inputModalities) // ['text', 'image']
-```
-
-## Add reasoning to your adapter
+Do you write your own adapter? See [Set inputModalities in your own adapter](./extend-adapter-advanced#set-inputmodalities-in-your-own-adapter).
-Your adapter talks to a thinking model, and you want `chat({ reasoning: 'high' })` to work, with a type error for a level the model does not have. Three steps:
+## Advanced
-1. Put a `reasoning` field on each model's metadata.
-2. Build a type map of the levels with `ModelReasoningCapability`, and a runtime map with the same keys.
-3. Pass the levels to the adapter type, and return the runtime data from the `modelReasoning` hook.
+### Input types
-On an adapter built on `@tanstack/openai-base`, that is all. The base sends `reasoning_effort` on Chat Completions, or `reasoning: { effort, summary }` on Responses:
+The second argument of the positional form lists the input types of the model:
```typescript
-import OpenAI from 'openai'
-import { chat } from '@tanstack/ai'
-import { OpenAIBaseChatCompletionsTextAdapter } from '@tanstack/openai-base'
-import type {
- DefaultMessageMetadataByModality,
- Modality,
- ModelReasoning,
-} from '@tanstack/ai'
-import type { ModelReasoningCapability } from '@tanstack/ai/adapter-internals'
-
-// 1. The model's data: only the levels it has, with the value for each.
-const MY_MODEL = {
- name: 'my-model',
- reasoning: {
- levels: { off: 'none', low: 'low', medium: 'medium', high: 'high' },
- budget: false,
- },
-} as const
-
-// 2. The levels for the types, and the same data at run time.
-const MY_REASONING = {
- [MY_MODEL.name]: MY_MODEL.reasoning,
-}
-
-type MyModelReasoningByName = {
- [K in keyof typeof MY_REASONING]: ModelReasoningCapability<
- (typeof MY_REASONING)[K]
- >
-}
-
-const MY_MODEL_REASONING: Readonly> =
- MY_REASONING
-
-type MyModel = keyof MyModelReasoningByName
-
-// 3. The levels go in the last type parameter. The hook gives the data.
-class MyTextAdapter extends OpenAIBaseChatCompletionsTextAdapter<
- TModel,
- Record,
- ReadonlyArray,
- DefaultMessageMetadataByModality,
- ReadonlyArray,
- MyModelReasoningByName[TModel]
-> {
- protected override modelReasoning(model: string) {
- return MY_MODEL_REASONING[model]
- }
-}
-
-const adapter = new MyTextAdapter(
- 'my-model',
- 'my-provider',
- new OpenAI({ apiKey: 'my-key', baseURL: 'https://api.example.com/v1' }),
-)
-
-chat({ adapter, messages: [{ role: 'user', content: 'Hi' }], reasoning: 'high' })
-
-chat({
- adapter,
- messages: [{ role: 'user', content: 'Hi' }],
- // @ts-expect-error my-model has no max level
- reasoning: 'max',
-})
-```
-
-A model with `budget: true` also takes `budgetTokens`.
-
-Another wire format? Extend `BaseTextAdapter`, pass the levels as its last type parameter, and read `options.reasoning` in your request code. `resolveReasoning` from `@tanstack/ai/adapter-internals` moves the level to the nearest one the model has and gives its value. `reasoningBudget` gives the token budget for a level.
-
-A model added with `extendAdapter` has no reasoning data, so it takes no `reasoning`.
-
-## Send the prompt cache
-
-`chat()` asks every adapter for [prompt caching](./prompt-caching) by default. It passes the setting as `options.promptCache`: a `retention` (`'none'`, `'short'`, or `'long'`) and an optional `key`. Map it to your provider's cache fields when you build the request:
+import { createModel } from '@tanstack/ai'
-```typescript
-import type { TextOptions } from '@tanstack/ai'
-
-export function cacheFields(options: TextOptions) {
- const cache = options.promptCache
- if (!cache || cache.retention === 'none') return {}
- return {
- ...(cache.key ? { cache_key: cache.key } : {}),
- ...(cache.retention === 'long' ? { cache_ttl: '1h' } : {}),
- }
-}
+const models = [
+ createModel('text-only-model', ['text']),
+ createModel('multimodal-model', ['text', 'image', 'audio']),
+] as const
```
-- If `promptCache` is `undefined` (a call outside `chat()`), send no cache fields.
-- Put the cache fields first and the user's `modelOptions` after them, so a value the user sets wins.
-
-## Send mid-conversation changes
-
-Tools or system prompts can grow between model calls. If your provider takes them inside the conversation, the cached start of the request can stay the same. To support it:
+The input types are `'text'`, `'image'`, `'audio'`, `'video'`, and `'document'`.
-1. Set `midConversationChannels` on the adapter: `{ tools, systemPrompts }`. Then `chat()` passes the changes as `options.midConversationChanges`.
-2. Read them with `splitMidConversationChanges`. You get the start lists and the changes by message index. You get `undefined` when the names or counts do not match the current lists.
-3. Send the start lists at the top of the request. Send each change right before the message at its index. A change at `options.messages.length` goes at the end.
-
-Extending the Responses adapter of `@tanstack/openai-base`? Set the field and you are done. The base sends `additional_tools` and `developer` messages:
-
-```typescript
-import { OpenAIBaseResponsesTextAdapter } from '@tanstack/openai-base'
+### Capabilities-object form
-export class MyResponsesAdapter extends OpenAIBaseResponsesTextAdapter<'my-model'> {
- override readonly midConversationChannels = { tools: true, systemPrompts: true }
-}
-```
+A model definition (`ExtendedModelDef`) has three required fields: `name`, `input`, and `modelOptions`. It also has two optional fields: `features` and `tools`.
-With another wire format, do the steps yourself:
+To set the typed `modelOptions`, the `features`, or the provider `tools`, give `createModel` a capabilities object as the second argument:
```typescript
-import { splitMidConversationChanges } from '@tanstack/ai'
-import type { ModelMessage, TextOptions } from '@tanstack/ai'
-
-type WireTool = { name: string; description: string }
-type WireItem =
- | { kind: 'message'; message: ModelMessage }
- | { kind: 'change'; tools: Array; prompts: Array }
-
-export function buildRequest(
- options: TextOptions,
- tools: Array,
- prompts: Array,
-) {
- const changes = options.midConversationChanges
- const split = changes
- ? splitMidConversationChanges({ changes, tools, systemPrompts: prompts })
- : undefined
- if (!split) {
- // No changes, or names that do not match: send the full lists.
- const items = options.messages.map((message): WireItem => ({ kind: 'message', message }))
- return { tools, system: prompts, items }
- }
- const items: Array = []
- const pushChange = (index: number) => {
- const change = split.at.get(index)
- if (change) items.push({ kind: 'change', tools: change.tools, prompts: change.systemPrompts })
- }
- options.messages.forEach((message, index) => {
- pushChange(index)
- items.push({ kind: 'message', message })
- })
- pushChange(options.messages.length)
- return { tools: split.startTools, system: split.startSystemPrompts, items }
-}
-```
-
-- `split.addedTools` lists every added tool, in order. Use it if your provider also wants them in its tool list.
-- If your provider cannot add a kind of tool later (a hosted search tool, for example), send the full tool list for that request. Keep the prompt changes in the conversation.
-
-## Send assistant blocks in order
+import { createModel } from '@tanstack/ai'
+import type { OpenAITextProviderOptions } from '@tanstack/ai-openai'
-Your provider wants thinking, text, and tool calls in the order the model sent them. A `ModelMessage` keeps them in three separate fields: `thinking`, `content`, and `toolCalls`. Use `orderedAssistantBlocks` to get them back in order:
+// Type brand for the provider options. The value is not used at runtime.
+const modelOptions: OpenAITextProviderOptions = {}
-```typescript
-import { orderedAssistantBlocks } from '@tanstack/ai'
-import type { ModelMessage } from '@tanstack/ai'
-
-type WireBlock =
- | { type: 'reasoning'; text: string; signature?: string }
- | { type: 'text'; text: string }
- | { type: 'call'; id: string; name: string; args: string }
-
-export function orderedWireBlocks(message: ModelMessage): Array | undefined {
- const blocks = orderedAssistantBlocks(message)
- if (!blocks) return undefined
- return blocks.map((block): WireBlock => {
- if (block.type === 'thinking') {
- return { type: 'reasoning', text: block.thinking.content, signature: block.thinking.signature }
- }
- if (block.type === 'text') return { type: 'text', text: block.text }
- return {
- type: 'call',
- id: block.toolCall.id,
- name: block.toolCall.function.name,
- args: block.toolCall.function.arguments,
- }
- })
-}
+const reasoner = createModel('my-reasoner', {
+ input: ['text'],
+ features: ['reasoning', 'structured_outputs'],
+ tools: ['web_search'],
+ modelOptions,
+})
```
-- The order comes from `message.blockOrder`. The library writes it only when the order is not the default: all thinking, then the text, then the tool calls.
-- `orderedAssistantBlocks` returns `undefined` when the message has no map, or when the map does not match the message. Then send your default order.
+- `input`: the input types, the same as in the positional form. If you do not set it, the value is `['text']`.
+- `features`: the feature flags of the model, for example `'reasoning'` or `'structured_outputs'`.
+- `tools`: the provider tools of the model, for example `'web_search'`.
+- `modelOptions`: a type brand for the provider options of the model. The value is not used at runtime. Declare an empty object with the type of the provider options.
-## Example: OpenAI-Compatible Proxy
+### Type errors for unknown names
-A common use case is typing models for an OpenAI-compatible proxy:
+The new factory rejects a name that is not in either list:
-```typescript
-import { extendAdapter, createModel } from '@tanstack/ai'
+```typescript ignore
+import { createModel, extendAdapter } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
-// Models available through your proxy
-const proxyModels = [
- createModel(
- 'llama-3.1-70b',
- ['text']
- ),
- createModel(
- 'mixtral-8x7b',
- ['text']
- ),
-] as const
-
-const proxyAdapter = extendAdapter(openaiText, proxyModels)
-
-// Use with your proxy's base URL
-const adapter = proxyAdapter('llama-3.1-70b', {
- baseURL: 'https://my-llm-proxy.com/v1'
-})
-```
+const myOpenai = extendAdapter(openaiText, [createModel('custom-model', ['text'])])
-## Example: Fine-tuned Models
+// OK: a built-in model, with its built-in types
+const a1 = myOpenai('gpt-6.1-sol')
-Adding type safety for your fine-tuned models:
+// OK: your model, with the types that you declared
+const a2 = myOpenai('custom-model')
-```typescript
-import { chat, createModel, extendAdapter } from '@tanstack/ai'
-import { anthropicText } from '@tanstack/ai-anthropic'
+// Type error: the name is not known.
+// The check works when you assign the result to a variable.
+const invalid = myOpenai('nonexistent-model')
+```
-const fineTunedModels = [
- createModel(
- 'ft:claude-3-opus:my-org:custom-task:abc123',
- ['text', 'image']
- ),
-] as const
+### Write your own adapter
-const myAnthropic = extendAdapter(anthropicText, fineTunedModels)
+Do you build a full adapter for a new provider? [Extend Adapter: Advanced](./extend-adapter-advanced) shows how to:
-chat({
- adapter: myAnthropic('ft:claude-3-opus:my-org:custom-task:abc123'),
- messages: [{ role: 'user', content: 'Analyze this...' }]
-})
-```
+- Set `inputModalities`.
+- Add reasoning levels.
+- Send the prompt cache.
+- Send mid-conversation changes.
+- Send assistant blocks in order.
diff --git a/docs/advanced/files-api.md b/docs/advanced/files-api.md
index 45a051f8cf..332f0aacda 100644
--- a/docs/advanced/files-api.md
+++ b/docs/advanced/files-api.md
@@ -1,7 +1,7 @@
---
title: Files API
id: files-api
-description: "Upload media once and reference it by a provider-issued handle with TanStack AI's tree-shakeable files adapters (OpenAI, Anthropic, Gemini, Grok, fal)."
+description: "Upload a PDF, image, or other file to the provider one time, then put a small handle in each message. Works with OpenAI, Anthropic, Gemini, Grok, and fal."
keywords:
- tanstack ai
- files api
@@ -12,212 +12,277 @@ keywords:
- multimodal
---
-Provider **Files / storage APIs** let you upload a media asset once and reference it later by a lightweight handle, instead of re-sending base64 (or relying on the provider to re-fetch a public URL) on every request. That means large or reused inputs are uploaded a single time — lower latency and bandwidth, no re-buffering of base64 on memory-constrained runtimes (e.g. Cloudflare Workers) — plus access to provider-side file lifecycle (TTL, deletion).
+The files API uploads a file once and sends the file id on later turns. Each request then skips a fresh base64 upload of the same PDF.
-TanStack AI exposes this as a tree-shakeable **`files` adapter** per provider, paired with a `{ type: 'file' }` [content source](./multimodal-content.md#file-handle-files-api) you drop into a message.
+## Quick setup
-## Files adapters
+This setup uploads a PDF to Anthropic and asks Claude about it. It takes about 10 minutes. Set `ANTHROPIC_API_KEY` on the server first.
-Each provider with a native surface has a factory: `openaiFiles()`, `anthropicFiles()`, `geminiFiles()`, `grokFiles()`, and `falFiles()`. They read the same credentials from the environment as the provider's other adapters. To pass a key explicitly, use the `create*Files(apiKey)` variants (`createOpenaiFiles`, `createAnthropicFiles`, `createGeminiFiles`, `createGrokFiles`). `falFiles(config)` takes its key in the config object. For a Bearer token, use `createAnthropicFiles(token, { auth: 'bearer' })`, see [Bearer and OAuth tokens](../adapters/anthropic#bearer-and-oauth-tokens).
+### 1. Add an upload route
+
+The upload needs the provider key, so it runs on the server. `uploadFile` takes a `Blob` and returns a `FileHandle`:
+
+```typescript
+// api/upload.ts
+import { uploadFile } from "@tanstack/ai";
+import { anthropicFiles } from "@tanstack/ai-anthropic";
+
+export async function POST(request: Request) {
+ const form = await request.formData();
+ const [file] = form.getAll("file");
+ if (!(file instanceof Blob)) {
+ return new Response("Missing file", { status: 400 });
+ }
+
+ const handle = await uploadFile({ adapter: anthropicFiles(), input: file });
+ return Response.json(handle);
+}
+```
+
+### 2. Keep the handle in the browser and send it
+
+Store the handle that the upload route returns. Then put it in the message with `fileSourceFromHandle`. Import it from `@tanstack/ai/client`, because that entry does not load server code:
+
+```tsx
+import { useState } from "react";
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
+import { fileSourceFromHandle } from "@tanstack/ai/client";
+import type { FileHandle } from "@tanstack/ai/client";
+
+export function AskAboutPdf() {
+ const [handle, setHandle] = useState(null);
+ const { sendMessage } = useChat({
+ connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
+ });
+
+ async function upload(file: File) {
+ const body = new FormData();
+ body.append("file", file);
+ const response = await fetch("/api/upload", { method: "POST", body });
+ const uploaded: FileHandle = await response.json();
+ setHandle(uploaded);
+ }
+
+ return (
+ <>
+ {
+ const file = event.target.files?.[0];
+ if (file) void upload(file);
+ }}
+ />
+ {
+ if (!handle) return;
+ void sendMessage({
+ content: [
+ { type: "text", content: "Summarize this PDF" },
+ { type: "document", source: fileSourceFromHandle(handle) },
+ ],
+ });
+ }}
+ >
+ Ask
+
+ >
+ );
+}
+```
+
+### 3. Use the same provider in the chat route
+
+The file part arrives in the messages. Use a text adapter from the provider that issued the handle:
```typescript
-import { createOpenaiFiles, openaiFiles } from '@tanstack/ai-openai'
-import { geminiFiles } from '@tanstack/ai-gemini'
-import { anthropicFiles } from '@tanstack/ai-anthropic'
-import { falFiles } from '@tanstack/ai-fal'
-import { grokFiles } from '@tanstack/ai-grok'
-
-const files = openaiFiles() // reads OPENAI_API_KEY
-const filesWithKey = createOpenaiFiles('sk-your-key') // explicit key
+// api/chat.ts
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
+import { anthropicText } from "@tanstack/ai-anthropic";
+
+export async function POST(request: Request) {
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
+
+ const stream = chat({
+ adapter: anthropicText("claude-sonnet-5-5"),
+ messages,
+ threadId,
+ runId,
+ });
+
+ return toServerSentEventsResponse(stream);
+}
+```
+
+Pick a PDF, click **Ask**, and Claude answers about it. Ask again with the same handle, and no bytes go up a second time.
+
+## How it works
+
+1. `uploadFile` sends the bytes to the provider and returns a `FileHandle`.
+2. `fileSourceFromHandle(handle)` turns the handle into a `{ type: 'file' }` [content source](./multimodal-content#file-handle-files-api).
+3. The adapter maps that source to the field of its provider, for example `file_id` on Anthropic.
+4. A handle works only with the provider that issued it. If you send it to another provider, the adapter throws before any request goes out.
+
+## Advanced
+
+### Files adapters
+
+Each provider with a Files API has a factory:
+
+| Provider | Factory | Key from |
+| --- | --- | --- |
+| OpenAI | `openaiFiles()` or `createOpenaiFiles(apiKey)` | `OPENAI_API_KEY`, or the argument |
+| Anthropic | `anthropicFiles()` or `createAnthropicFiles(apiKey)` | `ANTHROPIC_API_KEY`, or the argument |
+| Gemini | `geminiFiles()` or `createGeminiFiles(apiKey)` | the same env as the Gemini adapters, or the argument |
+| Grok (xAI) | `grokFiles()` or `createGrokFiles(apiKey)` | the same env as the Grok adapters, or the argument |
+| fal | `falFiles(config)` | the config object |
+
+For a Bearer token, use `createAnthropicFiles(token, { auth: 'bearer' })`. See [Bearer and OAuth tokens](../adapters/anthropic-advanced#bearer-and-oauth-tokens).
+
+```typescript
+import { createOpenaiFiles, openaiFiles } from "@tanstack/ai-openai";
+
+const files = openaiFiles(); // reads OPENAI_API_KEY
+const filesWithKey = createOpenaiFiles("sk-your-key");
```
-### uploadFile
+### Upload input
-Drive an adapter with the `uploadFile()` activity function. It accepts a `Blob` (memory-efficient — preferred for large assets) or `{ data, mimeType }` where `data` is base64, and returns a `FileHandle`:
+`uploadFile` accepts one of these:
+
+- A `Blob`. This uses less memory, so it is better for large files.
+- `{ data, mimeType, filename? }`, where `data` is base64.
```typescript
-import { uploadFile } from '@tanstack/ai'
-import { openaiFiles } from '@tanstack/ai-openai'
-import { pdfBase64 } from './pdf-data'
+import { uploadFile } from "@tanstack/ai";
+import { openaiFiles } from "@tanstack/ai-openai";
+import { pdfBase64 } from "./pdf-data";
const handle = await uploadFile({
adapter: openaiFiles(),
- input: { data: pdfBase64, mimeType: 'application/pdf' },
-})
+ input: { data: pdfBase64, mimeType: "application/pdf" },
+});
// handle: { id, provider, uri?, mimeType?, sizeBytes?, expiresAt?, filename? }
```
-- `id` — the provider handle used for `get` / `delete` (OpenAI/Anthropic `file_id`, Gemini file resource name, fal storage URL).
-- `uri`: the handle's URL form when the provider exposes one (Gemini file URI, fal storage URL, Grok public URL). It is `undefined` for OpenAI and Anthropic, whose handles are opaque ids.
-- `expiresAt` — epoch milliseconds, when the provider schedules the handle to expire.
+The fields of the handle:
-> **Runtime note (Gemini upload).** `geminiFiles().upload()` uses `@google/genai`'s
-> resumable upload, which sets an explicit `Content-Length` header on a `Blob`-body
-> request. Some server runtimes reject that with `fetch failed` /
-> `InvalidArgumentError: invalid content-length header`. On **TanStack Start / Nitro**
-> this fails on older Nitro (observed on `nitro@3.0.1-alpha.2`) and works on current
-> Nitro (verified on `nitro@3.0.260610-beta`) — upgrade Nitro if you hit it. Native
-> Node (and the production `node-server` build) are unaffected. OpenAI, Anthropic, and
-> fal uploads use different transports and don't exercise this path.
+- `id`: the provider handle for `get` and `delete`. This is the OpenAI or Anthropic `file_id`, the Gemini file resource name, or the fal storage URL.
+- `uri`: the URL form of the handle, when the provider has one (Gemini file URI, fal storage URL, Grok public URL). It is `undefined` for OpenAI and Anthropic, because their handles are opaque ids.
+- `expiresAt`: epoch milliseconds, when the provider plans to expire the handle.
### getFile and deleteFile
-Providers with a lifecycle API support `getFile()` and `deleteFile()`. Both accept the handle itself (preferred — the handle's provider type rejects a foreign provider's handle at compile time) or its raw `id`:
+Providers with a lifecycle API support `getFile()` and `deleteFile()`. Both take the handle or its raw `id`. If you can, pass the handle. Its provider type rejects a handle from another provider at compile time.
```typescript
-import { deleteFile, getFile, uploadFile } from '@tanstack/ai'
-import { openaiFiles } from '@tanstack/ai-openai'
-import { pdfBase64 } from './pdf-data'
+import { deleteFile, getFile, uploadFile } from "@tanstack/ai";
+import { openaiFiles } from "@tanstack/ai-openai";
+import { pdfBase64 } from "./pdf-data";
-const files = openaiFiles()
+const files = openaiFiles();
const handle = await uploadFile({
adapter: files,
- input: { data: pdfBase64, mimeType: 'application/pdf' },
-})
+ input: { data: pdfBase64, mimeType: "application/pdf" },
+});
-const meta = await getFile({ adapter: files, id: handle })
-await deleteFile({ adapter: files, id: handle })
+const meta = await getFile({ adapter: files, id: handle });
+await deleteFile({ adapter: files, id: handle });
```
-> fal storage is **upload-only** — `falFiles()` defines no `get` / `delete`, and calling `getFile()` / `deleteFile()` with it throws a clear error.
+fal storage is upload-only. `falFiles()` has no `get` or `delete`, so `getFile()` and `deleteFile()` throw with it.
### Grok (xAI): handles are public URLs
-`grokFiles()` uploads to the xAI Files API, then mints a [public URL](https://docs.x.ai/developers/files/public-urls) for the stored object and uses that URL as the handle's reference.
+`grokFiles()` uploads to the xAI Files API. Then it mints a [public URL](https://docs.x.ai/developers/files/public-urls) for the file and uses that URL as the reference of the handle.
-xAI takes a `file_id` only on `input_file` (documents), and only on agentic-capable models. Its image path takes a URL. A public URL works for both, so one handle covers every modality on every chat model.
+xAI takes a `file_id` only on `input_file` (documents), and only on agentic models. Its image path takes a URL. A public URL works for both, so one handle works for every input type on every chat model.
```typescript
-import { uploadFile } from '@tanstack/ai'
-import { grokFiles } from '@tanstack/ai-grok'
-import { pngBase64 } from './image-data'
+import { uploadFile } from "@tanstack/ai";
+import { grokFiles } from "@tanstack/ai-grok";
+import { pngBase64 } from "./image-data";
const handle = await uploadFile({
// Omit `expiresAfter` for a URL that does not expire.
adapter: grokFiles({ expiresAfter: 86_400 }),
- input: { data: pngBase64, mimeType: 'image/png' },
-})
-// handle.id -> 'file_abc123' (lifecycle)
-// handle.uri -> 'https://files-cdn.x.ai/…' (wire reference)
+ input: { data: pngBase64, mimeType: "image/png" },
+});
+// handle.id -> 'file_abc123' (lifecycle)
+// handle.uri -> 'https://files-cdn.x.ai/...' (sent in the request)
```
-xAI limits to know:
+xAI limits:
-- 50 MiB per file, and PNG, JPEG, MP4, or PDF only.
-- `expiresAfter` runs from 3600 seconds (one hour) to 2592000 (thirty days).
-- Up to 1000 active public URLs per team.
-- Minting is idempotent, so re-uploading the same file returns the same URL.
+- 50 MiB for each file. PNG, JPEG, MP4, or PDF only.
+- `expiresAfter` is from 3600 seconds (one hour) to 2592000 (thirty days).
+- Up to 1000 active public URLs for each team.
+- Minting is idempotent. If you upload the same file again, you get the same URL.
-To stop a URL resolving without deleting the file, call `revokePublicUrl(handle.id)` on the adapter. `deleteFile()` removes the file itself.
+To stop a URL without deleting the file, call `revokePublicUrl(handle.id)` on the adapter. `deleteFile()` deletes the file itself.
`getFile()` mints the public URL to put it on the handle. After you revoke a URL, do not call `getFile()` for that file, or the file gets a public URL again.
-## Referencing a handle in a message
+### The file source
-Use `fileSourceFromHandle(handle)` to turn a `FileHandle` into a `{ type: 'file' }` content source:
+`fileSourceFromHandle(handle)` returns this shape. You can also write it yourself:
```typescript
-import type { ContentPartFileSource } from '@tanstack/ai'
+import type { ContentPartFileSource } from "@tanstack/ai";
const source: ContentPartFileSource = {
- type: 'file',
- value: 'file-abc',
- provider: 'openai',
-}
+ type: "file",
+ value: "file-abc",
+ provider: "openai",
+};
```
-- `value` is the handle exactly as the provider issued it. Treat it as opaque.
+- `value` is the handle exactly as the provider issued it. Treat it as opaque. `fileSourceFromHandle` uses `uri` when the handle has one, and `id` if not.
- `provider` names the adapter that issued it.
-Each adapter maps `value` to its native wire field: OpenAI and Anthropic `file_id`, Gemini `fileData.fileUri`, fal storage URL, Grok public URL.
-
-A handle only works with the provider that issued it. If `provider` names a different adapter, that adapter throws a clear error. Adapters that cannot consume file handles at all are rejected before any mapping starts.
-
-### Server: upload + reference
+You can also upload and send in one server function:
```typescript
-import { chat, fileSourceFromHandle, uploadFile } from '@tanstack/ai'
-import { anthropicFiles, anthropicText } from '@tanstack/ai-anthropic'
-
-export async function askAboutPdf(pdfBase64: string, request: string) {
- // Upload once; reuse the handle across turns.
+import { chat, fileSourceFromHandle, uploadFile } from "@tanstack/ai";
+import { anthropicFiles, anthropicText } from "@tanstack/ai-anthropic";
+
+export async function askAboutPdf(
+ pdfBase64: string,
+ request: string,
+ threadId: string,
+) {
+ // Upload one time. Use the handle again on later turns.
const handle = await uploadFile({
adapter: anthropicFiles(),
- input: { data: pdfBase64, mimeType: 'application/pdf' },
- })
+ input: { data: pdfBase64, mimeType: "application/pdf" },
+ });
return chat({
- adapter: anthropicText('claude-sonnet-5'),
+ adapter: anthropicText("claude-sonnet-5-5"),
messages: [
{
- role: 'user',
+ role: "user",
content: [
- { type: 'text', content: request },
- { type: 'document', source: fileSourceFromHandle(handle) },
+ { type: "text", content: request },
+ { type: "document", source: fileSourceFromHandle(handle) },
],
},
],
- })
-}
-```
-
-### Client: reuse a handle across requests
-
-Upload needs the provider key, so it happens on the server. The browser keeps the handle it gets back and puts it in the message on each turn.
-
-1. Store the handle the upload endpoint returned. Keep `{ id, provider, uri, mimeType }`.
-2. Build the source with `fileSourceFromHandle(handle)` and put it in the message content.
-3. On the server, pass the messages to `chat()` as usual.
-
-In the browser, import `fileSourceFromHandle` from `@tanstack/ai/client`. That entry does not load server code:
-
-```tsx
-import { useChat } from '@tanstack/ai-react'
-import { fetchServerSentEvents } from '@tanstack/ai-client'
-import { fileSourceFromHandle } from '@tanstack/ai/client'
-import type { FileHandle } from '@tanstack/ai/client'
-
-// `handle` came from your upload endpoint and is stored client-side.
-function AskAboutFile({ handle }: { handle: FileHandle }) {
- const { sendMessage } = useChat({
- connection: fetchServerSentEvents('/api/chat'),
- })
-
- return (
-
- sendMessage({
- content: [
- { type: 'text', content: 'Describe this' },
- { type: 'image', source: fileSourceFromHandle(handle) },
- ],
- })
- }
- type="button"
- >
- Ask
-
- )
-}
-```
-
-On the server, the file part arrives in the messages. Use a text adapter from the provider that issued the handle:
-
-```typescript
-import { chat } from '@tanstack/ai'
-import { openaiText } from '@tanstack/ai-openai'
-import type { ModelMessage } from '@tanstack/ai'
-
-export function runTurn(messages: Array) {
- return chat({ adapter: openaiText('gpt-5.6'), messages })
+ threadId,
+ });
}
```
-> A handle from another provider throws before any request. For example, an OpenAI handle sent to a Gemini adapter fails in preflight. It is never dropped and never sent as a URL.
+### Provider support
-## Provider support
-
-| Provider | Adapter | Handle referenced as | Lifecycle |
+| Provider | Adapter | Handle sent as | Lifecycle |
| --- | --- | --- | --- |
| OpenAI | `openaiFiles()` | Responses `input_image` / `input_file` `file_id` | `get`, `delete` |
| Anthropic | `anthropicFiles()` | `file_id` message source (sends the `files-api-2025-04-14` beta) | `get`, `delete` |
@@ -225,10 +290,27 @@ export function runTurn(messages: Array) {
| fal | `falFiles()` | storage URL (used like any URL) | upload-only |
| Grok (xAI) | `grokFiles()` | public URL (used like any URL) | `get`, `delete` |
-Gemini and fal handles are URLs, so they also round-trip through a plain `{ type: 'url' }` source; OpenAI and Anthropic handles are opaque ids that require the `{ type: 'file' }` source.
+Gemini and fal handles are URLs, so they also work in a plain `{ type: 'url' }` source. OpenAI and Anthropic handles are opaque ids, so they need the `{ type: 'file' }` source.
+
+### Providers and endpoints that cannot use a handle
+
+Adapters that can use file handles declare a `supportsFileSources` capability. For all other adapters, `chat()`, `generateImage()`, `generateVideo()`, and `embed()` reject a `{ type: 'file' }` source before they build a request. This applies to:
+
+- Groq, Bedrock, Mistral, OpenRouter, Ollama, BytePlus, and Cohere.
+- Any adapter that does not declare `supportsFileSources`.
+
+So a handle never goes out by mistake as a URL or as data.
+
+Some endpoints of supported providers also need the bytes, and throw an error for that endpoint:
+
+- OpenAI `images/edits` and the Sora `input_reference` need the bytes.
+- Gemini Veo needs the bytes or a `gs://` URI.
+- The image path of OpenAI **Chat Completions** takes only a URL or a data URI. Use the Responses adapter (`openaiText`) for `file_id` images.
-### Providers and endpoints that can't consume references
+### Gemini upload on some server runtimes
-Adapters that can consume file references declare a `supportsFileSources` capability; for everyone else (Groq, Bedrock, Mistral, OpenRouter, Ollama, BytePlus, Cohere, and any adapter written before this feature existed) `chat()` / `generateImage()` / `generateVideo()` / `embed()` reject `{ type: 'file' }` sources **before any request is built**, so a reference can never be silently mis-mapped onto a URL or data field.
+`geminiFiles().upload()` uses the resumable upload of `@google/genai`. It sets a `Content-Length` header on a request with a `Blob` body. Some server runtimes reject that header with `fetch failed` or `InvalidArgumentError: invalid content-length header`.
-Some endpoints on supporting providers also have no "reference an uploaded handle" option — OpenAI's `images/edits` and Sora `input_reference`, and Gemini's Veo, need the actual bytes (or, for Veo, a `gs://` URI). The OpenAI **Chat Completions** image path also references images only by URL/data URI, not `file_id` — use the Responses adapter (`openaiText`) for `file_id` images. These throw a clear endpoint-specific error.
+- On TanStack Start or Nitro, older Nitro fails (seen on `nitro@3.0.1-alpha.2`). Current Nitro works (tested on `nitro@3.0.260610-beta`). If you get this error, upgrade Nitro.
+- Native Node and the production `node-server` build work.
+- OpenAI, Anthropic, and fal uploads use a different transport and do not hit this error.
diff --git a/docs/advanced/locks.md b/docs/advanced/locks.md
index 6b796c0ed0..57e9a3bcbe 100644
--- a/docs/advanced/locks.md
+++ b/docs/advanced/locks.md
@@ -2,7 +2,7 @@
title: Locks
id: locks
order: 3
-description: "Cross-instance mutual exclusion with LockStore and withLocks — coordination middleware for multi-worker critical sections (e.g. sandbox ensure)."
+description: "Let only one run at a time enter a critical section for a key, across workers. Use LockStore and withLocks, for example around sandbox create."
keywords:
- tanstack ai
- locks
@@ -16,60 +16,80 @@ keywords:
- coordination
---
-Locks answer a different question from persistence:
+A lock makes one worker create the sandbox for a thread. A second run for that thread waits, then uses the same sandbox.
-| Concern | Question | Seam |
-| --- | --- | --- |
-| **State** | What is durable? | Stores + `withPersistence` |
-| **Locks** | Who may run this critical section right now? | `LockStore` + `withLocks` |
+Only one caller at a time runs the critical section for a key.
-They live in **`@tanstack/ai`** as a middleware capability — not in
-`@tanstack/ai-persistence`, and never as a key on `AIPersistence.stores`.
+## Quick setup
-## When you need them
+### 1. Provide a lock store
-Use locks when **more than one process or isolate** might enter the same critical
-section for the same key:
+Add `withLocks` to the `middleware` array. Put it before the middleware that uses the lock:
-- **Sandbox resume-or-create** (`withSandbox` / `ensure`) — two concurrent runs
- for the same thread must not both create a provider sandbox. See
- [Sandbox Instance Durability](../sandbox/durability).
-- **Your own middleware** — any multi-writer work you want to serialize across
- workers (e.g. a custom “one active job per thread” gate).
-
-You do **not** need locks for:
+```ts
+// api/chat.ts
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from '@tanstack/ai'
+import { withLocks, InMemoryLockStore } from '@tanstack/ai/locks'
+import { openaiText } from '@tanstack/ai-openai'
+import { onePrepPerThread } from './one-prep-per-thread'
+
+// Create the store once, so all requests share it.
+// For more than one process, pass a distributed LockStore.
+const locks = new InMemoryLockStore()
+
+export async function POST(request: Request) {
+ const { messages, threadId, runId } = await chatParamsFromRequest(request)
+
+ const stream = chat({
+ adapter: openaiText('gpt-6.1-sol'),
+ messages,
+ threadId,
+ runId,
+ middleware: [withLocks(locks), onePrepPerThread],
+ })
+
+ return toServerSentEventsResponse(stream)
+}
+```
-- Single-process local dev (optional: `InMemoryLockStore` is fine).
-- Ordinary chat state durability — that is stores, not mutexes.
-- Automatically locking an entire `chat()` turn — `withLocks` only **provides**
- the capability; consumers call `withLock` when they need exclusion.
+### 2. Use the lock in your middleware
-## Wire it up
+Read the store with `getLocks(ctx)`. Then wrap the critical section in `withLock`:
```ts
-import { chat } from '@tanstack/ai'
-import { withLocks, InMemoryLockStore } from '@tanstack/ai/locks'
-import { grokBuildText } from '@tanstack/ai-grok-build'
-import type { ModelMessage } from '@tanstack/ai'
-
-const messages: Array = [{ role: 'user', content: 'hi' }]
-
-chat({
- adapter: grokBuildText('grok-build'),
- messages,
- middleware: [
- // Single process. Multi-instance: pass a distributed LockStore instead.
- withLocks(new InMemoryLockStore()),
- // later middleware can getLocks(ctx) / withSandbox will use the same token
- ],
+// one-prep-per-thread.ts
+import { defineChatMiddleware } from '@tanstack/ai'
+import { LocksCapability, getLocks } from '@tanstack/ai/locks'
+
+export const onePrepPerThread = defineChatMiddleware({
+ name: 'one-prep-per-thread',
+ requires: [LocksCapability],
+ async onStart(ctx) {
+ const locks = getLocks(ctx)
+ await locks.withLock(`thread:${ctx.threadId}`, async (signal) => {
+ // Only one run for this thread is in here at a time.
+ // Pass `signal` to cancellable work when you use a lease-backed store.
+ console.log(`start ${ctx.threadId}`)
+ await new Promise((resolve) => setTimeout(resolve, 1000))
+ console.log(`end ${ctx.threadId}, lost lock: ${signal.aborted}`)
+ })
+ },
})
```
-Capability identity is by **object reference**. `withLocks` provides the shared
-`LocksCapability` from core; any later middleware that reads that token (including
-`@tanstack/ai-sandbox`) sees the same store.
+When two runs for the same thread start at the same time, the server console shows `start`, `end`, `start`, `end`. The two critical sections never overlap.
+
+## How it works
-Typical order when composing with sandbox:
+- `withLocks(store)` is a middleware. It provides the `LocksCapability` in its `setup` hook. See [Capabilities](./middleware-advanced#capabilities).
+- `withLocks` does not lock a whole `chat()` turn. It only gives the store to later middleware. A consumer calls `withLock` when it needs the lock.
+- The capability is the same object everywhere. So each later middleware that reads `LocksCapability` sees the same store, including `@tanstack/ai-sandbox`.
+
+When you use a sandbox, put `withLocks` before `withSandbox`:
```ts
import { withLocks, InMemoryLockStore } from '@tanstack/ai/locks'
@@ -80,20 +100,34 @@ declare const sandbox: SandboxDefinition
const middleware = [
withLocks(new InMemoryLockStore()),
- withSandbox(sandbox), // after providers
+ withSandbox(sandbox), // after the providers
]
```
-## The contract
+## When you need a lock
+
+Use a lock when **more than one process or isolate** can enter the same critical section for the same key:
+
+- **Sandbox resume-or-create** (`withSandbox` / `ensure`). Two runs for the same thread must not both create a provider sandbox. See [Sandbox Instance Durability](../sandbox/durability).
+- **Your own middleware.** Any work with more than one writer that you want to run one at a time across workers. For example, "one active job per thread".
+
+You do not need a lock for:
+
+- Local development with one process. `InMemoryLockStore` is enough there.
+- Saving chat state. Stores and `withPersistence` do that. See [Persistence Controls](../persistence/controls).
+
+## Advanced
+
+### The contract
```ts
import type { LockStore } from '@tanstack/ai/locks'
declare const locks: LockStore
-// Mutual exclusion for a key; lease-backed impls abort `signal` on loss.
+// Mutual exclusion for a key. Lease-backed stores abort `signal` on loss.
await locks.withLock('thread:abc', async (signal) => {
- // critical section — pass `signal` to cancellable work when using leases
+ // Critical section. Pass `signal` to cancellable work when you use leases.
void signal
})
```
@@ -102,18 +136,23 @@ await locks.withLock('thread:abc', async (signal) => {
| --- | --- |
| `LockStore` | Interface: `withLock(key, fn)` |
| `withLocks(store)` | Chat middleware that provides `LocksCapability` |
-| `InMemoryLockStore` | Process-local implementation (promise chain per key) |
+| `InMemoryLockStore` | Implementation for one process (a promise chain per key) |
+| `defineLock(lock)` | Types your own `LockStore` inline |
| `getLocks` / `provideLocks` | Low-level capability accessors for custom middleware |
-`InMemoryLockStore` is correct **within one process only**. It serializes
-callers for the same key, does not poison the chain when a critical section
-throws, and never aborts its signal (ownership cannot be lost in-process).
+`InMemoryLockStore` is correct **in one process only**:
+
+- It runs callers for the same key one at a time.
+- When a critical section throws, the next caller still runs.
+- It never aborts its signal, because a process cannot lose its own lock.
+
+### Implement a lock
-## Implement a lock
+Wrap your own mutual-exclusion primitive with `defineLock`. It checks the object against the contract inline, so you get autocomplete and need no `: LockStore` annotation.
-Wrap your own mutual-exclusion primitive with `defineLock`. It types the object
-against the contract inline (autocomplete, no `: LockStore` annotation). Acquire
-the key, run `fn`, and release when `fn` settles (whether it resolves or throws):
+1. Acquire the key.
+2. Run `fn`.
+3. Release the key when `fn` settles, also when it throws.
```ts
import { defineLock } from '@tanstack/ai/locks'
@@ -133,61 +172,25 @@ export const locks = defineLock({
})
```
-Wire it as middleware with `withLocks(locks)`. The requirements a production
-store must meet are covered below.
+Wire it as middleware with `withLocks(locks)`. A production store must meet the rules in the next section.
-## Distributed locks and leases
+### Distributed locks and leases
-Multi-instance deployments need a **distributed** implementation (Durable Object,
-Redis, etc.). A good store:
+A deployment with more than one instance needs a **distributed** store, for example a Durable Object or Redis. A good store:
-1. Serializes owners per `key`.
-2. Uses **leases** (or equivalent) so a crashed owner cannot block forever.
-3. Passes an `AbortSignal` into `fn`; when the lease is lost, **abort** so the
- callback stops starting externally visible work and passes the signal to
- cancellable dependencies.
+1. Lets one owner at a time hold each `key`.
+2. Uses **leases** (or something like them), so an owner that crashes cannot block the key forever.
+3. Passes an `AbortSignal` into `fn`. When the lease is lost, it **aborts** the signal. The callback then stops starting work that others can see, and passes the signal to cancellable dependencies.
-Callbacks that ignore `signal` still type-check (`() => Promise` is
-assignable), but lease-backed backends cannot protect you if the critical
-section keeps mutating after abort.
+A callback that ignores `signal` still type-checks, because `() => Promise` is assignable. But a lease-backed store cannot protect you if the critical section keeps changing data after the abort.
-There is no shared lock conformance suite in the chat store testkit — write
-targeted tests for concurrency, release-on-throw, and lease expiry for your
-backend. The Cloudflare Durable Object recipe lives in the
-`ai-persistence/build-cloudflare-adapter` agent skill (app-owned file,
-not a shipped package).
+To test your store:
-On Redis, `upstashLocks()` from `@upstash/agentkit-tanstack-ai` is a published
-third-party `LockStore` backed by Upstash Redis. It renews its lease while `fn`
-runs and aborts `signal` if renewal fails. See
-[Upstash](../community-adapters/upstash#locks).
-
-## Consume in custom middleware
-
-```ts
-import { defineChatMiddleware } from '@tanstack/ai'
-import { LocksCapability, getLocks } from '@tanstack/ai/locks'
-
-const serializePerThread = defineChatMiddleware({
- name: 'serialize-per-thread',
- requires: [LocksCapability],
- async onStart(ctx) {
- const locks = getLocks(ctx)
- await locks.withLock(`thread:${ctx.threadId}`, async (signal) => {
- // critical section — honor `signal` under lease-backed locks
- void signal
- })
- },
-})
-```
+- The chat store testkit has no lock conformance suite. Write your own tests for concurrency, release on throw, and lease expiry.
+- The Cloudflare Durable Object recipe is in the `ai-persistence/build-cloudflare-adapter` agent skill. It is a file that your app owns, not a published package.
-Or provide without `withLocks` by calling `provideLocks` in your own
-`setup` hook if you already own a custom middleware.
+On Redis, `upstashLocks()` from `@upstash/agentkit-tanstack-ai` is a published third-party `LockStore` on Upstash Redis. It renews its lease while `fn` runs, and it aborts `signal` if a renewal fails. See [Upstash](../community-adapters/upstash#locks).
-## See also
+### Provide a store without `withLocks`
-- [Middleware](./middleware) — capability bus and lifecycle
-- [Sandboxes](../sandbox/overview) — sandbox middleware overview
-- [Sandbox Instance Durability](../sandbox/durability) — primary product consumer (`withSandbox` / `ensure`)
-- [Persistence Controls](../persistence/controls) — compose state stores from different systems
-- [Build Your Own Adapter](../persistence/build-your-own-adapter) — chat store contracts
+If you already own a custom middleware, call `provideLocks(ctx, store)` in its `setup` hook, and list `LocksCapability` in its `provides`. Later middleware then reads the store with `getLocks(ctx)`.
diff --git a/docs/advanced/mid-conversation-changes.md b/docs/advanced/mid-conversation-changes.md
index a96cfb9c59..2d18561422 100644
--- a/docs/advanced/mid-conversation-changes.md
+++ b/docs/advanced/mid-conversation-changes.md
@@ -12,11 +12,26 @@ keywords:
- tool_addition
---
-Your agent gets a new tool halfway through a chat. A middleware adds it, a [lazy tool](../tools/lazy-tool-discovery) is found, or the user turns on a feature. The tool list sits at the start of each request, so the new tool changes the start. The provider can no longer use its [prompt cache](./prompt-caching), and the next call costs more.
+Put a stable tool list at the start of each request so the provider can keep its prompt cache. Add a tool that appears mid-chat after that prefix.
-On some GPT and Claude models, TanStack AI sends an added tool inside the conversation when the first call already had a tool. The start stays the same, and the cache keeps working. You change no code.
+On some GPT and Claude models, TanStack AI sends the added tool inside the conversation. The start stays the same, and the cache keeps working. You change no code.
-## Add a tool during a run
+## Quick setup
+
+This setup takes about 5 minutes.
+
+### 1. Pick a model with a channel
+
+The channel exists on these models:
+
+| Adapter | Models |
+|---|---|
+| `openaiText` | `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6-astra`, `gpt-6-luna`, `gpt-6-sol` |
+| `anthropicText` | `claude-opus-4-8`, `claude-opus-5`, `claude-opus-5-5`, `claude-fable-5`, `claude-fable-5-1` |
+
+Every other model and adapter sends the full lists on each call.
+
+### 2. Add a tool during the run
This route starts with one tool. A middleware adds a second tool after the first model call:
@@ -65,7 +80,11 @@ export async function POST(request: Request) {
}
```
-What the second model call sends:
+The client does not change. Ask about the weather, and the second model call reads the start of the request from the cache.
+
+## How it works
+
+The second model call of the example sends this:
| Model | `tools` | The new tool |
|---|---|---|
@@ -73,16 +92,14 @@ What the second model call sends:
| `claude-opus-5-5` | `get_weather`, a placeholder tool, then `get_forecast` with `defer_loading: true` | A `system` message with a `tool_addition` block after the tool result |
| A model with no channel, such as `gpt-6.1-sol` | `get_weather` and `get_forecast` | Nothing extra |
-A system prompt that you add at the end of the list works the same way. It goes into the conversation as a `developer` message (GPT) or a `system` message (Claude).
+Some more rules:
-## Check your model
+- The channel works only when the first call already had a tool.
+- A system prompt that you add at the end of the list works the same way. It goes into the conversation as a `developer` message (GPT) or a `system` message (Claude).
-| Adapter | Models |
-|---|---|
-| `openaiText` | `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6-astra`, `gpt-6-luna`, `gpt-6-sol` |
-| `anthropicText` | `claude-opus-4-8`, `claude-opus-5`, `claude-opus-5-5`, `claude-fable-5`, `claude-fable-5-1` |
+## Advanced
-Every other model and adapter sends the full lists on each call.
+### Check your model
To check in code, read `midConversationChannels` on the adapter:
@@ -95,12 +112,12 @@ console.log(adapter.midConversationChannels); // { tools: true, systemPrompts: t
It is `undefined` when the model has no channel, or when the channels are off.
-## Turn it on or off
+### Turn it on or off
-The channels are on by default only when the adapter talks to the provider's own API. A gateway or a proxy can drop the extra fields, so the channels are off with:
+The channels are on by default only when the adapter talks to the API of the provider directly. A gateway or a proxy can drop the extra fields. So the channels are off with:
-- a custom `baseURL` or a custom `fetch`,
-- a proxy URL in `OPENAI_BASE_URL` or `ANTHROPIC_BASE_URL`,
+- a custom `baseURL` or a custom `fetch`.
+- a proxy URL in `OPENAI_BASE_URL` or `ANTHROPIC_BASE_URL`.
- an Anthropic adapter on your own client (`createAnthropicChatWithClient`, `anthropicVertexText`).
Set `midConversationChannels` in the adapter config to choose:
@@ -114,7 +131,7 @@ export const fullLists = openaiText("gpt-6-astra", {
midConversationChannels: false,
});
-// Use the channels behind a proxy that sends the request on as it is.
+// Use the channels behind a proxy that sends the request on with no change.
export const throughProxy = anthropicText("claude-opus-5-5", {
baseURL: "https://llm-proxy.example.com",
midConversationChannels: true,
@@ -123,17 +140,17 @@ export const throughProxy = anthropicText("claude-opus-5-5", {
On a model with no channel, `true` changes nothing.
-## Keep the record when you store messages
+### Keep the record when you store messages
Before each model call, `chat()` compares the tools and the system prompts with the earlier calls. It saves a small record on the first assistant message of the call, in the `midConversationChange` field. The record holds tool names and short prompt hashes, not tool definitions.
-The next turn reads these records. So:
+The next turn reads these records:
- If you save messages yourself, keep the `midConversationChange` field.
-- With [`withPersistence`](../persistence/chat-persistence), you do nothing. The store keeps the record, even when `useChat` sends the same message back without it.
-- With plain `useChat` and no store, the records last for one `chat()` call. Each new turn starts fresh, and the cache breaks once per turn.
+- With [`withPersistence`](../persistence/chat-persistence), you do nothing. The store keeps the record, also when `useChat` sends the same message back without it.
+- With plain `useChat` and no store, the records last for one `chat()` call. Each new turn starts with no record, and the cache breaks one time for each turn.
-## Limits
+### Limits
These changes still break the cache one time:
@@ -141,5 +158,3 @@ These changes still break the cache one time:
- A system prompt that you edit, move, or add anywhere but the end.
- A provider tool, such as `webSearchTool()`, in the request. That request sends the full `tools` list. Prompt changes still go into the conversation.
- The first tool, when the recorded start had no tools. That request sends the full tool list.
-
-You now keep the prompt cache when your agent picks up new tools or prompts mid-conversation.
diff --git a/docs/advanced/middleware-advanced.md b/docs/advanced/middleware-advanced.md
new file mode 100644
index 0000000000..ba06d451d7
--- /dev/null
+++ b/docs/advanced/middleware-advanced.md
@@ -0,0 +1,1351 @@
+---
+title: "Middleware: Advanced"
+id: middleware-advanced
+description: "Every chat() middleware hook in detail: the lifecycle, the context object, composition rules, capabilities, recipes, and the types."
+keywords:
+ - tanstack ai
+ - middleware
+ - lifecycle hooks
+ - capabilities
+ - onConfig
+ - onChunk
+ - onBeforeToolCall
+ - structured output
+---
+
+This page covers each middleware hook in detail. Set up your first middleware with [Middleware](./middleware).
+
+## Lifecycle
+
+Each `chat()` call goes through the same steps. The hooks fire at these steps:
+
+```mermaid
+graph TD
+ A["chat() called"] --> B["onConfig (phase: init)"]
+ B --> C[onStart]
+ C --> D["onConfig (phase: beforeModel)"]
+ D --> E["Adapter streams response"]
+ E --> F["onChunk (for each chunk)"]
+ F --> G{Tool calls?}
+ G -->|No| H[onUsage]
+ G -->|Yes| I[onBeforeToolCall]
+ I --> J[Tool executes]
+ J --> K[onAfterToolCall]
+ K --> L{Continue loop?}
+ L -->|Yes| D
+ L -->|No| H
+ H --> SO{"Structured output path?"}
+ SO -->|None| M{Outcome}
+ SO -->|"Native combined"| SOH["Post-loop structured-output harvest (onChunk)"]
+ SOH --> M
+ SO -->|"Separate finalization"| SOC[onStructuredOutputConfig]
+ SOC --> SOM["onConfig (phase: structuredOutput)"]
+ SOM --> SOS["Structured-output finalization (onChunk, onUsage)"]
+ SOS --> M
+ M -->|Success| N[onFinish]
+ M -->|Abort| O[onAbort]
+ M -->|Error| P[onError]
+
+ style I fill:#e1f5ff
+ style J fill:#ffe1e1
+ style SOC fill:#e1f5ff
+ style SOM fill:#e1f5ff
+ style SOS fill:#e1f5ff
+ style SOH fill:#e1f5ff
+ style N fill:#e1ffe1
+ style O fill:#fff4e1
+ style P fill:#ffe1e1
+```
+
+### Phase transitions
+
+The `phase` field of the context tells you where the run is:
+
+| Phase | When | Hooks called |
+| --- | --- | --- |
+| `init` | Once at startup | `onConfig` |
+| `beforeModel` | Before each model call (one time per iteration) | `onConfig` |
+| `modelStream` | While the adapter streams chunks | `onChunk`, `onUsage` |
+| `beforeTools` | Before the tools run | `onBeforeToolCall` |
+| `afterTools` | After the tools run | `onAfterToolCall` |
+| `structuredOutput` | During the separate structured-output call (see below) | `onStructuredOutputConfig`, `onConfig`, `onChunk`, `onUsage` |
+
+### The two structured-output paths
+
+When you call `chat()` with `outputSchema`, the adapter takes one of two paths.
+
+- **Separate finalization.** `supportsCombinedToolsAndSchema()` does not return `true` for the current model and options. After the agent loop, the engine makes one more call to the adapter. The chunks from `adapter.structuredOutputStream` (or the non-streaming fallback) go through `onChunk` with the `structuredOutput` phase. `onUsage` fires for the tokens of that call.
+- **Native combined.** The adapter sends tools and the schema in one streaming call. The `structuredOutput` phase does not fire. Middleware sees the run through `beforeModel` and `modelStream` as usual. These adapters do this (see issue #605):
+ - Modern OpenAI Chat Completions and OpenAI Responses.
+ - Claude 4.5+, Gemini 3.x, and the Grok 4.x family.
+ - OpenRouter, when every routed model is in `OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS`.
+
+## Hooks reference
+
+### onConfig
+
+`onConfig` runs once in the `init` phase, and once per iteration in the `beforeModel` phase. Use it to change the config that the model gets.
+
+On the separate-finalization path, `onConfig` runs one more time at the structured-output step, with `ctx.phase === 'structuredOutput'`. It gets the config after `onStructuredOutputConfig`. So a separate-finalization run with one iteration calls `onConfig` three times (`init`, `beforeModel`, `structuredOutput`). Native-combined output does not add this third call.
+
+Return a **partial** config with only the fields you want to change. The engine merges it into the current config (a shallow merge). You do not have to spread the existing config.
+
+```typescript
+import { type ChatMiddleware } from "@tanstack/ai";
+
+const dynamicTemperature: ChatMiddleware = {
+ name: "dynamic-temperature",
+ onConfig: (ctx, config) => {
+ if (ctx.phase === "init") {
+ // Add a system prompt at startup. Only systemPrompts changes.
+ return {
+ systemPrompts: [
+ ...config.systemPrompts,
+ "You are a helpful assistant.",
+ ],
+ };
+ }
+
+ if (ctx.phase === "beforeModel" && ctx.iteration > 0) {
+ // Increase the temperature on later calls. Sampling params live in
+ // the provider-native modelOptions object. `temperature` has the same
+ // key on all providers. Spread modelOptions to keep the other options.
+ const current =
+ typeof config.modelOptions?.temperature === "number"
+ ? config.modelOptions.temperature
+ : 0.7;
+ return {
+ modelOptions: {
+ ...config.modelOptions,
+ temperature: Math.min(current + 0.1, 1.0),
+ },
+ };
+ }
+ },
+};
+```
+
+> [!NOTE]
+> Sampling parameters (`temperature`, `top_p` / `topP`, the `max*Tokens` keys) live in `modelOptions`, under the native name of each provider. `temperature` has the same name on all providers. A token limit does not. For example, OpenAI uses `max_output_tokens`, and Ollama uses `num_predict` inside `modelOptions.options`.
+
+Config fields you can change:
+
+| Field | Type | Description |
+| --- | --- | --- |
+| `messages` | `ModelMessage[]` | The canonical conversation history. Persistence and `ctx.messages` use this field. |
+| `providerMessages` | `ModelMessage[]` | Temporary context sent to the provider. Defaults to `messages`. |
+| `systemPrompts` | `SystemPrompt[]` | System prompts |
+| `tools` | `Tool[]` | The tools the model can call |
+| `metadata` | `Record` | Request metadata |
+| `modelOptions` | `Record` | Provider-native options, including the sampling params |
+| `reasoning` | `ReasoningRequest \| undefined` | How hard the model thinks at this call |
+| `toolChoice` | `ToolChoice \| undefined` | How the model uses the tools at the next model call. See [Change the tool choice of a call](./middleware#change-the-tool-choice-of-a-call). |
+| `wrapFetch` | `FetchWrapper \| undefined` | Wraps the HTTP fetch of the next model call. See [Change the HTTP requests of a call](./middleware#change-the-http-requests-of-a-call). |
+| `promptCache` | `ResolvedPromptCache \| undefined` | The prompt cache of the next model call: its `retention` and `key`. See [Change the prompt cache of a call](./middleware#change-the-prompt-cache-of-a-call). |
+
+When more than one middleware has `onConfig`, the config is **piped** through them in order. Each one gets the merged config from the one before it.
+
+If a change must affect only the model call, return `providerMessages`. Returning `messages` also changes the provider input, unless the same result sets `providerMessages`.
+
+### onStructuredOutputConfig
+
+This hook runs once, at the start of the separate structured-output call. It runs only when `chat()` has `outputSchema` **and** `supportsCombinedToolsAndSchema()` does not return `true` for the current model and options.
+
+It pipes through the middleware in order, like `onConfig`. It also gets the **JSON Schema** that goes to the provider. Use it to:
+
+- Change the schema. For example, add `$defs`, or remove keywords that a provider does not accept.
+- Change only the final call. For example, remove the system prompts from it.
+
+> [!NOTE]
+> Native-combined adapters (modern OpenAI, Claude 4.5+, Gemini 3.x, Grok 4.x, see issue #605) make no separate call, so this hook never runs. The engine sends the converted schema to `chatStream` after `onConfig`. Middleware cannot change the schema on that path.
+
+Return a **partial** `StructuredOutputMiddlewareConfig` with the fields you want to change. The engine merges it (a shallow merge). Return nothing to pass the config through.
+
+```typescript
+import { type ChatMiddleware } from "@tanstack/ai";
+import { sharedDefs } from "./defs";
+
+const injectDefs: ChatMiddleware = {
+ name: "inject-defs",
+ onStructuredOutputConfig: (_ctx, config) => {
+ // `config.outputSchema` is the JSON Schema that goes to the provider
+ return {
+ outputSchema: {
+ ...config.outputSchema,
+ $defs: { ...sharedDefs },
+ },
+ };
+ },
+};
+```
+
+Config fields you can change:
+
+| Field | Type | Description |
+| --- | --- | --- |
+| `messages` | `ModelMessage[]` | The canonical conversation history |
+| `providerMessages` | `ModelMessage[]` | Temporary context sent to the final call |
+| `systemPrompts` | `SystemPrompt[]` | System prompts on the final call |
+| `metadata` | `Record` | Request metadata |
+| `modelOptions` | `Record` | Provider-native options, including the sampling params |
+| `outputSchema` | `JSONSchema` | The JSON Schema that goes to the provider |
+
+The order at the structured-output step:
+
+1. `onStructuredOutputConfig` runs first, through each middleware in array order.
+2. `onConfig` runs again with `ctx.phase === 'structuredOutput'`. It gets the config after step 1, without `outputSchema`.
+
+Use `onConfig` for changes that apply to every adapter call. If you need the schema, use `onStructuredOutputConfig`.
+
+### onStart
+
+`onStart` runs once, after the first `onConfig`. Use it to start timers or write a log line.
+
+```typescript
+import { type ChatMiddleware } from "@tanstack/ai";
+
+const timer: ChatMiddleware = {
+ name: "timer",
+ onStart: (ctx) => {
+ console.log(`Request ${ctx.requestId} started at iteration ${ctx.iteration}`);
+ },
+};
+```
+
+### onIteration
+
+`onIteration` runs at the start of each agent-loop iteration, after the engine creates the new assistant message ID. It gets `{ iteration, messageId }`. Use it to see the iteration boundaries. It does not run for the separate structured-output call.
+
+### onChunk
+
+`onChunk` runs for each chunk from the adapter. It can read, change, split, or drop the chunk.
+
+```typescript ignore
+import { type ChatMiddleware } from "@tanstack/ai";
+
+const redactor: ChatMiddleware = {
+ name: "redactor",
+ onChunk: (ctx, chunk) => {
+ if (chunk.type === "TEXT_MESSAGE_CONTENT") {
+ // Redact numbers that look like an SSN
+ return {
+ ...chunk,
+ delta: chunk.delta.replace(/\b\d{3}-\d{2}-\d{4}\b/g, "[REDACTED]"),
+ };
+ }
+ // Return nothing to pass the chunk through
+ },
+};
+```
+
+Return values:
+
+| Return | Effect |
+| --- | --- |
+| `void` / `undefined` | The chunk passes through unchanged |
+| `StreamChunk` | Replaces the chunk |
+| `StreamChunk[]` | Splits it into more chunks |
+| `null` | Drops the chunk |
+
+When more than one middleware has `onChunk`, chunks flow through them in order. If one middleware drops a chunk, the middleware after it never see it.
+
+#### Chunk types you see
+
+`onChunk` gets every [AG-UI event](https://docs.ag-ui.com/introduction) of the run, not only text. Narrow on `chunk.type` (a discriminated union) before you read the fields of a type. The common types:
+
+| `chunk.type` | Meaning | Key fields |
+| --- | --- | --- |
+| `RUN_STARTED` / `RUN_FINISHED` / `RUN_ERROR` | Start and end of the run | `runId`, `finishReason`, `usage` (on finish), `message` (on error) |
+| `TEXT_MESSAGE_START` / `TEXT_MESSAGE_CONTENT` / `TEXT_MESSAGE_END` | Assistant text | `messageId`, `delta` (content) |
+| `TOOL_CALL_START` / `TOOL_CALL_ARGS` / `TOOL_CALL_END` | Tool calls | `toolCallId`, `toolCallName`, `delta` (args), result on end |
+| `REASONING_START` / `REASONING_MESSAGE_CONTENT` / `REASONING_END` | Thinking or reasoning | `messageId`, `delta`. The signature comes on `REASONING_ENCRYPTED_VALUE` |
+| `STATE_SNAPSHOT` / `STATE_DELTA` | Agent state sync | `snapshot`, `delta` |
+| `CUSTOM` | Extension events, including structured output (see below) | `name`, `value` |
+
+For the full list of events and their fields, see the [AG-UI protocol docs](https://docs.ag-ui.com/introduction).
+
+#### Change structured-output chunks
+
+Structured-output chunks go through the same `onChunk` hook as all other chunks. There is no separate chunk hook. When `chat()` has `outputSchema`, these chunks flow through `onChunk`:
+
+- The JSON `TEXT_MESSAGE_CONTENT` deltas.
+- The `structured-output.start` and `structured-output.complete` CUSTOM events.
+- Any `RUN_ERROR` from the final step.
+
+How you find them depends on the path of the adapter:
+
+- **Separate finalization:** `ctx.phase === 'structuredOutput'` during the final call. Check the phase.
+- **Native combined:** the model writes the JSON on its natural last turn, so **`ctx.phase` stays `'modelStream'`**. The `'structuredOutput'` phase never fires. Check the CUSTOM event name (`structured-output.start` / `structured-output.complete`).
+
+```typescript ignore
+import { type ChatMiddleware } from "@tanstack/ai";
+
+const redactStructuredOutput: ChatMiddleware = {
+ name: "redact-structured-output",
+ onChunk: (ctx, chunk) => {
+ // Separate-finalization path: the JSON streams as TEXT_MESSAGE_CONTENT
+ // in the 'structuredOutput' phase. Change the delta like any other text
+ // chunk. Here, redact numbers that look like an SSN.
+ if (
+ ctx.phase === "structuredOutput" &&
+ chunk.type === "TEXT_MESSAGE_CONTENT"
+ ) {
+ return {
+ ...chunk,
+ delta: chunk.delta.replace(/\b\d{3}-\d{2}-\d{4}\b/g, "[REDACTED]"),
+ };
+ }
+
+ // Both paths: the complete typed payload comes as a CUSTOM
+ // `structured-output.complete` event. On the native-combined path this
+ // is the only signal (ctx.phase never becomes 'structuredOutput'), so
+ // check the event name, not the phase. `chunk.value` has { object, raw }.
+ if (chunk.type === "CUSTOM" && chunk.name === "structured-output.complete") {
+ console.log("final structured output:", chunk.value);
+ }
+
+ // Return nothing to pass all other chunks through.
+ },
+};
+```
+
+The config hooks are different because the config at the structured-output step has an `outputSchema` field that `ChatMiddlewareConfig` does not have (see [onStructuredOutputConfig](#onstructuredoutputconfig)). Chunks are always `StreamChunk`, in every phase. So one `onChunk` plus `ctx.phase` (or the CUSTOM event name) covers all cases.
+
+### onShouldContinue
+
+`onShouldContinue` runs when the engine decides if it starts another agent-loop iteration (after a tool phase, or between model turns). It gets the same `AgentLoopState` as the loop strategies (`iterationCount`, `toolCallCount`, `lastTurnToolCallCount`, and more).
+
+- Return `false` to stop the loop.
+- Return `true`, `void`, or `undefined` to let it continue.
+- The results combine with AND, across all middleware and with `agentLoopStrategy`. One `false` stops the loop.
+
+This hook does **not** abort the run. The stream finishes normally with the current messages. Use `ctx.abort()` only for a hard stop.
+
+```typescript
+import { type ChatMiddleware } from "@tanstack/ai";
+
+const budget: ChatMiddleware = {
+ name: "tool-budget",
+ onShouldContinue: (_ctx, state) => {
+ // Stop more turns after the model makes 20 tool calls
+ if (state.toolCallCount >= 20) return false;
+ },
+};
+```
+
+For a full budget per turn and per run, see [Tool-call budgets](../chat/agentic-cycle#tool-call-budgets-middleware-recipe).
+
+### onModelError
+
+Some model calls fail for a reason that your middleware can fix. For example, the prompt is too long for the model. `onModelError` gets the `RUN_ERROR` before anyone else sees it. Fix the request and call `ctx.retry()`. While the call has retries left, the run goes on as if the call never failed.
+
+Say your app sends retrieved documents as a second system prompt. When the call is too long, this middleware drops them and tries again:
+
+```typescript
+import { CONTEXT_OVERFLOW_CODE } from "@tanstack/ai";
+import type { ChatMiddleware, ChatMiddlewareContext } from "@tanstack/ai";
+
+// The runs that go on without the documents.
+const withoutDocs = new WeakSet();
+
+const dropDocsWhenTooLong: ChatMiddleware = {
+ name: "drop-docs-when-too-long",
+ onModelError: (ctx, error) => {
+ if (error.code !== CONTEXT_OVERFLOW_CODE) return;
+ withoutDocs.add(ctx);
+ ctx.retry();
+ },
+ onConfig: (ctx, config) => {
+ if (ctx.phase !== "beforeModel" || !withoutDocs.has(ctx)) return;
+ // Keep the main system prompt. Drop the documents after it.
+ return { systemPrompts: config.systemPrompts.slice(0, 1) };
+ },
+};
+```
+
+What `ctx.retry()` does while the call has retries left:
+
+- It drops the failed call. `onChunk` and the caller never see its `RUN_STARTED` or `RUN_ERROR`.
+- It runs `onConfig` again (phase `beforeModel`), then calls the model again.
+- It works only inside `onModelError`. In any other hook it throws.
+
+When the hook runs:
+
+- Only when the adapter sends a `RUN_ERROR` before any output. A call that already sent text is not retried, so no output repeats.
+- Not for an exception that the adapter throws. That error goes to `onError`.
+- Not for the separate structured-output call after the agent loop. The model calls of the agent loop get the hook as usual.
+- Every time, also after the last retry. A model call retries at most 2 times. After that, the error goes to the caller.
+
+`withCompaction` uses this hook to [compact and retry after a context overflow](./compaction#when-the-chat-is-still-too-long).
+
+### onInterruptBoundary and onInterruptResolution
+
+Use these hooks when middleware needs data from the client. Define the request with `defineInterrupt()`. Register it with `chat({ interrupts })` and `useChat({ interrupts })`. Do not send raw AG-UI events from middleware.
+
+`onInterruptBoundary` runs at four points in an agent iteration:
+
+- `beforeModel`, before the adapter starts.
+- `afterModel`, after the model response is complete.
+- `beforeTools`, before the tools start.
+- `afterTools`, after the tool phase is complete.
+
+Each middleware can return requests from one boundary. The engine puts all requests from that boundary into one AG-UI interrupt batch. The batch ends the run with one interrupt outcome.
+
+This hook cannot change the config. It can return only `{ interrupts }` or nothing. The continuation is a new `chat()` call, so the hook runs again. If `ctx.parentRunId` is set and the pause belongs to the first request only, return nothing.
+
+For what `ctx` has at each phase, and when to use each phase, see [Lifecycle Boundaries](../interrupts/boundaries).
+
+Create one shared definition. The server and the client both import this value, so the definition ID and the response shape are the same on both sides.
+
+```typescript title="review-plan.ts"
+import { defineInterrupt, type ChatMiddleware } from '@tanstack/ai'
+import { z } from 'zod'
+
+export const reviewPlan = defineInterrupt({
+ id: 'review-plan',
+ payloadSchema: z.object({ title: z.string() }),
+ responseSchema: z.object({ approved: z.boolean() }),
+})
+
+export const reviewMiddleware: ChatMiddleware = {
+ name: 'review-plan',
+ onInterruptBoundary(ctx) {
+ if (ctx.phase !== 'beforeTools') return
+ if (ctx.parentRunId) return
+ return {
+ interrupts: [
+ reviewPlan.interrupt({
+ key: 'release-plan',
+ reason: 'review-required',
+ message: 'Approve this plan?',
+ payload: { title: 'Release plan' },
+ }),
+ ],
+ }
+ },
+ onInterruptResolution(_ctx, resumedInterrupts) {
+ for (const result of resumedInterrupts.for(reviewPlan)) {
+ if (result.status === 'resolved' && !result.response.approved) {
+ return { toolResume: 'stop' }
+ }
+ }
+ },
+}
+```
+
+Register the definition on the server. Forward `parentRunId` and `resume`, so that a client answer starts the continuation with its full context.
+
+```typescript title="route.ts"
+import {
+ chat,
+ chatParamsFromRequestBody,
+ toServerSentEventsResponse,
+} from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+import { reviewMiddleware, reviewPlan } from './review-plan'
+
+export async function POST(request: Request) {
+ const params = await chatParamsFromRequestBody(await request.json())
+ const stream = chat({
+ adapter: openaiText('gpt-6.1-sol'),
+ messages: params.messages,
+ threadId: params.threadId,
+ runId: params.runId,
+ ...(params.parentRunId ? { parentRunId: params.parentRunId } : {}),
+ ...(params.resume ? { resume: params.resume } : {}),
+ interrupts: [reviewPlan],
+ middleware: [reviewMiddleware],
+ })
+
+ return toServerSentEventsResponse(stream)
+}
+```
+
+Register the same definition on the client. Check `kind` and `definitionId`. TypeScript then treats the item as `GenericInterrupt`. `resolveInterrupt` uses the response shape from `reviewPlan.responseSchema`.
+
+```tsx title="review-plan-panel.tsx"
+import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
+import type { GenericInterrupt } from '@tanstack/ai-react'
+import { reviewPlan } from './review-plan'
+
+function ReviewCard({
+ interrupt,
+}: {
+ interrupt: GenericInterrupt
+}) {
+ return (
+ interrupt.resolveInterrupt({ approved: true })}
+ >
+ Approve plan
+
+ )
+}
+
+export function ReviewPlanPanel() {
+ const { interrupts, sendMessage } = useChat({
+ connection: fetchServerSentEvents('/api/chat'),
+ threadId: 'support-chat',
+ interrupts: [reviewPlan],
+ })
+
+ return (
+ <>
+ sendMessage('Review the release plan')}>
+ Start review
+
+ {interrupts.map((interrupt) => {
+ if (interrupt.kind !== 'generic') return null
+ if (!('definitionId' in interrupt)) return null
+ if (interrupt.definitionId !== reviewPlan.id) return null
+ return
+ })}
+ >
+ )
+}
+```
+
+`onInterruptResolution` does not run in the `chat()` call that paused. It runs once at the start of the next `chat()` call, after the client answers:
+
+```
+setup
+onConfig (phase is init)
+onInterruptResolution (phase is still init)
+onStart
+then stop, or continue the agent loop
+```
+
+`useChat` sends `parentRunId` and `resume` on that second request. Each generic resume item has the original request in `metadata`. If `resume` is present and `parentRunId` is missing, the server throws.
+
+To read the answers:
+
+- `resumedInterrupts.for(definition)` reads one typed definition.
+- `resumedInterrupts.all()` reads every registered definition.
+- `resumedInterrupts.all(definitionA, definitionB)` reads a typed subset.
+
+The hook can return `toolResume: 'continue'`, `'cancel'`, or `'stop'`. The results from all middleware combine by the most restrictive rule: `stop` wins over `cancel`, and `cancel` wins over `continue`.
+
+This hook cannot change prompts, tools, or messages. Store the answer on a capability. Then return those fields from `onConfig` when `ctx.phase === 'beforeModel'`.
+
+| Hook | Can change |
+| --- | --- |
+| `onInterruptBoundary` | Nothing. It can only pause. |
+| `onInterruptResolution` | Pending-tool policy (`toolResume`) |
+| `onConfig` | `messages`, `systemPrompts`, `tools`, `modelOptions`, `metadata`, `toolChoice` |
+
+The full resume order, and an example that writes a user note into the system prompt, is in [Apply Answers](../interrupts/apply-answers).
+
+### onBeforeToolCall
+
+`onBeforeToolCall` runs before a server tool runs, and before a client tool goes to the client. The first middleware that returns a decision skips the other middleware for that call.
+
+Each tool call goes through these steps:
+
+1. The engine parses the JSON arguments from the model.
+2. `onBeforeToolCall` gets the parsed input.
+3. The `inputSchema` of the tool checks the final input. The engine awaits an async validator. A schema transform applies once, here.
+4. The server tool runs, or the client gets the call, with the checked input.
+
+The schema checks the input after your hook:
+
+- `hookCtx.args` has no schema defaults or transforms yet, and it can be invalid. Check its shape before you read it.
+- A `transformArgs` value or an in-place edit must fit the schema. If it does not fit, the call gets a tool error, `onAfterToolCall` runs with `ok: false`, and the tool does not run.
+
+When the hook runs:
+
+- For a client tool, the hook runs before the server sends the call to the client. The hook does not run when the client result comes back.
+- A client tool gets `onAfterToolCall` only when the hook skips the call or the schema check fails. The result from the client does not run it. If your middleware pairs the two hooks and `hookCtx.tool?.execute` is missing, return early.
+- A call that waits for approval does not run the hook. A denied call does not run it either.
+- After approval, the hook gets the approved input, including any `editedArgs`. See [Tool Approval](../interrupts/tool-approval).
+
+```typescript
+import { type ChatMiddleware } from "@tanstack/ai";
+
+function isRecord(v: unknown): v is Record {
+ return typeof v === "object" && v !== null;
+}
+
+const guard: ChatMiddleware = {
+ name: "guard",
+ onBeforeToolCall: (ctx, hookCtx) => {
+ // Block dangerous tools
+ if (hookCtx.toolName === "deleteDatabase") {
+ return { type: "abort", reason: "Dangerous operation blocked" };
+ }
+
+ // Add a default argument
+ if (hookCtx.toolName === "search" && isRecord(hookCtx.args) && !hookCtx.args.limit) {
+ return {
+ type: "transformArgs",
+ args: { ...hookCtx.args, limit: 10 },
+ };
+ }
+ },
+};
+```
+
+Decisions:
+
+| Decision | Effect |
+| --- | --- |
+| `void` / `undefined` | Go on as usual. The next middleware can decide. |
+| `{ type: 'transformArgs', args }` | Replace the tool arguments before the tool runs |
+| `{ type: 'skip', result }` | Do not run the tool. Use `result` instead. |
+| `{ type: 'abort', reason? }` | Abort the whole chat run |
+
+The `hookCtx` fields:
+
+| Field | Type | Description |
+| --- | --- | --- |
+| `toolCall` | `ToolCall` | The raw tool call object |
+| `tool` | `Tool \| undefined` | The resolved tool definition |
+| `args` | `unknown` | The parsed arguments |
+| `toolName` | `string` | The tool name |
+| `toolCallId` | `string` | The tool call ID |
+
+### onAfterToolCall
+
+`onAfterToolCall` runs after each tool runs (or is skipped). Every middleware runs, in array order.
+
+```typescript
+import { type ChatMiddleware } from "@tanstack/ai";
+
+const toolLogger: ChatMiddleware = {
+ name: "tool-logger",
+ onAfterToolCall: (ctx, info) => {
+ if (info.ok) {
+ console.log(`${info.toolName} completed in ${info.duration}ms`);
+ } else {
+ console.error(`${info.toolName} failed:`, info.error);
+ }
+ },
+};
+```
+
+The `info` fields:
+
+| Field | Type | Description |
+| --- | --- | --- |
+| `toolCall` | `ToolCall` | The raw tool call object |
+| `tool` | `Tool \| undefined` | The resolved tool definition |
+| `toolName` | `string` | The tool name |
+| `toolCallId` | `string` | The tool call ID |
+| `ok` | `boolean` | `true` when the tool succeeded |
+| `duration` | `number` | Run time in milliseconds |
+| `result` | `unknown` | The result (when `ok` is true). After a `replaceResult` from an earlier middleware, the new result. |
+| `error` | `unknown` | The error (when `ok` is false) |
+
+#### Replace a tool result
+
+Some tools return much more than the model needs: 500 rows, a huge log, a secret you want to hide. The model reads every token of it. Return `{ type: 'replaceResult', result }`, and the model gets your version:
+
+```typescript
+import { type ChatMiddleware } from "@tanstack/ai";
+
+const firstTwentyRows: ChatMiddleware = {
+ name: "first-twenty-rows",
+ onAfterToolCall: (ctx, info) => {
+ if (!info.ok || !Array.isArray(info.result)) return;
+ if (info.result.length <= 20) return;
+ return {
+ type: "replaceResult",
+ result: {
+ rows: info.result.slice(0, 20),
+ omitted: info.result.length - 20,
+ },
+ };
+ },
+};
+```
+
+| Return | What happens |
+| --- | --- |
+| nothing | The result stays the same. |
+| `{ type: 'replaceResult', result }` | The model gets `result`. The stream sends it in `TOOL_CALL_RESULT`, so the client shows it too. |
+
+Good to know:
+
+- Each middleware gets the result of the one before it as `info.result`. The last replacement wins.
+- A failed call stays failed. Your replacement becomes the error that the model reads.
+- A `skip` result from `onBeforeToolCall` goes through this hook too. `info.result` is the parsed skip result, the same value that the model gets.
+- A call that the output limit cut goes through this hook too. It did not run, so `ok` is false, and `error` has the code `max_tokens`. See [When the output limit cuts a tool call](../chat/agentic-cycle#when-the-output-limit-cuts-a-tool-call).
+
+### Tool hook order in one turn
+
+When the model calls more than one server tool in one turn, the tools run at the same time:
+
+- `onBeforeToolCall` runs for each call in call order, before any tool starts.
+- `onAfterToolCall` runs for each tool when it finishes, so in finish order.
+- A call that `onBeforeToolCall` skips gets its `onAfterToolCall` immediately, before the tools start.
+- If the run aborts before the tools start, no tool runs. Each call gets `onAfterToolCall` with `ok: false` and the error "Operation aborted".
+- If `onBeforeToolCall` aborts or throws for a call, the calls before it still run. The error is thrown after they finish.
+
+With `toolExecution: 'sequential'`, each call finishes its `onBeforeToolCall`, its run, and its `onAfterToolCall` before the next call starts. See [Run tools one at a time](../tools/tools#run-tools-one-at-a-time).
+
+### onToolPhaseComplete
+
+`onToolPhaseComplete` runs once after all tool calls of an iteration are processed. Its `info` has:
+
+- `toolCalls`: the tool calls of the assistant message.
+- `results`: the completed results.
+- `needsApproval`: the calls that wait for user approval.
+- `needsClientExecution`: the calls that the client must run.
+
+### onUsage
+
+`onUsage` runs once per model call when the `RUN_FINISHED` chunk has usage data. It gets the usage object.
+
+```typescript
+import { type ChatMiddleware } from "@tanstack/ai";
+
+const usageTracker: ChatMiddleware = {
+ name: "usage-tracker",
+ onUsage: (ctx, usage) => {
+ console.log(
+ `Iteration ${ctx.iteration}: ${usage.totalTokens} tokens`
+ );
+ },
+};
+```
+
+The main `usage` fields:
+
+| Field | Type | Description |
+| --- | --- | --- |
+| `promptTokens` | `number` | Input tokens |
+| `completionTokens` | `number` | Output tokens |
+| `totalTokens` | `number` | Total tokens |
+| `cost` | `number \| undefined` | The cost that the provider reports, when it reports one |
+
+The object can also have `promptTokensDetails`, `completionTokensDetails`, `costDetails`, and `providerUsageDetails`.
+
+### Terminal hooks: onFinish, onAbort, onError
+
+Exactly **one** terminal hook runs per `chat()` call:
+
+| Hook | When it runs |
+| --- | --- |
+| `onFinish` | The run completed normally |
+| `onAbort` | The run was aborted: by `ctx.abort()`, by an external `AbortSignal`, or by an `abort` decision from `onBeforeToolCall` |
+| `onError` | An error was not handled |
+
+`onFinish` also runs when the call pauses for a client tool or an approval. The run is not complete yet. To tell a pause from a completion, look in `onChunk` for a `RUN_FINISHED` chunk with `outcome.type === 'interrupt'`.
+
+`onAbort` gets `info.cancelRequested`. It is `true` only when an explicit cancel aborted the run, never when the client only disconnected.
+
+```typescript
+import { type ChatMiddleware } from "@tanstack/ai";
+
+const terminal: ChatMiddleware = {
+ name: "terminal",
+ onFinish: (ctx, info) => {
+ console.log(`Finished: ${info.finishReason}, ${info.duration}ms`);
+ console.log(`Content: ${info.content}`);
+ if (info.usage) {
+ console.log(`Tokens: ${info.usage.totalTokens}`);
+ }
+ },
+ onAbort: (ctx, info) => {
+ console.log(`Aborted: ${info.reason}, ${info.duration}ms`);
+ },
+ onError: (ctx, info) => {
+ console.error(`Error after ${info.duration}ms:`, info.error);
+ },
+};
+```
+
+The `info` fields for `onFinish` (`FinishInfo`):
+
+| Field | Type | Description |
+| --- | --- | --- |
+| `finishReason` | `string \| null` | The last `finishReason` of the agent loop. `null` when no agent-loop iteration sent `RUN_FINISHED` (for example, a `chat({ outputSchema })` run with no tools). |
+| `duration` | `number` | The total run time in milliseconds, including any structured-output call. |
+| `content` | `string` | The text of the agent loop. Includes native-combined structured JSON. Excludes separate-finalization JSON. |
+| `usage` | `TokenUsage \| undefined` | **Optional.** The last `RUN_FINISHED.usage` of the agent loop. **Does not include the tokens of the separate structured-output call.** Always guard with `if (info.usage)` or `info.usage?.`. |
+
+To count the usage of the whole run, add up the values from `onUsage`. Do not use `info.usage` for that.
+
+#### Terminal hooks and structured output
+
+On the **separate-finalization** path, the adapter makes one more provider call after the agent loop:
+
+- `onStructuredOutputConfig` runs before that call. `ctx.phase` is `'structuredOutput'` for its chunks.
+- `onIteration` does **not** run for that call. It runs only for agent-loop iterations.
+- `onFinish` runs after that call completes. Its `info` shows the end state of the **agent loop**.
+- `info.content` does not have the JSON deltas. Read the result from the `structured-output.complete` CUSTOM event in `onChunk`.
+- `info.usage` is `undefined` for a structured-output run with no tools. Use `onUsage` to get the tokens of the final call. `onUsage` runs for **every** `RUN_FINISHED` with usage.
+
+On the **native-combined** path, the JSON comes in the normal agent-loop stream:
+
+- `onStructuredOutputConfig` does not run, and `ctx.phase` stays `'modelStream'`.
+- `onIteration` runs for the iteration that writes the JSON.
+- `info.content` includes the JSON.
+- `onChunk` gets the `structured-output.complete` event in the same phase.
+
+On both paths, a successful `onFinish` gets the complete canonical transcript in `ctx.messages`:
+
+- Native-combined output keeps the structured result on its last assistant message.
+- The separate-finalization path can keep the plain-text assistant message of the agent loop, then add a separate structured-output assistant message.
+
+This transcript is separate from the fields on `info`.
+
+## Context object
+
+Each hook gets a `ChatMiddlewareContext` as its first argument. The fields you use most:
+
+| Field | Type | Description |
+| --- | --- | --- |
+| `requestId` | `string` | A unique ID for this chat request |
+| `streamId` | `string` | A unique ID for this stream |
+| `runId` | `string` | The AG-UI run ID |
+| `threadId` | `string` | The AG-UI thread ID: the `threadId` that you pass to `chat()`. Use it to correlate events. |
+| `parentRunId` | `string \| undefined` | The paused run that this continuation belongs to |
+| `provider`, `model` | `string` | The provider name and the model ID |
+| `phase` | `ChatMiddlewarePhase` | The current lifecycle phase |
+| `iteration` | `number` | The agent-loop iteration (starts at 0) |
+| `chunkIndex` | `number` | How many chunks the run sent so far |
+| `callMetadata` | `Readonly` | The `metadata.tanstack` of the current model call (`source`, `stopReason`, `responseId`, and more). It changes as chunks arrive. Each assistant message of the call gets a copy. |
+| `messages` | `ReadonlyArray` | The current messages (read-only) |
+| `signal` | `AbortSignal \| undefined` | The external abort signal |
+| `abort(reason?)` | `function` | Abort the run from middleware |
+| `retry()` | `function` | Call the model again. Works only in [`onModelError`](#onmodelerror). |
+| `emitCustomEvent(name, value, options?)` | `function` | Push a `CUSTOM` chunk onto the stream now. See [Live custom events](#live-custom-events). |
+| `context` | `TContext` | The runtime context that you pass to `chat()` |
+| `defer(promise)` | `function` | Register a side effect that does not block the stream |
+
+### Typed runtime context
+
+`ChatMiddleware` takes a context generic. Middleware that you declare outside `chat()` then reads the same typed runtime context as your tools.
+
+```typescript
+import { chat, type ChatMiddleware } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
+import { session, audit } from "./server";
+
+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: openaiText("gpt-6.1-sol"),
+ messages: [{ role: "user", content: "Hello" }],
+ threadId: "thread-1",
+ middleware: [auditMiddleware],
+ context: {
+ userId: session.user.id,
+ audit,
+ },
+});
+```
+
+- When typed middleware or typed tools are present, `chat()` checks that `context` has the required shape.
+- Middleware typed as plain `ChatMiddleware` still works. Its `ctx.context` is `unknown`, and it does not require a `context` option.
+- Runtime context is application state in the process. AG-UI `RunAgentInput.context` is a different thing: protocol metadata that `chatParamsFromRequest` parses.
+
+See [Runtime Context](./runtime-context) for the server, the client, and how to send values from the client to the server.
+
+### Abort from middleware
+
+Call `ctx.abort()` to stop the run. The `onAbort` terminal hook then runs:
+
+```typescript
+import { type ChatMiddleware } from "@tanstack/ai";
+
+const timeout: ChatMiddleware = {
+ name: "timeout",
+ onChunk: (ctx) => {
+ if (ctx.chunkIndex > 1000) {
+ ctx.abort("Too many chunks");
+ }
+ },
+};
+```
+
+### Deferred side effects
+
+Use `ctx.defer()` to register promises that do not block the stream. The engine awaits them after the terminal hook:
+
+```typescript
+import { type ChatMiddleware } from "@tanstack/ai";
+
+const analytics: ChatMiddleware = {
+ name: "analytics",
+ onFinish: (ctx, info) => {
+ ctx.defer(
+ fetch("/api/analytics", {
+ method: "POST",
+ body: JSON.stringify({
+ requestId: ctx.requestId,
+ duration: info.duration,
+ tokens: info.usage?.totalTokens,
+ }),
+ })
+ );
+ },
+};
+```
+
+## Composing multiple middleware
+
+Middleware runs in array order. The order matters for hooks that pipe or stop early:
+
+```typescript
+import { chat } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
+import { authMiddleware, loggingMiddleware, cachingMiddleware } from "./middleware";
+
+const stream = chat({
+ adapter: openaiText("gpt-6.1-sol"),
+ messages: [{ role: "user", content: "Hello" }],
+ threadId: "thread-1",
+ middleware: [authMiddleware, loggingMiddleware, cachingMiddleware],
+});
+```
+
+### Composition rules
+
+| Hook | Composition | Effect of the order |
+| --- | --- | --- |
+| `onConfig` | **Piped.** Each gets the output of the one before it. | The earlier middleware changes the config first. |
+| `onStructuredOutputConfig` | **Piped.** Each gets the output of the one before it. | The earlier middleware changes the config first. |
+| `onStart` | Sequential | All run in order. |
+| `onChunk` | **Piped.** Chunks flow through each middleware. | If the first one drops a chunk, the later ones never see it. |
+| `onBeforeToolCall` | **First wins.** The first decision wins. | The earlier middleware has priority. |
+| `onShouldContinue` | **AND.** One `false` stops the loop. | The order changes only which middleware runs first. |
+| `onModelError` | Sequential. If any middleware calls `ctx.retry()` and the call has retries left, the model is called again. | All run in order, so each one sees the error. |
+| `onAfterToolCall` | **Piped.** Each gets the result of the previous `replaceResult`. | The last replacement wins. |
+| `onUsage` | Sequential | All run in order. |
+| `onFinish` / `onAbort` / `onError` | Sequential | All run in order. |
+
+## Capabilities
+
+Middleware often needs to **share state**. A provider middleware creates something (a database handle, a counter for the request, a sandbox). A consumer middleware reads it later in the same run.
+
+Capabilities make this hand-off type-safe and check the order:
+
+- The consumer declares what it needs.
+- The provider declares what it gives.
+- `chat()` refuses to run if no middleware provides a required capability. It checks at compile time _and_ at runtime.
+
+### Create a capability
+
+Create a capability with `createCapability()('name')`. It is a **curried** call:
+
+```typescript
+import { createCapability } from "@tanstack/ai";
+
+const counterCapability = createCapability<{ value: number }>()("counter");
+const [getCounter, provideCounter] = counterCapability;
+```
+
+You give the **value type** explicitly (`<{ value: number }>`). TypeScript infers the **name literal** from the argument (`"counter"`). One call cannot do both. If you give `T` explicitly, TypeScript stops inferring the name and makes it `string`. Then the compile-time coverage check, which uses the literal name, does not work.
+
+The returned `counterCapability` has two roles:
+
+- It **destructures to `[get, provide]`**, the two accessors that you use in hooks.
+- It **is the identity** that you list in `requires` and `provides`. There is no separate token to import.
+
+The accessors:
+
+| Accessor | Behavior |
+| --- | --- |
+| `getCounter(ctx)` | Returns the value. **Throws** if no middleware provided the capability. |
+| `getCounter(ctx, { optional: true })` | Returns `TValue \| undefined`. Does not throw. |
+| `provideCounter(ctx, value)` | Sets the value for this run. Call it from `setup`. |
+
+The context has the same accessors: `ctx.get(capability)`, `ctx.getOptional(capability)`, and `ctx.provide(capability, value)`. Pass the capability handle. The handle gives the type, so `getCounter(ctx)` and `ctx.get(counterCapability)` do the same thing.
+
+> [!WARNING]
+> Use a unique capability name across your app. The compile-time check uses the name literal. The runtime check uses the handle reference. Two capabilities with the same name get mixed up in the type check.
+
+### The `setup` hook
+
+You provide capabilities in a `setup(ctx)` hook. `setup` **runs first**, before any `onConfig` (init), across all middleware in array order. So every capability is in place when the rest of the run starts.
+
+- `setup` gets the stable `ChatMiddlewareContext`, not the config.
+- `setup` can be async.
+
+### `requires`, `provides`, `optionalRequires`
+
+Three array fields declare the capability contract of a middleware. Each one is a `ReadonlyArray`. List the capability handles:
+
+| Field | Meaning |
+| --- | --- |
+| `provides` | Capabilities that this middleware sets up. Each one **must** be provided in `setup`, or `chat()` throws after the setup phase. |
+| `requires` | Capabilities that this middleware reads. `chat()` checks (at compile time and at runtime) that an earlier middleware provides each one. |
+| `optionalRequires` | Capabilities that this middleware uses **if present**. They never cause a validation error. Read them with `getX(ctx, { optional: true })`. |
+
+### Array example
+
+Write middleware with `defineChatMiddleware`. It gives exact tuple types to `requires` and `provides`, so the coverage check and the builder can read them. Here a **provider** sets up a counter in `setup`, and a **consumer** reads it in a hook:
+
+```typescript
+import {
+ chat,
+ createCapability,
+ defineChatMiddleware,
+} from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
+
+const counterCapability = createCapability<{ value: number }>()("counter");
+const [getCounter, provideCounter] = counterCapability;
+
+// Provider: declares `provides` and sets the value in `setup`.
+const withCounter = defineChatMiddleware({
+ name: "with-counter",
+ provides: [counterCapability],
+ setup(ctx) {
+ provideCounter(ctx, { value: 0 });
+ },
+});
+
+// Consumer: declares `requires` and reads the value with `get` in a hook.
+const countsChunks = defineChatMiddleware({
+ name: "counts-chunks",
+ requires: [counterCapability],
+ onChunk(ctx) {
+ getCounter(ctx).value++;
+ },
+ onFinish(ctx) {
+ console.log(`Saw ${getCounter(ctx).value} chunks`);
+ },
+});
+
+const stream = chat({
+ adapter: openaiText("gpt-6.1-sol"),
+ messages: [{ role: "user", content: "Hello" }],
+ threadId: "thread-1",
+ // The provider must come before the consumer.
+ middleware: [withCounter, countsChunks],
+});
+```
+
+If you remove `withCounter` from the array, TypeScript shows an error at the `middleware` option. The error names the missing `"counter"` capability. At runtime, `chat()` throws before it calls the adapter.
+
+### Builder example
+
+`createChatMiddleware()` builds the array with chained `.use()` calls. It checks the **provider-before-consumer order at compile time**. Each `.use()` requires that earlier `.use()` calls provide the `requires` of the new middleware.
+
+```typescript
+import {
+ chat,
+ createCapability,
+ createChatMiddleware,
+ defineChatMiddleware,
+} from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
+
+const counterCapability = createCapability<{ value: number }>()("counter");
+const [getCounter, provideCounter] = counterCapability;
+
+const withCounter = defineChatMiddleware({
+ name: "with-counter",
+ provides: [counterCapability],
+ setup(ctx) {
+ provideCounter(ctx, { value: 0 });
+ },
+});
+
+const countsChunks = defineChatMiddleware({
+ name: "counts-chunks",
+ requires: [counterCapability],
+ onChunk(ctx) {
+ getCounter(ctx).value++;
+ },
+});
+
+const middleware = createChatMiddleware()
+ .use(withCounter) // provides "counter"
+ .use(countsChunks) // requires "counter", which the line above provides
+ .build();
+
+const stream = chat({
+ adapter: openaiText("gpt-6.1-sol"),
+ messages: [{ role: "user", content: "Hello" }],
+ threadId: "thread-1",
+ middleware,
+});
+```
+
+If you swap the two `.use()` calls (`.use(countsChunks).use(withCounter)`), the builder rejects the `.use(countsChunks)` line. The consumer comes before its provider, so `"counter"` is not in the provided set yet.
+
+### Validation guarantees
+
+The capability system fails early, with a clear error:
+
+- **Compile-time coverage.** A required capability that no middleware provides is a type error at the `middleware` option. Two checks do this: an **array coverage check** on `middleware: [...]`, and the **`createChatMiddleware()` builder**, which also checks the order.
+- **Runtime coverage.** If the types are bypassed, `chat()` still checks coverage. It **throws before the adapter runs** when a required capability is missing.
+- **Check after `setup`.** If a middleware lists a capability in `provides` but does not provide it in `setup`, `chat()` throws after the setup phase.
+- **Duplicate provide.** If two middleware provide the same capability, the last write wins, and you get a development warning.
+- **Unique names.** Capability names must be unique across your app. The compile-time check uses the name literal. The runtime check uses the handle reference.
+
+### Append records to the session log
+
+A host is a middleware that keeps a session log and provides `LogRecordsCapability`. `chat()` does not provide it, so without a host there is no log. With a host, your middleware can write its own records to that log. For example, a note that a run started. Read the writer with `getLogRecords`:
+
+```typescript
+import {
+ LogRecordsCapability,
+ defineChatMiddleware,
+ getLogRecords,
+} from "@tanstack/ai";
+
+const noteStart = defineChatMiddleware({
+ name: "note-start",
+ optionalRequires: [LogRecordsCapability],
+ async onStart(ctx) {
+ const log = getLogRecords(ctx, { optional: true });
+ await log?.append([{ type: "app.note", text: "run started" }]);
+ },
+});
+```
+
+- Each record needs a `type`. The other fields are yours.
+- The host decides which record types it accepts. If the host refuses a record, `append` rejects.
+- Without a host log, `getLogRecords(ctx, { optional: true })` returns `undefined`. If your middleware also runs without a log, use the optional read.
+- If your middleware cannot work without the log, list the capability in `requires` and call `getLogRecords(ctx)`. Then `chat()` throws when no host provides the log.
+
+If you write a host, provide the log in `setup` and list it in `provides`:
+
+```typescript
+import {
+ LogRecordsCapability,
+ defineChatMiddleware,
+ provideLogRecords,
+} from "@tanstack/ai";
+
+const withSessionLog = defineChatMiddleware({
+ name: "with-session-log",
+ provides: [LogRecordsCapability],
+ setup(ctx) {
+ // One log per run. A real host writes to its durable store here.
+ const records: Array<{ type: string }> = [];
+ provideLogRecords(ctx, {
+ append: async (batch) => {
+ records.push(...batch);
+ },
+ });
+ },
+});
+```
+
+## Recipes
+
+### Live custom events
+
+A long `onConfig` hook can send progress to the client while it waits. Call `ctx.emitCustomEvent` when the work starts, then again when it finishes:
+
+```typescript
+import { type ChatMiddleware } from "@tanstack/ai";
+
+async function prepare() {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 1);
+ });
+}
+
+const progress: ChatMiddleware = {
+ name: "progress",
+ async onConfig(ctx) {
+ if (ctx.phase !== "beforeModel") return;
+ ctx.emitCustomEvent("job:started", { step: "prepare" });
+ await prepare();
+ ctx.emitCustomEvent("job:ended", { step: "prepare" });
+ },
+};
+```
+
+- The engine sends each `CUSTOM` chunk as soon as you call `emitCustomEvent`, also while the hook still runs.
+- If `RUN_STARTED` is not on the wire yet, the engine sends it first.
+- Durability flushes the event immediately, so the client can show a live indicator. Pass `{ batch: true }` to keep it in the durability batch.
+
+Read these events on the client the same way as `emitCustomEvent` calls from tools. See [Custom Events](../protocol/custom-events).
+
+### Rate limiting
+
+Limit the number of tool calls per request:
+
+```typescript
+import { type ChatMiddleware } from "@tanstack/ai";
+
+function rateLimitMiddleware(maxCalls: number): ChatMiddleware {
+ let toolCallCount = 0;
+ return {
+ name: "rate-limit",
+ onBeforeToolCall: (ctx, hookCtx) => {
+ toolCallCount++;
+ if (toolCallCount > maxCalls) {
+ return {
+ type: "abort",
+ reason: `Rate limit: exceeded ${maxCalls} tool calls`,
+ };
+ }
+ },
+ };
+}
+```
+
+The counter lives in the middleware object. Call `rateLimitMiddleware()` in the request handler, so each request gets its own counter.
+
+### Audit trail
+
+Log each action for compliance:
+
+```typescript
+import { type ChatMiddleware } from "@tanstack/ai";
+import { db } from "./db";
+
+const auditTrail: ChatMiddleware = {
+ name: "audit-trail",
+ onStart: (ctx) => {
+ ctx.defer(
+ db.auditLog.create({
+ requestId: ctx.requestId,
+ event: "chat_started",
+ timestamp: Date.now(),
+ })
+ );
+ },
+ onAfterToolCall: (ctx, info) => {
+ ctx.defer(
+ db.auditLog.create({
+ requestId: ctx.requestId,
+ event: "tool_executed",
+ toolName: info.toolName,
+ success: info.ok,
+ duration: info.duration,
+ timestamp: Date.now(),
+ })
+ );
+ },
+ onFinish: (ctx, info) => {
+ ctx.defer(
+ db.auditLog.create({
+ requestId: ctx.requestId,
+ event: "chat_finished",
+ duration: info.duration,
+ tokens: info.usage?.totalTokens,
+ timestamp: Date.now(),
+ })
+ );
+ },
+};
+```
+
+### Different tools per iteration
+
+Give the model different tools at different steps of the agent loop:
+
+```typescript
+import { type ChatMiddleware } from "@tanstack/ai";
+
+const toolSwapper: ChatMiddleware = {
+ name: "tool-swapper",
+ onConfig: (ctx, config) => {
+ if (ctx.phase !== "beforeModel") return;
+
+ if (ctx.iteration === 0) {
+ // First iteration: only allow search
+ return {
+ tools: config.tools.filter((t) => t.name === "search"),
+ };
+ }
+ // Later iterations: allow all tools
+ },
+};
+```
+
+### Content filtering
+
+Drop or change chunks before they reach the consumer:
+
+```typescript ignore
+import { type ChatMiddleware } from "@tanstack/ai";
+import { containsProfanity } from "./filters";
+
+const contentFilter: ChatMiddleware = {
+ name: "content-filter",
+ onChunk: (ctx, chunk) => {
+ if (chunk.type === "TEXT_MESSAGE_CONTENT") {
+ if (containsProfanity(chunk.delta)) {
+ // Drop the chunk
+ return null;
+ }
+ }
+ },
+};
+```
+
+For a ready-made version, see [`contentGuardMiddleware`](./built-in-middleware#contentguardmiddleware).
+
+### Alert on errors
+
+```typescript
+import { type ChatMiddleware } from "@tanstack/ai";
+import { alertService } from "./services";
+
+const errorAlerts: ChatMiddleware = {
+ name: "error-alerts",
+ onError: (ctx, info) => {
+ ctx.defer(
+ alertService.send({
+ level: "error",
+ message: `Chat ${ctx.requestId} failed after ${info.duration}ms`,
+ error: String(info.error),
+ })
+ );
+ },
+};
+```
+
+## TypeScript types
+
+The core middleware types come from `@tanstack/ai`:
+
+```typescript
+import type {
+ ChatMiddleware,
+ ChatMiddlewareContext,
+ ChatMiddlewarePhase,
+ ChatMiddlewareConfig,
+ StructuredOutputMiddlewareConfig,
+ ToolCallHookContext,
+ BeforeToolCallDecision,
+ AfterToolCallInfo,
+ AfterToolCallDecision,
+ IterationInfo,
+ ToolPhaseCompleteInfo,
+ UsageInfo,
+ FinishInfo,
+ AbortInfo,
+ ErrorInfo,
+} from "@tanstack/ai";
+```
+
+The option types of the [built-in middleware](./built-in-middleware) come from the `@tanstack/ai/middlewares` subpath:
+
+```typescript
+import type {
+ ToolCacheMiddlewareOptions,
+ ToolCacheStorage,
+ ToolCacheEntry,
+ ContentGuardMiddlewareOptions,
+ ContentGuardRule,
+ ContentFilteredInfo,
+} from "@tanstack/ai/middlewares";
+```
diff --git a/docs/advanced/middleware.md b/docs/advanced/middleware.md
index 097abed74e..e5aacd7388 100644
--- a/docs/advanced/middleware.md
+++ b/docs/advanced/middleware.md
@@ -2,7 +2,7 @@
title: Middleware
id: middleware
order: 1
-description: "Hook into every stage of TanStack AI's chat() lifecycle with middleware — logging, analytics, stream transforms, tool interception, and side effects."
+description: "Run your own code at each step of a chat() call. Log runs, count tokens, change the request, guard tool calls, and filter the stream."
keywords:
- tanstack ai
- middleware
@@ -14,171 +14,112 @@ keywords:
- stream transform
---
-Middleware lets you hook into every stage of the `chat()` lifecycle — from configuration to streaming, tool execution, usage tracking, and completion. You can observe, transform, or short-circuit behavior at each stage without modifying your adapter or tool implementations.
+Middleware runs around every `chat()` call. Use it to log a run, count tokens, or block a tool, without copying that code into each route.
-Common use cases include:
+Write it once as middleware. A middleware is an object with hooks. `chat()` calls each hook at a fixed step of the run.
-- **Logging and observability** — track token usage, tool execution timing, errors
-- **Configuration transforms** — inject system prompts, adjust temperature per iteration, filter tools
-- **Stream processing** — redact sensitive content, transform chunks, drop unwanted events
-- **Tool call interception** — validate arguments, cache results, abort on dangerous calls
-- **Side effects** — send analytics, update databases, trigger notifications
+## Quick setup
-## Quick Start
-
-Pass an array of middleware to the `chat()` function:
+### 1. Write the middleware
```typescript
-import { chat, type ChatMiddleware } from "@tanstack/ai";
-import { openaiText } from "@tanstack/ai-openai";
+// logger.ts
+import { type ChatMiddleware } from "@tanstack/ai";
-const logger: ChatMiddleware = {
+export const logger: ChatMiddleware = {
name: "logger",
onStart: (ctx) => {
- console.log(`[${ctx.requestId}] Chat started`);
+ console.log(`[${ctx.requestId}] started with ${ctx.model}`);
+ },
+ onAfterToolCall: (ctx, info) => {
+ console.log(`[${ctx.requestId}] ${info.toolName} took ${info.duration} ms`);
},
onFinish: (ctx, info) => {
- console.log(`[${ctx.requestId}] Finished in ${info.duration}ms`);
+ console.log(
+ `[${ctx.requestId}] done in ${info.duration} ms, ${info.usage?.totalTokens ?? 0} tokens`,
+ );
},
};
-
-const stream = chat({
- adapter: openaiText("gpt-5.5"),
- messages: [{ role: "user", content: "Hello" }],
- middleware: [logger],
-});
```
-> **Just want to see chunks flowing through your middleware during development?**
-> Use `debug: { middleware: true }` on your `chat()` call — no custom middleware required. See [Debug Logging](./debug-logging).
+### 2. Add it to `chat()`
-## Lifecycle Overview
+```typescript
+// api/chat.ts
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
+import { logger } from "./logger";
-Every `chat()` invocation follows a predictable lifecycle. Middleware hooks fire at specific phases:
+export async function POST(request: Request) {
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
-```mermaid
-graph TD
- A["chat() called"] --> B["onConfig (phase: init)"]
- B --> C[onStart]
- C --> D["onConfig (phase: beforeModel)"]
- D --> E["Adapter streams response"]
- E --> F["onChunk (for each chunk)"]
- F --> G{Tool calls?}
- G -->|No| H[onUsage]
- G -->|Yes| I[onBeforeToolCall]
- I --> J[Tool executes]
- J --> K[onAfterToolCall]
- K --> L{Continue loop?}
- L -->|Yes| D
- L -->|No| H
- H --> SO{"Structured output path?"}
- SO -->|None| M{Outcome}
- SO -->|"Native combined"| SOH["Post-loop structured-output harvest (onChunk)"]
- SOH --> M
- SO -->|"Separate finalization"| SOC[onStructuredOutputConfig]
- SOC --> SOM["onConfig (phase: structuredOutput)"]
- SOM --> SOS["Structured-output finalization (onChunk, onUsage)"]
- SOS --> M
- M -->|Success| N[onFinish]
- M -->|Abort| O[onAbort]
- M -->|Error| P[onError]
+ const stream = chat({
+ adapter: openaiText("gpt-6.1-sol"),
+ messages,
+ threadId,
+ runId,
+ middleware: [logger],
+ });
- style I fill:#e1f5ff
- style J fill:#ffe1e1
- style SOC fill:#e1f5ff
- style SOM fill:#e1f5ff
- style SOS fill:#e1f5ff
- style SOH fill:#e1f5ff
- style N fill:#e1ffe1
- style O fill:#fff4e1
- style P fill:#ffe1e1
+ return toServerSentEventsResponse(stream);
+}
```
-### Phase Transitions
+### 3. Send a message
-The context's `phase` field tracks where you are in the lifecycle:
+Send a message from your chat UI. The server console shows one line when the run starts, one line for each tool call, and one line when the run finishes:
-| Phase | When | Hooks Called |
-|-------|------|-------------|
-| `init` | Once at startup | `onConfig` |
-| `beforeModel` | Before each model call (per iteration) | `onConfig` |
-| `modelStream` | While adapter streams chunks | `onChunk`, `onUsage` |
-| `beforeTools` | Before tool execution | `onBeforeToolCall` |
-| `afterTools` | After tool execution | `onAfterToolCall` |
-| `structuredOutput` | During the final structured-output adapter call (when `outputSchema` is set **and** `supportsCombinedToolsAndSchema()` does not return `true` for the current model/options). Chunks from `adapter.structuredOutputStream` (or the synthesized non-streaming fallback) flow through `onChunk` with this phase, and `onUsage` fires for the final call's tokens. **Does not fire** for adapters that natively combine tools + schema in one streaming call (modern OpenAI Chat Completions, OpenAI Responses, Claude 4.5+, Gemini 3.x, Grok 4.x family, and OpenRouter when every routed model is in `OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS`; see issue #605). On that path middleware observes the run through `beforeModel` / `modelStream` as usual. | `onStructuredOutputConfig`, `onConfig`, `onChunk`, `onUsage` |
+```text
+[chat-1760000000000-k3j9x2a] started with gpt-6.1-sol
+[chat-1760000000000-k3j9x2a] done in 1840 ms, 312 tokens
+```
-## Hooks Reference
+The client code does not change. Middleware runs on the server only.
-### onConfig
+> [!TIP]
+> Do you only want to see the chunks during development? Set `debug: { middleware: true }` on `chat()`. You do not need your own middleware for that. See [Debug Logging](./debug-logging).
-Called once during `init` (startup) and once per iteration during `beforeModel` (before each model call). On the separate-finalization path, `onConfig` additionally re-fires at the structured-output boundary with `ctx.phase === 'structuredOutput'`, receiving the post-`onStructuredOutputConfig` view of the config. A single-iteration separate-finalization run therefore fires `onConfig` three times (`init` + `beforeModel` + `structuredOutput`). Native-combined output does not add this third call. Use `onConfig` to transform the configuration that the model receives.
+## How it works
-Return a **partial** config object with only the fields you want to change — they are shallow-merged with the current config automatically. No need to spread the existing config.
+Every hook is optional. Add only the hooks you need. The ones you use most:
-```typescript
-import { type ChatMiddleware } from "@tanstack/ai";
+| Hook | When it runs | What it can do |
+| --- | --- | --- |
+| `onConfig` | At the start, and before each model call | Change messages, system prompts, tools, model options |
+| `onStart` | Once, after the first `onConfig` | Start timers, write a log line |
+| `onChunk` | For each chunk of the stream | Change, split, or drop the chunk |
+| `onBeforeToolCall` | Before a tool runs | Change the input, skip the tool, or stop the run |
+| `onAfterToolCall` | After a tool runs | Read the result, or replace it |
+| `onUsage` | When a model call reports token usage | Count tokens and cost |
+| `onFinish`, `onAbort`, `onError` | Once, at the end | Exactly one of the three runs |
-const dynamicTemperature: ChatMiddleware = {
- name: "dynamic-temperature",
- onConfig: (ctx, config) => {
- if (ctx.phase === "init") {
- // Add a system prompt at startup — only systemPrompts is overwritten
- return {
- systemPrompts: [
- ...config.systemPrompts,
- "You are a helpful assistant.",
- ],
- };
- }
-
- if (ctx.phase === "beforeModel" && ctx.iteration > 0) {
- // Increase temperature on retries. Sampling params live in the
- // provider-native modelOptions object — `temperature` is universal,
- // so it's the same key across providers. Spread the existing
- // modelOptions so other model options stay unchanged.
- const current =
- typeof config.modelOptions?.temperature === "number"
- ? config.modelOptions.temperature
- : 0.7;
- return {
- modelOptions: {
- ...config.modelOptions,
- temperature: Math.min(current + 0.1, 1.0),
- },
- };
- }
- },
-};
-```
+More hooks exist for the agent loop, model errors, and interrupts. See [Middleware: Advanced](./middleware-advanced#hooks-reference).
-> Sampling parameters (`temperature`, `top_p` / `topP`, the various `max*Tokens` keys) live inside `modelOptions` under each provider's native name — they are no longer root config fields. `temperature` happens to be spelled the same across every provider, so the example above is provider-agnostic; if you mutate a token limit instead, use the provider-native key (e.g. `max_output_tokens` for OpenAI, `num_predict` nested under `modelOptions.options` for Ollama). See [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options).
+The middleware run in array order:
-**Config fields you can transform:**
+- `onConfig` and `onChunk` pipe. Each middleware gets the output of the one before it.
+- `onBeforeToolCall` stops at the first middleware that returns a decision.
+- The other hooks run one after another, in order.
-| Field | Type | Description |
-|-------|------|-------------|
-| `messages` | `ModelMessage[]` | Canonical conversation history. Persistence and `ctx.messages` use this field. |
-| `providerMessages` | `ModelMessage[]` | Temporary context sent to the provider. Defaults to `messages`. |
-| `systemPrompts` | `string[]` | System prompts |
-| `tools` | `Tool[]` | Available tools |
-| `metadata` | `Record` | Request metadata |
-| `modelOptions` | `Record` | Provider-native options — this is where sampling params (`temperature`, `top_p` / `topP`, the provider's `max*Tokens` key) now live, alongside every other model-specific knob. See [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options). |
-| `toolChoice` | `ToolChoice \| undefined` | How the model uses the tools at the next model call. See [Change the tool choice of a call](#change-the-tool-choice-of-a-call). |
-| `wrapFetch` | `FetchWrapper \| undefined` | Wraps the HTTP fetch of the next model call. See [Change the HTTP requests of a call](#change-the-http-requests-of-a-call). |
-| `promptCache` | `ResolvedPromptCache \| undefined` | The prompt cache of the next model call: its `retention` and `key`. See [Change the prompt cache of a call](#change-the-prompt-cache-of-a-call). |
+Each hook gets a context object (`ctx`) as its first argument. It has the IDs of the run, the current phase and iteration, and helpers like `ctx.abort()` and `ctx.defer()`.
-When multiple middleware define `onConfig`, the config is **piped** through them in order — each receives the merged config from the previous middleware.
+## Common changes
-Return `providerMessages` when a transform must affect only the model call. For
-compatibility, returning `messages` also updates provider input unless the same
-result sets `providerMessages` explicitly.
-
-#### Change the tool choice of a call
+### Change the tool choice of a call
Your agent hits its last model call, and the model calls a tool again. The run ends after the tool result, and the user gets no answer. Return `toolChoice: 'none'` on that last call, and the model answers in text:
```typescript
-import { chat, maxIterations, toolDefinition, type ChatMiddleware } from "@tanstack/ai";
+import {
+ chat,
+ maxIterations,
+ toolDefinition,
+ type ChatMiddleware,
+} from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { z } from "zod";
@@ -202,6 +143,7 @@ const answerOnLastCall: ChatMiddleware = {
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "How do I add a tool?" }],
+ threadId: "thread-1",
tools: [search],
agentLoopStrategy: maxIterations(MAX_CALLS),
middleware: [answerOnLastCall],
@@ -213,7 +155,7 @@ const stream = chat({
- Return it in the `beforeModel` phase. A value from the `init` phase does not reach a model call.
- For the values, and what each provider does with them, see [Choose when the model calls a tool](../tools/tools#choose-when-the-model-calls-a-tool).
-#### Change the HTTP requests of a call
+### Change the HTTP requests of a call
Your proxy wants a header on each request. Or you want to log each model call. You do not need your own client for that. Return `wrapFetch` from `onConfig`:
@@ -235,15 +177,16 @@ const traceHeader: ChatMiddleware = {
};
```
-You want the same wrapper on every call of the run? Pass it to `chat()`:
+Do you want the same wrapper on every call of the run? Pass it to `chat()`:
```typescript
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
const stream = chat({
- adapter: openaiText("gpt-5.6"),
+ adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "Hi" }],
+ threadId: "thread-1",
wrapFetch: (next) => async (input, init) => {
const started = Date.now();
const response = await next(input, init);
@@ -258,7 +201,7 @@ const stream = chat({
- Wrappers chain. The `chat()` wrapper runs first, then the middleware wrappers in middleware order, then the fetch of the adapter.
- Each adapter page says if that adapter supports `wrapFetch`.
-#### Change the prompt cache of a call
+### Change the prompt cache of a call
A slow tool, like a build or a test run, can pause your agent for more than 5 minutes. The short Claude cache is gone by then. Return `promptCache` from `onConfig` to keep the cache longer from the next call on:
@@ -278,1175 +221,35 @@ const longCacheAfterTools: ChatMiddleware = {
- The value you return goes to the adapter on the next model call. It stays until a middleware returns another one.
- For the retention values and what they cost, see [Prompt Caching](./prompt-caching#turn-it-off-or-keep-it-longer).
-### onStructuredOutputConfig
-
-Called once at the start of the final structured-output adapter call — only when `chat()` was invoked with `outputSchema` **and** `supportsCombinedToolsAndSchema()` does not return `true` for the current model/options. Pipes through middleware in order, like `onConfig`, but with access to the **JSON Schema** being sent to the provider. Use this hook when you need to transform the schema (e.g., inject `$defs`, strip vendor-incompatible keywords) or apply structured-output-specific behavior (e.g., suppress system prompts on the final call).
+### Block a tool call
-> Native-combined adapters (modern OpenAI, Claude 4.5+, Gemini 3.x, Grok 4.x — see issue #605) skip the separate finalization call and never invoke this hook. The engine passes the converted schema directly to `chatStream` after `onConfig` runs, so middleware cannot transform the native-combined schema.
-
-Return a **partial** `StructuredOutputMiddlewareConfig` with only the fields you want to change — they are shallow-merged with the current config. Return `void` to pass through.
+Some tools must never run from a chat. Return an `abort` decision from `onBeforeToolCall`, and the run stops before the tool runs:
```typescript
import { type ChatMiddleware } from "@tanstack/ai";
-import { sharedDefs } from "./defs";
-
-const injectDefs: ChatMiddleware = {
- name: "inject-defs",
- onStructuredOutputConfig: (_ctx, config) => {
- // `config.outputSchema` is the JSON Schema being sent to the provider
- return {
- outputSchema: {
- ...config.outputSchema,
- $defs: { ...sharedDefs },
- },
- };
- },
-};
-```
-
-**Config fields you can transform:**
-
-| Field | Type | Description |
-|-------|------|-------------|
-| `messages` | `ModelMessage[]` | Canonical conversation history |
-| `providerMessages` | `ModelMessage[]` | Temporary context sent to the final call |
-| `systemPrompts` | `SystemPrompt[]` | System prompts on the final call |
-| `metadata` | `Record` | Request metadata |
-| `modelOptions` | `Record` | Provider-native options — this is where sampling params (`temperature`, `top_p` / `topP`, the provider's `max*Tokens` key) now live, alongside every other model-specific knob. See [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options). |
-| `outputSchema` | `JSONSchema` | JSON Schema being sent to the provider for structured output |
-
-**Ordering at the structured-output boundary:**
-
-1. `onStructuredOutputConfig` fires first, piping through every middleware in array order.
-2. `onConfig` then re-fires at the same boundary with `ctx.phase === 'structuredOutput'`, receiving the post-`onStructuredOutputConfig` view of the config (minus `outputSchema`). Use `onConfig` for general-purpose transforms that apply to every adapter call; use `onStructuredOutputConfig` when you need access to the schema.
-
-When multiple middleware define `onStructuredOutputConfig`, the config is **piped** through them in order — each receives the merged config from the previous middleware.
-
-### onStart
-
-Called once after the initial `onConfig` completes. Use it for setup tasks like initializing timers or logging.
-
-```typescript
-import { type ChatMiddleware } from "@tanstack/ai";
-
-const timer: ChatMiddleware = {
- name: "timer",
- onStart: (ctx) => {
- console.log(`Request ${ctx.requestId} started at iteration ${ctx.iteration}`);
- },
-};
-```
-
-### onChunk
-
-Called for every chunk streamed from the adapter. You can observe, transform, expand, or drop chunks.
-
-```typescript ignore
-import { type ChatMiddleware } from "@tanstack/ai";
-
-const redactor: ChatMiddleware = {
- name: "redactor",
- onChunk: (ctx, chunk) => {
- if (chunk.type === "TEXT_MESSAGE_CONTENT") {
- // Transform: redact sensitive content
- return {
- ...chunk,
- delta: chunk.delta.replace(/\b\d{3}-\d{2}-\d{4}\b/g, "[REDACTED]"),
- };
- }
- // Return void to pass through unchanged
- },
-};
-```
-
-**Return values:**
-
-| Return | Effect |
-|--------|--------|
-| `void` / `undefined` | Chunk passes through unchanged |
-| `StreamChunk` | Replaces the original chunk |
-| `StreamChunk[]` | Expands into multiple chunks |
-| `null` | Drops the chunk entirely |
-
-When multiple middleware define `onChunk`, chunks flow through them in order. If one middleware drops a chunk (returns `null`), subsequent middleware never see it.
-
-#### Chunk types you'll see
-
-`onChunk` receives every [AG-UI event](https://docs.ag-ui.com/introduction) the run produces — not just text. Narrow on `chunk.type` (a discriminated union) before reading type-specific fields. The common ones:
-
-| `chunk.type` | Meaning | Key fields |
-|--------------|---------|-----------|
-| `RUN_STARTED` / `RUN_FINISHED` / `RUN_ERROR` | Run lifecycle boundaries | `runId`, `finishReason`, `usage` (on finish), `message` (on error) |
-| `TEXT_MESSAGE_START` / `TEXT_MESSAGE_CONTENT` / `TEXT_MESSAGE_END` | Assistant text streaming | `messageId`, `delta` (content) |
-| `TOOL_CALL_START` / `TOOL_CALL_ARGS` / `TOOL_CALL_END` | Tool invocation streaming | `toolCallId`, `toolCallName`, `delta` (args), result on end |
-| `STEP_STARTED` / `STEP_FINISHED` | Thinking / reasoning steps | `delta`, `signature` |
-| `STATE_SNAPSHOT` / `STATE_DELTA` | Agent state sync | `snapshot`, `delta` |
-| `CUSTOM` | Extensibility events (incl. structured-output — see below) | `name`, `value` |
-
-See the [AG-UI protocol docs](https://docs.ag-ui.com/introduction) for the full event catalogue and exact field shapes.
-
-#### Transforming structured-output chunks
-
-There is **no separate `onStructuredOutputChunk` hook** — and you don't need one. When `chat()` is invoked with `outputSchema`, the structured-output chunks (the JSON `TEXT_MESSAGE_CONTENT` deltas, plus the `structured-output.start` / `structured-output.complete` CUSTOM events and any finalization `RUN_ERROR`) flow through the **same `onChunk` hook** as everything else. You transform, expand, or drop them exactly like any other chunk.
-
-How you distinguish them depends on which finalization path the adapter takes:
-
-- **Separate-finalization adapters** (`supportsCombinedToolsAndSchema()` does not return `true` for the current model/options): `ctx.phase === 'structuredOutput'` during the finalization call. Discriminate on the phase.
-- **Native-combined adapters** (modern OpenAI Chat Completions / Responses, Claude 4.5+, Gemini 3.x, Grok 4.x — see issue #605): the schema-constrained JSON is produced on the model's natural final turn, so **`ctx.phase` stays `'modelStream'`** — the `'structuredOutput'` phase never fires. Discriminate on the CUSTOM event name (`structured-output.start` / `structured-output.complete`) instead.
-
-```typescript ignore
-import { type ChatMiddleware } from "@tanstack/ai";
-
-const redactStructuredOutput: ChatMiddleware = {
- name: "redact-structured-output",
- onChunk: (ctx, chunk) => {
- // Separate-finalization path: the JSON streams as TEXT_MESSAGE_CONTENT
- // during the 'structuredOutput' phase. Transform the delta like any
- // other text chunk — here, redact anything that looks like an SSN before
- // it reaches the client.
- if (
- ctx.phase === "structuredOutput" &&
- chunk.type === "TEXT_MESSAGE_CONTENT"
- ) {
- return {
- ...chunk,
- delta: chunk.delta.replace(/\b\d{3}-\d{2}-\d{4}\b/g, "[REDACTED]"),
- };
- }
-
- // Both paths: the completed typed payload arrives as a CUSTOM
- // `structured-output.complete` event. On the native-combined path this is
- // your only signal (ctx.phase never flips to 'structuredOutput'), so key
- // off the event name, not the phase. `chunk.value` carries { object, raw }.
- if (chunk.type === "CUSTOM" && chunk.name === "structured-output.complete") {
- console.log("final structured output:", chunk.value);
- }
-
- // Return void to pass everything else through unchanged.
- },
-};
-```
-
-> Why is there `onStructuredOutputConfig` but no `onStructuredOutputChunk`? Because the **config** shape genuinely differs at the structured-output boundary — it carries an `outputSchema` field that plain `ChatMiddlewareConfig` doesn't (see [onStructuredOutputConfig](#onstructuredoutputconfig)). **Chunks** are all just `StreamChunk` regardless of phase, so one `onChunk` plus `ctx.phase` (or the CUSTOM event name) covers every case — a parallel chunk hook would be redundant.
-
-### onShouldContinue
-
-Called when the engine is deciding whether to start another agent-loop iteration (after a tool phase or between model turns). Combined with AND semantics across middleware **and** with `agentLoopStrategy` — any explicit `false` stops the loop. Return `true`, `void`, or `undefined` to allow continuation.
-
-Does **not** abort the run: the stream finishes normally with the current messages. Use `ctx.abort()` only for a hard abort.
-
-```typescript
-import { type ChatMiddleware } from "@tanstack/ai";
-
-const budget: ChatMiddleware = {
- name: "tool-budget",
- onShouldContinue: (_ctx, state) => {
- // Stop further turns once 20 tool calls have been emitted
- if (state.toolCallCount >= 20) return false;
- },
-};
-```
-
-For a full per-turn + cumulative tool budget recipe, see [Tool-call budgets](../chat/agentic-cycle#tool-call-budgets-middleware-recipe).
-
-### onModelError
-
-Some model calls fail for a reason your middleware can fix. For example, the prompt is too long for the model. `onModelError` gets the `RUN_ERROR` before anyone else sees it. Fix the request and call `ctx.retry()`. While the call has retries left, the run goes on as if the call never failed.
-
-Say your app sends retrieved documents as a second system prompt. When the call is too long, this middleware drops them and tries again:
-
-```typescript
-import { CONTEXT_OVERFLOW_CODE } from "@tanstack/ai";
-import type { ChatMiddleware, ChatMiddlewareContext } from "@tanstack/ai";
-
-// The runs that go on without the documents.
-const withoutDocs = new WeakSet();
-
-const dropDocsWhenTooLong: ChatMiddleware = {
- name: "drop-docs-when-too-long",
- onModelError: (ctx, error) => {
- if (error.code !== CONTEXT_OVERFLOW_CODE) return;
- withoutDocs.add(ctx);
- ctx.retry();
- },
- onConfig: (ctx, config) => {
- if (ctx.phase !== "beforeModel" || !withoutDocs.has(ctx)) return;
- // Keep the main system prompt. Drop the documents after it.
- return { systemPrompts: config.systemPrompts.slice(0, 1) };
- },
-};
-```
-
-What `ctx.retry()` does while the call has retries left:
-
-- It drops the failed call. `onChunk` and the caller never see its `RUN_STARTED` or `RUN_ERROR`.
-- It runs `onConfig` again (phase `beforeModel`), then calls the model again.
-- It works only inside `onModelError`. In any other hook it throws.
-
-When the hook runs:
-
-- Only when the adapter sends a `RUN_ERROR` before any output. A call that already sent text is not retried, so no output repeats.
-- Not for an exception that the adapter throws. That error goes to `onError`.
-- Not for the separate structured-output call that runs after the agent loop. The model calls of the agent loop get the hook as usual.
-- Every time, also after the last retry. A model call retries at most 2 times. After that, the error goes to the caller.
-
-`withCompaction` uses this hook to [compact and retry after a context overflow](./compaction#when-the-chat-is-still-too-long).
-
-### onInterruptBoundary and onInterruptResolution
-
-Use these hooks when middleware needs data from the client. Define the request
-with `defineInterrupt()` and register it with `chat({ interrupts })` and
-`useChat({ interrupts })`. Do not emit raw AG-UI events from middleware.
-
-`onInterruptBoundary` runs at four points in an agent iteration:
-
-- `beforeModel`, before the adapter starts.
-- `afterModel`, after the model response is complete.
-- `beforeTools`, before tool execution starts.
-- `afterTools`, after the tool phase is complete.
-
-Each middleware can return requests from one boundary. The engine combines all
-requests from that boundary into one AG-UI interrupt batch. The batch ends the
-run with one interrupt outcome.
-
-This hook cannot change config. Its only legal return is `{ interrupts }` or
-nothing. The continuation is a new `chat()` call, so the hook runs again. Skip
-the emit when `ctx.parentRunId` is set if this pause belongs to the original
-request only.
-
-What is in `ctx` at each phase, and when to use each phase, is in
-[Lifecycle Boundaries](../interrupts/boundaries).
-
-Create one shared definition. Both the server and the client import this value,
-so the definition ID and response shape stay the same on both sides.
-
-```typescript title="review-plan.ts"
-import { defineInterrupt, type ChatMiddleware } from '@tanstack/ai'
-import { z } from 'zod'
-
-export const reviewPlan = defineInterrupt({
- id: 'review-plan',
- payloadSchema: z.object({ title: z.string() }),
- responseSchema: z.object({ approved: z.boolean() }),
-})
-
-export const reviewMiddleware: ChatMiddleware = {
- name: 'review-plan',
- onInterruptBoundary(ctx) {
- if (ctx.phase !== 'beforeTools') return
- if (ctx.parentRunId) return
- return {
- interrupts: [
- reviewPlan.interrupt({
- key: 'release-plan',
- reason: 'review-required',
- message: 'Approve this plan?',
- payload: { title: 'Release plan' },
- }),
- ],
- }
- },
- onInterruptResolution(_ctx, resumedInterrupts) {
- for (const result of resumedInterrupts.for(reviewPlan)) {
- if (result.status === 'resolved' && !result.response.approved) {
- return { toolResume: 'stop' }
- }
- }
- },
-}
-```
-
-Register the definition on the server. Forward `parentRunId` and `resume` so
-a client resolution starts the continuation with its full context.
-
-```typescript title="route.ts"
-import {
- chat,
- chatParamsFromRequestBody,
- toServerSentEventsResponse,
-} from '@tanstack/ai'
-import { openaiText } from '@tanstack/ai-openai'
-import { reviewMiddleware, reviewPlan } from './review-plan'
-
-export async function POST(request: Request) {
- const params = await chatParamsFromRequestBody(await request.json())
- const stream = chat({
- adapter: openaiText('gpt-5.5'),
- messages: params.messages,
- threadId: params.threadId,
- runId: params.runId,
- ...(params.parentRunId ? { parentRunId: params.parentRunId } : {}),
- ...(params.resume ? { resume: params.resume } : {}),
- interrupts: [reviewPlan],
- middleware: [reviewMiddleware],
- })
-
- return toServerSentEventsResponse(stream)
-}
-```
-
-Register the same definition on the client. Check `kind` and `definitionId`.
-TypeScript then treats the item as `GenericInterrupt`.
-`resolveInterrupt` uses the response shape from `reviewPlan.responseSchema`.
-
-```tsx title="review-plan-panel.tsx"
-import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
-import type { GenericInterrupt } from '@tanstack/ai-react'
-import { reviewPlan } from './review-plan'
-
-function ReviewCard({
- interrupt,
-}: {
- interrupt: GenericInterrupt
-}) {
- return (
- interrupt.resolveInterrupt({ approved: true })}
- >
- Approve plan
-
- )
-}
-
-export function ReviewPlanPanel() {
- const { interrupts, sendMessage } = useChat({
- connection: fetchServerSentEvents('/api/chat'),
- interrupts: [reviewPlan],
- })
-
- return (
- <>
- sendMessage('Review the release plan')}>
- Start review
-
- {interrupts.map((interrupt) => {
- if (interrupt.kind !== 'generic') return null
- if (!('definitionId' in interrupt)) return null
- if (interrupt.definitionId !== reviewPlan.id) return null
- return
- })}
- >
- )
-}
-```
-
-`onInterruptResolution` does not run in the `chat()` call that paused. It
-runs once at the start of the next `chat()` call, after the client answers.
-
-```
-setup
-onConfig (phase is init)
-onInterruptResolution (phase is still init)
-onStart
-then stop, or continue the agent loop
-```
-
-`useChat` sends `parentRunId` and `resume` on that second request. Each
-generic resume item includes the original request in `metadata`. If `resume`
-is present and `parentRunId` is missing, the server throws.
-
-Use `resumedInterrupts.for(definition)` for one typed definition. Use
-`resumedInterrupts.all()` for every registered definition. Use
-`resumedInterrupts.all(definitionA, definitionB)` to read a typed subset.
-
-The hook can return `toolResume: 'continue'`, `'cancel'`, or `'stop'`. Results
-from all middleware combine by the most restrictive rule: `stop` wins over
-`cancel`, and `cancel` wins over `continue`.
-
-This hook cannot change prompts, tools, or messages. Store the answer on a
-capability, then return those fields from `onConfig` when
-`ctx.phase === 'beforeModel'`.
-
-| Hook | Can change |
-| --- | --- |
-| `onInterruptBoundary` | Nothing. It can only pause. |
-| `onInterruptResolution` | Pending-tool policy (`toolResume`) |
-| `onConfig` | `messages`, `systemPrompts`, `tools`, `modelOptions`, `metadata`, `toolChoice` |
-
-The full resume order, plus an example that writes a user note into the
-system prompt, is in [Apply Answers](../interrupts/apply-answers).
-
-### onBeforeToolCall
-
-Called before a server tool runs, and before a client tool goes to the client. The first middleware that returns a non-void decision skips the remaining middleware for that call.
-
-Each tool call goes through these steps:
-
-1. The engine parses the JSON arguments from the model.
-2. `onBeforeToolCall` gets the parsed input.
-3. The tool's `inputSchema` checks the final input. An async validator is awaited. A schema transform applies once, here.
-4. The server tool runs, or the client gets the call, with the checked input.
-
-The schema checks the input after your hook:
-
-- `hookCtx.args` has no schema defaults or transforms yet, and it can be invalid. Check its shape before you read it.
-- A `transformArgs` value or an in-place edit must fit the schema. If it does not fit, the call gets a tool error, `onAfterToolCall` runs with `ok: false`, and the tool does not run.
-
-When the hook runs:
-
-- A client tool runs the hook before the server sends the call to the client. The hook does not run when the client result comes back.
-- A client tool gets `onAfterToolCall` only when the hook skips the call or the schema check fails. The result from the client does not run it. If your middleware pairs the two hooks, return early when `hookCtx.tool?.execute` is missing.
-- A call that waits for approval does not run the hook. A denied call does not run it either.
-- After approval, the hook gets the approved input, including any `editedArgs`. See [Tool Approval](../interrupts/tool-approval).
-
-```typescript
-import { type ChatMiddleware } from "@tanstack/ai";
-
-function isRecord(v: unknown): v is Record {
- return typeof v === "object" && v !== null;
-}
const guard: ChatMiddleware = {
name: "guard",
onBeforeToolCall: (ctx, hookCtx) => {
- // Block dangerous tools
if (hookCtx.toolName === "deleteDatabase") {
return { type: "abort", reason: "Dangerous operation blocked" };
}
-
- // Validate and transform arguments
- if (hookCtx.toolName === "search" && isRecord(hookCtx.args) && !hookCtx.args.limit) {
- return {
- type: "transformArgs",
- args: { ...hookCtx.args, limit: 10 },
- };
- }
- },
-};
-```
-
-**Decision types:**
-
-| Decision | Effect |
-|----------|--------|
-| `void` / `undefined` | Continue normally, next middleware can decide |
-| `{ type: 'transformArgs', args }` | Replace tool arguments before execution |
-| `{ type: 'skip', result }` | Skip execution entirely, use provided result |
-| `{ type: 'abort', reason? }` | Abort the entire chat run |
-
-The `hookCtx` provides:
-
-| Field | Type | Description |
-|-------|------|-------------|
-| `toolCall` | `ToolCall` | Raw tool call object |
-| `tool` | `Tool \| undefined` | Resolved tool definition |
-| `args` | `unknown` | Parsed arguments |
-| `toolName` | `string` | Tool name |
-| `toolCallId` | `string` | Tool call ID |
-
-### onAfterToolCall
-
-Called after each tool execution (or skip). Every middleware runs, in array order.
-
-```typescript
-import { type ChatMiddleware } from "@tanstack/ai";
-
-const toolLogger: ChatMiddleware = {
- name: "tool-logger",
- onAfterToolCall: (ctx, info) => {
- if (info.ok) {
- console.log(`${info.toolName} completed in ${info.duration}ms`);
- } else {
- console.error(`${info.toolName} failed:`, info.error);
- }
- },
-};
-```
-
-The `info` object provides:
-
-| Field | Type | Description |
-|-------|------|-------------|
-| `toolCall` | `ToolCall` | Raw tool call object |
-| `tool` | `Tool \| undefined` | Resolved tool definition |
-| `toolName` | `string` | Tool name |
-| `toolCallId` | `string` | Tool call ID |
-| `ok` | `boolean` | Whether execution succeeded |
-| `duration` | `number` | Execution time in milliseconds |
-| `result` | `unknown` | Result (when `ok` is true). After a `replaceResult` from an earlier middleware, the new result. |
-| `error` | `unknown` | Error (when `ok` is false) |
-
-#### Replace a tool result
-
-Some tools return way more than the model needs: 500 rows, a huge log, a secret you want to hide. The model reads every token of it. Return `{ type: 'replaceResult', result }` and the model gets your version instead:
-
-```typescript
-import { type ChatMiddleware } from "@tanstack/ai";
-
-const firstTwentyRows: ChatMiddleware = {
- name: "first-twenty-rows",
- onAfterToolCall: (ctx, info) => {
- if (!info.ok || !Array.isArray(info.result)) return;
- if (info.result.length <= 20) return;
- return {
- type: "replaceResult",
- result: {
- rows: info.result.slice(0, 20),
- omitted: info.result.length - 20,
- },
- };
- },
-};
-```
-
-| Return | What happens |
-|--------|--------|
-| nothing | The result stays the same. |
-| `{ type: 'replaceResult', result }` | The model gets `result`. The stream sends it in `TOOL_CALL_RESULT`, so the client shows it too. |
-
-Good to know:
-
-- Each middleware gets the result of the one before it as `info.result`. The last replacement wins.
-- A failed call stays failed. Your replacement becomes the error that the model reads.
-- A `skip` result from `onBeforeToolCall` goes through this hook too. `info.result` is the parsed skip result, the same value the model gets.
-- A call that the output limit cut goes through this hook too. It did not run, so `ok` is false, and `error` has the code `max_tokens`. See [When the output limit cuts a tool call](../chat/agentic-cycle#when-the-output-limit-cuts-a-tool-call).
-
-### Tool hook order in one turn
-
-When the model calls several server tools in one turn, the tools run at the same time:
-
-- `onBeforeToolCall` runs for each call in call order, before any tool starts.
-- `onAfterToolCall` runs for each tool when it finishes, so in finish order.
-- A call that `onBeforeToolCall` skips gets its `onAfterToolCall` right away, before the tools start.
-- If the run aborts before the tools start, no tool runs. Each call gets `onAfterToolCall` with `ok: false` and the error "Operation aborted".
-- If `onBeforeToolCall` aborts or throws for a call, the calls before it still run. The error is thrown after they finish.
-
-With `toolExecution: 'sequential'`, each call finishes its `onBeforeToolCall`, its run, and its `onAfterToolCall` before the next call starts. See [Run tools one at a time](../tools/tools#run-tools-one-at-a-time).
-
-### onUsage
-
-Called once per model iteration when the `RUN_FINISHED` chunk includes usage data. Receives the usage object directly.
-
-```typescript
-import { type ChatMiddleware } from "@tanstack/ai";
-
-const usageTracker: ChatMiddleware = {
- name: "usage-tracker",
- onUsage: (ctx, usage) => {
- console.log(
- `Iteration ${ctx.iteration}: ${usage.totalTokens} tokens`
- );
- },
-};
-```
-
-The `usage` object:
-
-| Field | Type | Description |
-|-------|------|-------------|
-| `promptTokens` | `number` | Input tokens |
-| `completionTokens` | `number` | Output tokens |
-| `totalTokens` | `number` | Total tokens |
-
-### Terminal Hooks: onFinish, onAbort, onError
-
-Exactly **one** terminal hook fires per `chat()` invocation. They are mutually exclusive:
-
-| Hook | When it fires |
-|------|--------------|
-| `onFinish` | Run completed normally |
-| `onAbort` | Run was aborted (via `ctx.abort()`, an external `AbortSignal`, or a `{ type: 'abort' }` decision from `onBeforeToolCall`) |
-| `onError` | An unhandled error occurred |
-
-`onFinish` also fires when the invocation pauses at a client-tool or approval wait. The run is not complete yet. To tell a pause from a completion, look in `onChunk` for a `RUN_FINISHED` chunk with `outcome.type === 'interrupt'`.
-
-> **Separate-finalization path:** Adapters without native-combined support make a separate structured-output provider call after the agent loop.
->
-> - `onStructuredOutputConfig` fires before the separate provider call, and `ctx.phase` is `'structuredOutput'` for its chunks.
-> - `onIteration` does **not** fire for finalization; it only fires for agent-loop iterations.
-> - `onFinish` fires after finalization completes. Its `info` object reflects the **agent loop's** terminal state.
-> - `info.content` — the agent loop's accumulated text. Separate-finalization JSON deltas are **not** included. Middleware can observe the completed result through the `structured-output.complete` CUSTOM event in `onChunk`.
-> - `info.usage` — the agent loop's last `RUN_FINISHED.usage`. For a tools-less structured-output run (no agent-loop iteration produces `RUN_FINISHED`), this is `undefined`. To capture finalization tokens, use `onUsage` — that hook fires for **every** `RUN_FINISHED` carrying usage, including the finalization call.
-> - `info.finishReason` — the agent loop's last `finishReason`. `null` when no agent-loop iteration produced `RUN_FINISHED` (e.g. a tools-less structured-output run).
-> - `info.duration` — wall-clock duration of the entire `chat()` invocation, including finalization.
->
-> **Native-combined output:** Adapters with native-combined support produce the schema-constrained JSON in the regular agent-loop stream. `onStructuredOutputConfig` does not fire, `ctx.phase` remains `'modelStream'`, and `onIteration` fires for the iteration that produces the JSON. The JSON is agent-loop text, so `info.content` includes it. Middleware observes the `structured-output.complete` event in `onChunk` during the same phase.
->
-> On successful completion in either path, `onFinish` receives the complete canonical transcript in `ctx.messages`. Native-combined output keeps the structured result on its terminal assistant message. The separate-finalization path can preserve the agent loop's plain-text assistant message followed by a distinct structured-output assistant message. This transcript is separate from the path-specific fields on `info`.
->
-> To aggregate usage across the whole run, accumulate from `onUsage` callbacks rather than relying on `info.usage`.
-
-```typescript
-import { type ChatMiddleware } from "@tanstack/ai";
-
-const terminal: ChatMiddleware = {
- name: "terminal",
- onFinish: (ctx, info) => {
- console.log(`Finished: ${info.finishReason}, ${info.duration}ms`);
- console.log(`Content: ${info.content}`);
- if (info.usage) {
- console.log(`Tokens: ${info.usage.totalTokens}`);
- }
- },
- onAbort: (ctx, info) => {
- console.log(`Aborted: ${info.reason}, ${info.duration}ms`);
- },
- onError: (ctx, info) => {
- console.error(`Error after ${info.duration}ms:`, info.error);
},
};
```
-The `info` object for `onFinish` (`FinishInfo`):
-
-| Field | Type | Description |
-|-------|------|-------------|
-| `finishReason` | `string \| null` | The agent loop's last `finishReason`. `null` when no agent-loop iteration produced `RUN_FINISHED` (e.g. a tools-less `chat({ outputSchema })` run). |
-| `duration` | `number` | Total run duration in milliseconds, including any structured-output finalization. |
-| `content` | `string` | The agent loop's accumulated text content. Includes native-combined structured JSON; excludes separate-finalization JSON. Observe the completed result through the `structured-output.complete` CUSTOM event via `onChunk`. |
-| `usage` | `{ promptTokens; completionTokens; totalTokens } \| undefined` | **Optional.** The agent loop's last `RUN_FINISHED.usage`. **Does not include finalization tokens** — use `onUsage` to observe those. Always guard with `if (info.usage)` or `info.usage?.`. |
-
-## Context Object
+To change the input or skip the tool with your own result, see [onBeforeToolCall](./middleware-advanced#onbeforetoolcall).
-Every hook receives a `ChatMiddlewareContext` as its first argument. It provides request-scoped information and control functions:
+## Ready-made middleware
-| Field | Type | Description |
-|-------|------|-------------|
-| `requestId` | `string` | Unique ID for this chat request |
-| `streamId` | `string` | Unique ID for this stream |
-| `threadId` | `string` | AG-UI thread identifier. Resolves to caller-provided `threadId` (or legacy `conversationId`), or an auto-generated value if neither is supplied. Use this for event correlation. |
-| `conversationId` | `string \| undefined` | **Deprecated** alias of `threadId`. Always equals `ctx.threadId`; retained so middleware written before the AG-UI rename keeps working. New middleware should read `ctx.threadId`. |
-| `phase` | `ChatMiddlewarePhase` | Current lifecycle phase |
-| `iteration` | `number` | Agent loop iteration (0-indexed) |
-| `chunkIndex` | `number` | Running count of chunks yielded |
-| `callMetadata` | `Readonly` | The `metadata.tanstack` of the current model call (`source`, `stopReason`, `responseId`, and so on). It changes as chunks arrive. Each assistant message of the call gets a copy. |
-| `signal` | `AbortSignal \| undefined` | External abort signal |
-| `abort(reason?)` | `function` | Abort the run from within middleware |
-| `retry()` | `function` | Call the model again. Works only in [`onModelError`](#onmodelerror) |
-| `emitCustomEvent(name, value, options?)` | `function` | Push a `CUSTOM` chunk onto the chat stream now. The engine yields it while the current hook is still running, including during `onConfig`. Durability flushes it immediately. Pass `{ batch: true }` to keep it in the durability batch. |
-| `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";
-import { openaiText } from "@tanstack/ai-openai";
-import { session, audit } from "./server";
-
-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: openaiText("gpt-5.5"),
- messages: [{ role: "user", content: "Hello" }],
- middleware: [auditMiddleware],
- context: {
- userId: session.user.id,
- audit,
- },
-});
-```
+You do not have to write the common cases yourself:
-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.
+- `toolCacheMiddleware`: cache tool results.
+- `contentGuardMiddleware`: redact or block text in the stream.
+- `otelMiddleware`: send OpenTelemetry traces and metrics.
-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:
-
-```typescript
-import { type ChatMiddleware } from "@tanstack/ai";
-
-const timeout: ChatMiddleware = {
- name: "timeout",
- onChunk: (ctx) => {
- if (ctx.chunkIndex > 1000) {
- ctx.abort("Too many chunks");
- }
- },
-};
-```
-
-### Deferred Side Effects
-
-Use `ctx.defer()` to register promises that run after the terminal hook without blocking the stream:
-
-```typescript
-import { type ChatMiddleware } from "@tanstack/ai";
-
-const analytics: ChatMiddleware = {
- name: "analytics",
- onFinish: (ctx, info) => {
- ctx.defer(
- fetch("/api/analytics", {
- method: "POST",
- body: JSON.stringify({
- requestId: ctx.requestId,
- duration: info.duration,
- tokens: info.usage?.totalTokens,
- }),
- })
- );
- },
-};
-```
-
-## Composing Multiple Middleware
-
-Middleware execute in array order. The ordering matters for hooks that pipe or short-circuit:
-
-```typescript
-import { chat, type ChatMiddleware } from "@tanstack/ai";
-import { openaiText } from "@tanstack/ai-openai";
-import { authMiddleware, loggingMiddleware, cachingMiddleware } from "./middleware";
-
-const stream = chat({
- adapter: openaiText("gpt-5.5"),
- messages: [{ role: "user", content: "Hello" }],
- middleware: [authMiddleware, loggingMiddleware, cachingMiddleware],
-});
-```
-
-### Composition Rules
-
-| Hook | Composition | Effect of Order |
-|------|------------|----------------|
-| `onConfig` | **Piped** — each receives previous output | Earlier middleware transforms first |
-| `onStructuredOutputConfig` | **Piped** — each receives previous output | Earlier middleware transforms first |
-| `onStart` | Sequential | All run in order |
-| `onChunk` | **Piped** — chunks flow through each middleware | If first drops a chunk, later middleware never see it |
-| `onBeforeToolCall` | **First-win** — first non-void decision wins | Earlier middleware has priority |
-| `onShouldContinue` | **AND** — any explicit `false` stops the loop | Order only affects which middleware runs first when short-circuiting |
-| `onModelError` | Sequential. If any middleware calls `ctx.retry()` and the call has retries left, the model is called again | All run in order, so each one sees the error |
-| `onAfterToolCall` | **Piped**: each gets the result of the previous `replaceResult` | The last replacement wins |
-| `onUsage` | Sequential | All run in order |
-| `onFinish/onAbort/onError` | Sequential | All run in order |
-
-## Capabilities
-
-Middleware often need to **share state**. A provider middleware sets something up (a database handle, a per-request counter, a sandbox), and a consumer middleware reads it back later in the same run. Capabilities make that hand-off **type-safe and order-checked**: the consumer declares what it needs, the provider declares what it offers, and `chat()` refuses to run (at compile time _and_ at runtime) if a required capability was never provided.
-
-### Creating a capability
-
-A capability is created with `createCapability()('name')` — a **curried** call:
-
-```typescript
-import { createCapability } from "@tanstack/ai";
-
-const counterCapability = createCapability<{ value: number }>()("counter");
-const [getCounter, provideCounter] = counterCapability;
-```
-
-The currying is deliberate: you supply the **value type** explicitly (`<{ value: number }>`) while the **name literal** is inferred from the argument (`"counter"`). A single `createCapability('name')` call can't do both — supplying `T` explicitly stops TypeScript inferring the name, collapsing it to `string` and defeating the compile-time coverage check that keys on the literal name.
-
-The returned `counterCapability` is a hybrid value:
-
-- It **destructures to `[get, provide]`** — the two accessors you use inside hooks.
-- It **is itself the identity** you list in `requires` / `provides`. There is no separate token to import.
-
-The accessors:
-
-| Accessor | Behavior |
-|----------|----------|
-| `getCounter(ctx)` | Returns the value. **Throws** if the capability was never provided. |
-| `getCounter(ctx, { optional: true })` | Returns `TValue \| undefined` — no throw when absent. |
-| `provideCounter(ctx, value)` | Sets the value for this run. Call it from `setup`. |
-
-Equivalently, the context exposes `ctx.get(capability)`, `ctx.getOptional(capability)`, and `ctx.provide(capability, value)` — pass the capability handle directly. These are typed by the handle you pass (`ctx.get(counterCapability)` returns the value type), so `getCounter(ctx)` and `ctx.get(counterCapability)` are interchangeable — use whichever reads better in your hook.
-
-> **Capability names must be unique across your app.** The compile-time coverage check keys on the name literal (runtime keys on the handle reference), so two capabilities sharing a name will conflate in the type-level check.
-
-### The `setup` hook
-
-Provisioning happens in a dedicated `setup(ctx)` hook. It **runs first** — before any `onConfig` (init), across all middleware in array order — so that by the time the rest of the lifecycle begins, every capability is in place. `setup` receives the stable `ChatMiddlewareContext` (not the mutable config), and may be async.
-
-### `requires` / `provides` / `optionalRequires`
-
-Three array fields on a middleware declare its capability contract. Each is a `ReadonlyArray` — you list the capability handles themselves:
-
-| Field | Meaning |
-|-------|---------|
-| `provides` | Capabilities this middleware sets up. Each one **must** be `provide`d inside `setup`, or `chat()` throws after the setup phase. |
-| `requires` | Capabilities this middleware reads. `chat()` validates (compile time + runtime) that some earlier middleware provides each one. |
-| `optionalRequires` | Capabilities used **if present** but not required. Non-gating — never causes a validation error. Read with `getX(ctx, { optional: true })`. |
-
-### Array example
-
-Author middleware with `defineChatMiddleware` — it sharpens the `requires` / `provides` tuple types so the coverage check and builder can read them precisely. Here a **provider** sets up a counter in `setup`, and a **consumer** reads it in a hook:
-
-```typescript
-import {
- chat,
- createCapability,
- createChatMiddleware,
- defineChatMiddleware,
-} from "@tanstack/ai";
-import { openaiText } from "@tanstack/ai-openai";
-
-const counterCapability = createCapability<{ value: number }>()("counter");
-const [getCounter, provideCounter] = counterCapability;
-
-// Provider: declares `provides` and provisions the value in `setup`.
-const withCounter = defineChatMiddleware({
- name: "with-counter",
- provides: [counterCapability],
- setup(ctx) {
- provideCounter(ctx, { value: 0 });
- },
-});
-
-// Consumer: declares `requires` and reads the value via `get` in a hook.
-const countsChunks = defineChatMiddleware({
- name: "counts-chunks",
- requires: [counterCapability],
- onChunk(ctx) {
- getCounter(ctx).value++;
- },
- onFinish(ctx) {
- console.log(`Saw ${getCounter(ctx).value} chunks`);
- },
-});
-
-const stream = chat({
- adapter: openaiText("gpt-5.5"),
- messages: [{ role: "user", content: "Hello" }],
- // Provider must come before the consumer.
- middleware: [withCounter, countsChunks],
-});
-```
-
-If you drop `withCounter` from the array, `chat()` reports a compile-time error at the `middleware` option naming the missing `"counter"` capability — and throws at runtime before the adapter is ever called.
-
-### Builder example
-
-`createChatMiddleware()` builds the array through chained `.use()` calls and enforces **provider-before-consumer ordering at compile time**: each `.use()` requires that the middleware's `requires` are already covered by capabilities provided by earlier `.use()` calls.
-
-```typescript
-import {
- chat,
- createCapability,
- createChatMiddleware,
- defineChatMiddleware,
-} from "@tanstack/ai";
-import { openaiText } from "@tanstack/ai-openai";
-
-const counterCapability = createCapability<{ value: number }>()("counter");
-const [getCounter, provideCounter] = counterCapability;
-
-const withCounter = defineChatMiddleware({
- name: "with-counter",
- provides: [counterCapability],
- setup(ctx) {
- provideCounter(ctx, { value: 0 });
- },
-});
-
-const countsChunks = defineChatMiddleware({
- name: "counts-chunks",
- requires: [counterCapability],
- onChunk(ctx) {
- getCounter(ctx).value++;
- },
-});
-
-const middleware = createChatMiddleware()
- .use(withCounter) // provides "counter"
- .use(countsChunks) // requires "counter" — OK, already provided above
- .build();
-
-const stream = chat({
- adapter: openaiText("gpt-5.5"),
- messages: [{ role: "user", content: "Hello" }],
- middleware,
-});
-```
-
-Swap the two `.use()` calls (`.use(countsChunks).use(withCounter)`) and the builder rejects it at the `.use(countsChunks)` line — the consumer is ordered before its provider, so `"counter"` isn't in the provided set yet.
-
-### Validation guarantees
-
-The capability system fails loudly and early:
-
-- **Compile-time coverage.** A required capability that nothing provides surfaces as a type error at the `middleware` option. This is enforced two ways: an **array coverage check** on `middleware: [...]`, and the order-aware **`createChatMiddleware()` builder** (which additionally enforces ordering).
-- **Runtime coverage.** Even if types are bypassed, `chat()` validates coverage and **throws before the adapter runs** if a required capability is missing.
-- **Post-`setup` assertion.** If a middleware declares a capability in `provides` but never calls its `provide` accessor during `setup`, `chat()` throws after the setup phase — you can't silently forget to provision.
-- **Duplicate provide → last-wins + warning.** If two middleware provide the same capability, the last write wins and a development warning is emitted.
-- **Unique names.** Capability `name`s must be unique across your app; the compile-time coverage check keys on the name literal (runtime keys on the handle reference).
-
-### Append records to the session log
-
-A host is a middleware that keeps a session log and provides `LogRecordsCapability`. `chat()` does not provide it, so without a host there is no log. With a host, your middleware can write its own records to that log, for example a note that a run started. Read the writer with `getLogRecords`:
-
-```typescript
-import {
- LogRecordsCapability,
- defineChatMiddleware,
- getLogRecords,
-} from "@tanstack/ai";
-
-const noteStart = defineChatMiddleware({
- name: "note-start",
- optionalRequires: [LogRecordsCapability],
- async onStart(ctx) {
- const log = getLogRecords(ctx, { optional: true });
- await log?.append([{ type: "app.note", text: "run started" }]);
- },
-});
-```
-
-- Each record needs a `type`. The other fields are yours.
-- The host decides which record types it accepts. If the host refuses a record, `append` rejects.
-- Without a host log, `getLogRecords(ctx, { optional: true })` returns `undefined`. Use the optional read when your middleware also runs without a log.
-- If your middleware cannot work without the log, list the capability in `requires` and call `getLogRecords(ctx)`. Then `chat()` throws when no host provides the log.
-
-If you write a host, provide the log in `setup` and list it in `provides`:
-
-```typescript
-import {
- LogRecordsCapability,
- defineChatMiddleware,
- provideLogRecords,
-} from "@tanstack/ai";
-
-const withSessionLog = defineChatMiddleware({
- name: "with-session-log",
- provides: [LogRecordsCapability],
- setup(ctx) {
- // One log per run. A real host writes to its durable store here.
- const records: Array<{ type: string }> = [];
- provideLogRecords(ctx, {
- append: async (batch) => {
- records.push(...batch);
- },
- });
- },
-});
-```
-
-## Built-in Middleware
-
-TanStack AI ships ready-made middleware for common cases — caching tool results, redacting streamed text, and OpenTelemetry tracing:
-
-| Middleware | Import | What it does |
-|------------|--------|--------------|
-| `toolCacheMiddleware` | `@tanstack/ai/middlewares` | Cache tool-call results by name + arguments |
-| `contentGuardMiddleware` | `@tanstack/ai/middlewares` | Redact / transform / block streamed text content |
-| `otelMiddleware` | `@tanstack/ai/middlewares/otel` | Emit OpenTelemetry spans + GenAI metrics |
-
-See [Built-in Middleware](./built-in-middleware) for full options and examples for each. The recipes below show how to build your own.
-
-## Recipes
-
-### Live custom events
-
-A long `onConfig` hook can send progress to the client while it waits. Call `ctx.emitCustomEvent` when the work starts, then again when it finishes:
-
-```typescript
-import { type ChatMiddleware } from "@tanstack/ai";
-
-async function prepare() {
- await new Promise((resolve) => {
- setTimeout(resolve, 1);
- });
-}
-
-const progress: ChatMiddleware = {
- name: "progress",
- async onConfig(ctx) {
- if (ctx.phase !== "beforeModel") return;
- ctx.emitCustomEvent("job:started", { step: "prepare" });
- await prepare();
- ctx.emitCustomEvent("job:ended", { step: "prepare" });
- },
-};
-```
-
-The engine yields each `CUSTOM` chunk as soon as you call `emitCustomEvent`. If `RUN_STARTED` is not on the wire yet, the engine sends it first. Durability then flushes the event so the client can render a live indicator. Read these events on the client the same way as tool `emitCustomEvent` calls. See [Custom Events](../protocol/custom-events).
-
-### Rate Limiting
-
-Limit the number of tool calls per request:
-
-```typescript
-import { type ChatMiddleware } from "@tanstack/ai";
-
-function rateLimitMiddleware(maxCalls: number): ChatMiddleware {
- let toolCallCount = 0;
- return {
- name: "rate-limit",
- onBeforeToolCall: (ctx, hookCtx) => {
- toolCallCount++;
- if (toolCallCount > maxCalls) {
- return {
- type: "abort",
- reason: `Rate limit: exceeded ${maxCalls} tool calls`,
- };
- }
- },
- };
-}
-```
-
-### Audit Trail
-
-Log every action for compliance:
-
-```typescript
-import { type ChatMiddleware } from "@tanstack/ai";
-import { db } from "./db";
-
-const auditTrail: ChatMiddleware = {
- name: "audit-trail",
- onStart: (ctx) => {
- ctx.defer(
- db.auditLog.create({
- requestId: ctx.requestId,
- event: "chat_started",
- timestamp: Date.now(),
- })
- );
- },
- onAfterToolCall: (ctx, info) => {
- ctx.defer(
- db.auditLog.create({
- requestId: ctx.requestId,
- event: "tool_executed",
- toolName: info.toolName,
- success: info.ok,
- duration: info.duration,
- timestamp: Date.now(),
- })
- );
- },
- onFinish: (ctx, info) => {
- ctx.defer(
- db.auditLog.create({
- requestId: ctx.requestId,
- event: "chat_finished",
- duration: info.duration,
- tokens: info.usage?.totalTokens,
- timestamp: Date.now(),
- })
- );
- },
-};
-```
-
-### Per-Iteration Tool Swapping
-
-Expose different tools at different stages of the agent loop:
-
-```typescript
-import { type ChatMiddleware } from "@tanstack/ai";
-
-const toolSwapper: ChatMiddleware = {
- name: "tool-swapper",
- onConfig: (ctx, config) => {
- if (ctx.phase !== "beforeModel") return;
-
- if (ctx.iteration === 0) {
- // First iteration: only allow search
- return {
- tools: config.tools.filter((t) => t.name === "search"),
- };
- }
- // Later iterations: allow all tools
- },
-};
-```
-
-### Content Filtering
-
-Drop or transform chunks before they reach the consumer:
-
-```typescript ignore
-import { type ChatMiddleware } from "@tanstack/ai";
-import { containsProfanity } from "./filters";
-
-const contentFilter: ChatMiddleware = {
- name: "content-filter",
- onChunk: (ctx, chunk) => {
- if (chunk.type === "TEXT_MESSAGE_CONTENT") {
- if (containsProfanity(chunk.delta)) {
- // Drop the chunk entirely
- return null;
- }
- }
- },
-};
-```
-
-### Error Recovery with Retry Logging
-
-```typescript
-import { type ChatMiddleware } from "@tanstack/ai";
-import { alertService } from "./services";
-
-const errorRecovery: ChatMiddleware = {
- name: "error-recovery",
- onError: (ctx, info) => {
- ctx.defer(
- alertService.send({
- level: "error",
- message: `Chat ${ctx.requestId} failed after ${info.duration}ms`,
- error: String(info.error),
- })
- );
- },
-};
-```
-
-## TypeScript Types
-
-The core middleware types are exported from `@tanstack/ai`:
-
-```typescript
-import type {
- ChatMiddleware,
- ChatMiddlewareContext,
- ChatMiddlewarePhase,
- ChatMiddlewareConfig,
- StructuredOutputMiddlewareConfig,
- ToolCallHookContext,
- BeforeToolCallDecision,
- AfterToolCallInfo,
- AfterToolCallDecision,
- IterationInfo,
- ToolPhaseCompleteInfo,
- UsageInfo,
- FinishInfo,
- AbortInfo,
- ErrorInfo,
-} from "@tanstack/ai";
-```
-
-The option/type surfaces for the [built-in middleware](./built-in-middleware) are exported from the `@tanstack/ai/middlewares` subpath (not the main barrel):
-
-```typescript
-import type {
- ToolCacheMiddlewareOptions,
- ToolCacheStorage,
- ToolCacheEntry,
- ContentGuardMiddlewareOptions,
- ContentGuardRule,
- ContentFilteredInfo,
-} from "@tanstack/ai/middlewares";
-```
+See [Built-in Middleware](./built-in-middleware). To keep long chats under the context limit, see [Compaction](./compaction).
-## Next Steps
+## Advanced
-- [Built-in Middleware](./built-in-middleware) — `toolCacheMiddleware`, `contentGuardMiddleware`, `otelMiddleware`
-- [Compaction](./compaction): keep long chats under the context limit with `withCompaction`
-- [OpenTelemetry](./otel) — emit traces and metrics via `otelMiddleware`- [Tools](../tools/tools) — Learn about the isomorphic tool system
-- [Agentic Cycle](../chat/agentic-cycle) — Understand the multi-step agent loop
-- [Streaming](../chat/streaming) — How streaming works in TanStack AI
+Every hook in detail, the context object, capabilities, recipes, and the types are in [Middleware: Advanced](./middleware-advanced).
diff --git a/docs/advanced/multimodal-content.md b/docs/advanced/multimodal-content.md
index c55a3825c7..4a95b60afd 100644
--- a/docs/advanced/multimodal-content.md
+++ b/docs/advanced/multimodal-content.md
@@ -2,7 +2,7 @@
title: Multimodal Content
id: multimodal-content
order: 4
-description: "Send images, audio, video, and documents alongside text in TanStack AI messages with typed ContentPart primitives for multimodal models."
+description: "Send images, audio, video, and PDFs with the text of a message. Build the parts in the browser, and the adapter sends them to the model."
keywords:
- tanstack ai
- multimodal
@@ -15,589 +15,491 @@ keywords:
- ImagePart
---
-TanStack AI supports multimodal content in messages, allowing you to send images, audio, video, and documents alongside text to AI models that support these modalities.
+A message part can carry an image, audio clip, video, or PDF. The model then answers about that file.
-When sending messages to AI models, you can include different types of content:
-- **Text** - Plain text messages
-- **Images** - JPEG, PNG, GIF, WebP images
-- **Audio** - Audio files (model-dependent support)
-- **Video** - Video files (model-dependent support)
-- **Documents** - PDFs and other document types
+Make the `content` of the message an array of parts. One part holds the text, and the other parts hold the image, audio, video, or document.
-## Content Parts
+## Quick setup
-Multimodal messages use the `ContentPart` type to represent different content types:
+This setup lets a user pick an image and ask about it. It takes about 5 minutes.
-```typescript
-import type { ContentPart, ImagePart, TextPart } from '@tanstack/ai'
+### 1. Use a model that reads images
-// Text content
-const textPart: TextPart = {
- type: 'text',
- content: 'What do you see in this image?'
-}
+Your chat route does not change. Pick a model that takes image input:
-// Image from base64 data (mimeType is required for data sources)
-const imagePart: ImagePart = {
- type: 'image',
- source: {
- type: 'data',
- value: 'base64EncodedImageData...',
- mimeType: 'image/jpeg' // Required for data sources
- },
- metadata: {
- // Provider-specific metadata
- detail: 'high' // OpenAI detail level
- }
-}
-
-// Image from URL (mimeType is optional for URL sources)
-const imageUrlPart: ImagePart = {
- type: 'image',
- source: {
- type: 'url',
- value: 'https://example.com/image.jpg',
- mimeType: 'image/jpeg' // Optional hint for URL sources
- }
+```typescript
+// api/chat.ts
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
+
+export async function POST(request: Request) {
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
+
+ const stream = chat({
+ adapter: openaiText("gpt-6.1-sol"),
+ messages,
+ threadId,
+ runId,
+ });
+
+ return toServerSentEventsResponse(stream);
}
```
-## Using Multimodal Content in Messages
+### 2. Send the image from the browser
-Messages can have `content` as either a string or an array of `ContentPart`:
+Read the file as base64. Then send a text part and an image part together:
-```typescript
-import { chat } from '@tanstack/ai'
-import { openaiText } from '@tanstack/ai-openai'
+```tsx
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
+
+function readAsBase64(file: File): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => {
+ const result = reader.result;
+ if (typeof result !== "string") {
+ reject(new Error("Could not read the file"));
+ return;
+ }
+ // Remove the "data:image/png;base64," prefix.
+ resolve(result.slice(result.indexOf(",") + 1));
+ };
+ reader.onerror = () => reject(reader.error);
+ reader.readAsDataURL(file);
+ });
+}
-const response = await chat({
- adapter: openaiText('gpt-5.5'),
- messages: [
- {
- role: 'user',
+export function AskAboutImage() {
+ const { sendMessage } = useChat({
+ connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
+ });
+
+ async function send(file: File) {
+ const base64 = await readAsBase64(file);
+ await sendMessage({
content: [
- { type: 'text', content: 'What is in this image?' },
+ { type: "text", content: "What is in this image?" },
{
- type: 'image',
- source: {
- type: 'url',
- value: 'https://example.com/photo.jpg'
- }
- }
- ]
- }
- ]
-})
-```
-
-## Images in Tool Results
-
-A tool can return an array of `ContentPart`. Use it to give the model a screenshot or a chart:
-
-```typescript
-import { toolDefinition } from '@tanstack/ai'
-import type { ContentPart } from '@tanstack/ai'
-import { z } from 'zod'
+ type: "image",
+ source: { type: "data", value: base64, mimeType: file.type },
+ },
+ ],
+ });
+ }
-const getScreenshot = toolDefinition({
- name: 'get_screenshot',
- description: 'Get a screenshot of the page',
- inputSchema: z.object({}),
-}).server(async (): Promise> => [
- { type: 'text', content: 'Page screenshot' },
- {
- type: 'image',
- source: { type: 'data', value: 'iVBORw0KGgo...', mimeType: 'image/png' },
- },
-])
+ return (
+ {
+ const file = event.target.files?.[0];
+ if (file) void send(file);
+ }}
+ />
+ );
+}
```
-The adapter sends the image in the form that its API accepts:
-
-- **Responses APIs** (`openaiText`, `openRouterResponsesText`): the image stays in the tool output.
-- **Chat Completions APIs** (`openRouterText`, `openaiChatCompletions`, and the other Chat Completions adapters built on `@tanstack/openai-base`): a tool message holds text only. The text goes in the tool message. The images go in one user message after the tool results. If a result has only images, its tool text is `(see attached image)`.
+Pick an image, and the model describes it in the chat.
-When these adapters know that the model has no image input, they do not send the images. An adapter that does not know the input types of the model sends the images. A result with only images then has the text `(image omitted: this model has no image input)`.
+## How it works
-The Chat Completions adapters and `openRouterResponsesText` send only the text and the images of a result that has images. They do not send audio, video, or documents from it.
+- `content` is a string or an array of parts. A string is the same as one text part.
+- Each part has a `type`: `text`, `image`, `audio`, `video`, or `document`.
+- Each media part has a `source` that says where the bytes are. See [Source types](#source-types).
+- The adapter turns each part into the format of its provider.
+- The types of `chat()` allow only the input types that the selected model supports.
-When an adapter sends an image, a `file` source is an error, except on `openaiText`. Use a `data` or `url` source.
+## Advanced
-## Provider Support
+### Content parts
-### OpenAI
-
-OpenAI supports images, audio, and PDF documents in their vision, audio, and
-document-capable models:
+The part types come from `@tanstack/ai`:
```typescript
-import { openaiText } from '@tanstack/ai-openai'
-import { imageBase64, pdfBase64 } from './data'
+import type { ImagePart, TextPart } from "@tanstack/ai";
-const adapter = openaiText('gpt-5.5')
+const textPart: TextPart = {
+ type: "text",
+ content: "What do you see in this image?",
+};
-// Image with detail level metadata
-const message = {
- role: 'user' ,
- content: [
- { type: 'text' , content: 'Describe this image' },
- {
- type: 'image' ,
- source: { type: 'data' , value: imageBase64, mimeType: 'image/jpeg' },
- metadata: { detail: 'high' } // 'auto' | 'low' | 'high'
- }
- ]
-}
-```
+// Image from base64 data. `mimeType` is required for data sources.
+const imagePart: ImagePart = {
+ type: "image",
+ source: {
+ type: "data",
+ value: "base64EncodedImageData...",
+ mimeType: "image/jpeg",
+ },
+ metadata: {
+ detail: "high", // OpenAI detail level
+ },
+};
-```typescript
-import { pdfBase64 } from './data'
-
-// PDF document via base64 data (the API requires a filename alongside
-// inline data; defaults to "document.pdf" when omitted)
-const documentMessage = {
- role: 'user',
- content: [
- { type: 'text', content: 'Summarize this document' },
- {
- type: 'document',
- source: { type: 'data', value: pdfBase64, mimeType: 'application/pdf' },
- metadata: { filename: 'report.pdf' }
- }
- ]
-}
+// Image from a URL. `mimeType` is optional for URL sources.
+const imageUrlPart: ImagePart = {
+ type: "image",
+ source: {
+ type: "url",
+ value: "https://example.com/image.jpg",
+ mimeType: "image/jpeg",
+ },
+};
```
-**Supported modalities by model:**
-- `gpt-5.5`, `gpt-5.2`, `gpt-5-mini` (among others): text, image, PDF document
-- `gpt-4o-audio`: text, audio
-
-Check each model's `supports.input` in `@tanstack/ai-openai`'s `model-meta.ts` for the authoritative per-model list.
-
-### Anthropic
-
-Anthropic's Claude models support images and PDF documents:
+All the part types:
```typescript
-import { anthropicText } from '@tanstack/ai-anthropic'
-import { imageBase64, pdfBase64 } from './data'
-
-const adapter = anthropicText('claude-sonnet-4-6')
-
-// Image with mimeType in source
-const imageMessage = {
- role: 'user' ,
- content: [
- { type: 'text' , content: 'What do you see?' },
- {
- type: 'image' ,
- source: { type: 'data' , value: imageBase64, mimeType: 'image/jpeg' }
- }
- ]
-}
+import type {
+ AudioPart,
+ ContentPart,
+ DocumentPart,
+ ImagePart,
+ TextPart,
+ VideoPart,
+} from "@tanstack/ai";
-// PDF document
-const docMessage = {
- role: 'user',
- content: [
- { type: 'text', content: 'Summarize this document' },
- {
- type: 'document',
- source: { type: 'data', value: pdfBase64, mimeType: 'application/pdf' }
- }
- ]
-}
+// Metadata types for each provider
+import type { OpenAIImageMetadata } from "@tanstack/ai-openai";
+import type { AnthropicImageMetadata } from "@tanstack/ai-anthropic";
+import type { GeminiImageMetadata } from "@tanstack/ai-gemini";
```
-**Supported modalities:**
-- All Claude models (e.g. `claude-haiku-4-5`, `claude-sonnet-5`, `claude-opus-4-8`, `claude-fable-5`): text, image, and document (PDF)
-
-Check each model's `supports.input` in `@tanstack/ai-anthropic`'s `model-meta.ts` for the authoritative per-model list.
-
-### Gemini
-
-Google's Gemini models support a wide range of modalities:
+On the server, you can build the same message in a `chat()` call:
```typescript
-import { geminiText } from '@tanstack/ai-gemini'
-import { imageBase64 } from './data'
-
-const adapter = geminiText('gemini-3-flash-preview')
-
-// Image with mimeType in source
-const message = {
- role: 'user',
- content: [
- { type: 'text', content: 'Analyze this image' },
- {
- type: 'image',
- source: { type: 'data', value: imageBase64, mimeType: 'image/png' }
- }
- ]
-}
-```
-
-**Supported modalities:**
-- `gemini-2.5-flash`: text, image, audio, video
+import { chat } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
-### Ollama
-
-Ollama supports images in compatible models:
-
-```typescript
-import { ollamaText } from '@tanstack/ai-ollama'
-import { imageBase64 } from './data'
-
-// `ollamaText(model)` takes a model name. The host is read from the
-// `OLLAMA_HOST` environment variable (defaults to http://localhost:11434).
-const adapter = ollamaText('llama3.2-vision')
-
-// Image as base64
-const message = {
- role: 'user',
- content: [
- { type: 'text', content: 'What is in this image?' },
+const stream = chat({
+ adapter: openaiText("gpt-6.1-sol"),
+ messages: [
{
- type: 'image',
- source: { type: 'data', value: imageBase64, mimeType: 'image/jpeg' }
- }
- ]
-}
+ role: "user",
+ content: [
+ { type: "text", content: "What is in this image?" },
+ {
+ type: "image",
+ source: { type: "url", value: "https://example.com/photo.jpg" },
+ },
+ ],
+ },
+ ],
+ threadId: "thread-1",
+});
```
-**Note:** Ollama support varies by model. Check the specific model documentation for multimodal capabilities.
-
-## Source Types
+### Source types
-Content can be provided as either inline data or a URL:
+Each media part has one of three sources.
-### Data (Base64)
+#### Data (base64)
-Use `type: 'data'` for inline base64-encoded content. **The `mimeType` field is required** to ensure providers receive proper content type information:
+Use `type: 'data'` for inline base64 bytes. The `mimeType` field is required, so the provider gets the correct content type:
```typescript
-const imagePart = {
- type: 'image',
+import type { AudioPart, ImagePart } from "@tanstack/ai";
+
+const imagePart: ImagePart = {
+ type: "image",
source: {
- type: 'data',
- value: 'iVBORw0KGgoAAAANSUhEUgAAAAUA...', // Base64 string
- mimeType: 'image/png' // Required for data sources
- }
-}
+ type: "data",
+ value: "iVBORw0KGgoAAAANSUhEUgAAAAUA...",
+ mimeType: "image/png",
+ },
+};
-const audioPart = {
- type: 'audio',
+const audioPart: AudioPart = {
+ type: "audio",
source: {
- type: 'data',
- value: 'base64AudioData...',
- mimeType: 'audio/mp3' // Required for data sources
- }
-}
+ type: "data",
+ value: "base64AudioData...",
+ mimeType: "audio/mp3",
+ },
+};
```
-### URL
+Data is good for small files, or when the file is not on a public URL.
-Use `type: 'url'` for content hosted at a URL. The `mimeType` field is **optional** as providers can often infer it from the URL or response headers:
+#### URL
+
+Use `type: 'url'` for a file at a URL. The `mimeType` field is optional, because the provider can often find the type from the URL or the response headers:
```typescript
-const imagePart = {
- type: 'image' ,
+import type { ImagePart } from "@tanstack/ai";
+
+const imagePart: ImagePart = {
+ type: "image",
source: {
- type: 'url' ,
- value: 'https://example.com/image.jpg',
- mimeType: 'image/jpeg' // Optional hint
- }
-}
+ type: "url",
+ value: "https://example.com/image.jpg",
+ mimeType: "image/jpeg",
+ },
+};
```
-**Note:** Not all providers support URL-based content for all modalities. Check provider documentation for specifics.
+A URL is good for large files that are already online. Not all providers accept a URL for all input types. Read the provider docs.
-### File Handle (Files API)
+#### File handle (Files API)
-Use `type: 'file'` to reference media you uploaded once via a provider's [Files API](./files-api.md) — the provider stores the bytes and you pass a lightweight reference instead of re-sending base64 or a public URL every request. The source carries the opaque handle and the provider that issued it (`{ type: 'file', value: 'file-…', provider: 'openai' }`). An adapter throws if a different provider issued the handle, and adapters without Files API support reject the source before any request is built.
+Use `type: 'file'` for a file that you uploaded one time with the [Files API](./files-api) of a provider. The provider keeps the bytes, and each request sends a small reference. The source holds the opaque handle and the provider that issued it: `{ type: 'file', value: 'file-...', provider: 'openai' }`.
```typescript
-import { openaiFiles, openaiText } from '@tanstack/ai-openai'
-import { chat, fileSourceFromHandle, uploadFile } from '@tanstack/ai'
-import { pdfBase64 } from './pdf-data'
+import { chat, fileSourceFromHandle, uploadFile } from "@tanstack/ai";
+import { openaiFiles, openaiText } from "@tanstack/ai-openai";
+import { pdfBase64 } from "./pdf-data";
-// Upload once...
+// Upload one time.
const handle = await uploadFile({
adapter: openaiFiles(),
- input: { data: pdfBase64, mimeType: 'application/pdf' },
-})
+ input: { data: pdfBase64, mimeType: "application/pdf" },
+});
-// ...then reference the handle by id in as many requests as you like.
-for await (const chunk of chat({
- adapter: openaiText('gpt-5.5'),
+// Then use the handle in as many requests as you want.
+const stream = chat({
+ adapter: openaiText("gpt-5.5"),
messages: [
{
- role: 'user',
+ role: "user",
content: [
- { type: 'text', content: 'Summarize this document' },
- { type: 'document', source: fileSourceFromHandle(handle) },
+ { type: "text", content: "Summarize this document" },
+ { type: "document", source: fileSourceFromHandle(handle) },
],
},
],
-})) {
- // ...
-}
+ threadId: "thread-1",
+});
```
-`fileSourceFromHandle(handle)` builds the `{ type: 'file', value, provider }` source for you. It picks the handle URL for Gemini, fal, and Grok, or the opaque id for OpenAI and Anthropic. Each adapter maps `value` to the provider's native field (`file_id`, `fileData.fileUri`, or a URL). Sending the source to a different provider, or to an endpoint that requires raw bytes (image edits, Veo), throws a clear error. See [Files API](./files-api.md) for uploading, retrieving, and deleting handles.
+What `fileSourceFromHandle(handle)` does:
-## Backward Compatibility
+- It builds the `{ type: 'file', value, provider }` source.
+- It picks the handle URL for Gemini, fal, and Grok, and the opaque id for OpenAI and Anthropic.
+- Each adapter maps `value` to the native field of the provider (`file_id`, `fileData.fileUri`, or a URL).
-String content continues to work as before:
+These cases throw an error:
-```typescript
-// This still works
-const message = {
- role: 'user',
- content: 'Hello, world!'
-}
+- A handle that you send to a different provider.
+- A handle that you send to an adapter with no Files API support. The adapter rejects it before it builds the request.
+- A handle that you send to an endpoint that needs the bytes, such as image edits or Veo.
-// And this works for multimodal
-const multimodalMessage = {
- role: 'user',
- content: [
- { type: 'text', content: 'Hello, world!' },
- { type: 'image', source: { type: 'url', value: '...' } }
- ]
-}
-```
+See [Files API](./files-api) to upload, get, and delete handles.
-## Type Safety
+### Images in tool results
-The multimodal types are fully typed. Provider-specific metadata types are available:
+A tool can return an array of `ContentPart`. Use it to give the model a screenshot or a chart:
```typescript
-import type {
- ContentPart,
- ImagePart,
- DocumentPart,
- AudioPart,
- VideoPart,
- TextPart
-} from '@tanstack/ai'
+import { toolDefinition } from "@tanstack/ai";
+import type { ContentPart } from "@tanstack/ai";
+import { z } from "zod";
-// Provider-specific metadata types
-import type { OpenAIImageMetadata } from '@tanstack/ai-openai'
-import type { AnthropicImageMetadata } from '@tanstack/ai-anthropic'
-import type { GeminiImageMetadata } from '@tanstack/ai-gemini'
+const getScreenshot = toolDefinition({
+ name: "get_screenshot",
+ description: "Get a screenshot of the page",
+ inputSchema: z.object({}),
+}).server(async (): Promise> => [
+ { type: "text", content: "Page screenshot" },
+ {
+ type: "image",
+ source: { type: "data", value: "iVBORw0KGgo...", mimeType: "image/png" },
+ },
+]);
```
-### Validating Dynamic Messages
-
-When receiving messages from external sources (like `request.json()`), the data is typed as `any`. TanStack AI does not ship a runtime message validator — define a schema with your preferred Standard-Schema library (Zod, Valibot, ArkType, …) and parse the body before handing it to `chat()`.
+The adapter sends the image in the form that its API accepts:
-```typescript ignore
-import { chat } from '@tanstack/ai'
-import { openaiText } from '@tanstack/ai-openai'
-import { z } from 'zod'
+- **Responses APIs** (`openaiText`, `openRouterResponsesText`): the image stays in the tool output.
+- **Chat Completions APIs** (`openRouterText`, `openaiChatCompletions`, and the other Chat Completions adapters built on `@tanstack/openai-base`): a tool message holds text only. The text goes in the tool message. The images go in one user message after the tool results. If a result has only images, its tool text is `(see attached image)`.
-const ContentPartSchema = z.discriminatedUnion('type', [
- z.object({ type: z.literal('text'), content: z.string() }),
- z.object({
- type: z.literal('image'),
- source: z.object({ type: z.enum(['url', 'data']), value: z.string() }),
- }),
-])
+Some more rules:
-const MessageSchema = z.object({
- // `ModelMessage.role` is 'user' | 'assistant' | 'tool' — there is no
- // 'system' role. System instructions are passed separately via the
- // `systemPrompts` option on `chat()`, not as messages.
- role: z.enum(['user', 'assistant', 'tool']),
- content: z.union([z.string(), z.array(ContentPartSchema)]),
-})
+- If these adapters know that the model has no image input, they do not send the images.
+- An adapter that does not know the input types of the model sends the images. A result with only images then has the text `(image omitted: this model has no image input)`.
+- The Chat Completions adapters and `openRouterResponsesText` send only the text and the images of a result that has images. They do not send audio, video, or documents from it.
+- When an adapter sends an image from a tool result, a `file` source is an error, except on `openaiText`. Use a `data` or `url` source.
-const BodySchema = z.object({ messages: z.array(MessageSchema) })
+### Provider support
-// In an API route handler
-const { messages } = BodySchema.parse(await request.json())
+| Provider | Adapter | Input besides text |
+| --- | --- | --- |
+| OpenAI | `openaiText` | Image on `gpt-6.1-sol` and most new models. Image and PDF document on `gpt-5.5`. Audio on `gpt-4o-audio`. |
+| Anthropic | `anthropicText` | Image and PDF document on all Claude models, for example `claude-sonnet-5-5`. |
+| Gemini | `geminiText` | Image, audio, video, and document, for example on `gemini-3.8-flash`. |
+| Ollama | `ollamaText` | Image on vision models, such as `llama3.2-vision`. Support changes by model. |
-const stream = chat({
- adapter: openaiText('gpt-5.5'),
- messages,
-})
-```
+For the full list of each model, read `supports.input` in the `model-meta.ts` file of the adapter package.
-The TypeScript types on `chat()` still constrain anything you append at the call site to the modalities supported by the selected model.
+`ollamaText(model)` reads the host from the `OLLAMA_HOST` environment variable. The default is `http://localhost:11434`.
-## Best Practices
+#### OpenAI image detail and PDF filename
-1. **Use appropriate source type**: Use `data` for small content or when you need to include content inline. Use `url` for large files or when the content is already hosted.
+OpenAI reads two metadata fields:
-2. **Include metadata**: Provide relevant metadata (like `mimeType` or `detail`) to help the model process the content correctly.
+- `detail` on an image: `'auto'`, `'low'`, or `'high'`.
+- `filename` on a document. The API needs a filename with inline data. The default is `document.pdf`.
-3. **Check model support**: Not all models support all modalities. Verify the model you're using supports the content types you want to send.
+```typescript
+import { chat } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
+import { imageBase64, pdfBase64 } from "./data";
-4. **Handle errors gracefully**: When a model doesn't support a particular modality, it may throw an error. Handle these cases in your application.
+const stream = chat({
+ adapter: openaiText("gpt-5.5"),
+ messages: [
+ {
+ role: "user",
+ content: [
+ { type: "text", content: "Does the chart match the report?" },
+ {
+ type: "image",
+ source: { type: "data", value: imageBase64, mimeType: "image/jpeg" },
+ metadata: { detail: "high" },
+ },
+ {
+ type: "document",
+ source: { type: "data", value: pdfBase64, mimeType: "application/pdf" },
+ metadata: { filename: "report.pdf" },
+ },
+ ],
+ },
+ ],
+ threadId: "thread-1",
+});
+```
-## Client-Side Multimodal Messages
+### Send any file type from the browser
-When using the `ChatClient` from `@tanstack/ai-client`, you can send multimodal messages directly from your UI using the `sendMessage` method.
+This input accepts images, audio, video, and PDFs. It picks the part type from the MIME type of the file. It uses `readAsBase64` from the [quick setup](#2-send-the-image-from-the-browser):
-### Basic Usage
+```tsx
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
+import { readAsBase64 } from "./read-as-base64";
-The `sendMessage` method accepts either a simple string or a `MultimodalContent` object:
+export function ChatWithFileUpload() {
+ const { sendMessage } = useChat({
+ connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
+ });
-```typescript
-import { ChatClient, fetchServerSentEvents } from '@tanstack/ai-client'
+ async function handleFileUpload(file: File) {
+ const base64 = await readAsBase64(file);
-const client = new ChatClient({
- connection: fetchServerSentEvents('/api/chat'),
-})
+ const type = file.type.startsWith("image/")
+ ? "image"
+ : file.type.startsWith("audio/")
+ ? "audio"
+ : file.type.startsWith("video/")
+ ? "video"
+ : "document";
-// Simple text message
-await client.sendMessage('Hello!')
+ await sendMessage({
+ content: [
+ { type: "text", content: `Please analyze this ${type}` },
+ {
+ type,
+ source: { type: "data", value: base64, mimeType: file.type },
+ },
+ ],
+ });
+ }
-// Multimodal message with image
-await client.sendMessage({
- content: [
- { type: 'text', content: 'What is in this image?' },
- {
- type: 'image',
- source: { type: 'url', value: 'https://example.com/photo.jpg' }
- }
- ]
-})
+ return (
+ {
+ const file = event.target.files?.[0];
+ if (file) void handleFileUpload(file);
+ }}
+ />
+ );
+}
```
-### Custom Message ID
+The model on the server must accept each type that you allow here.
-You can provide a custom ID for the message:
+### Message ID and per-message props
+
+`sendMessage` takes a string or a `MultimodalContent` object. These examples use `ChatClient` from `@tanstack/ai-client`. The `sendMessage` of `useChat` takes the same first argument. For per-message props, it takes `{ body }` as the second argument.
+
+Give the message your own ID:
```typescript
-import { ChatClient, fetchServerSentEvents } from '@tanstack/ai-client'
+import { ChatClient, fetchServerSentEvents } from "@tanstack/ai-client";
const client = new ChatClient({
- connection: fetchServerSentEvents('/api/chat'),
-})
+ connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
+});
await client.sendMessage({
- content: 'Hello!',
- id: 'custom-message-id-123'
-})
+ content: "Hello!",
+ id: "custom-message-id-123",
+});
```
-### Per-Message Forwarded Props
-
-The second parameter allows you to pass additional `forwardedProps` for that specific request. These are shallow-merged with the client's base `forwardedProps` configuration, with per-message values taking priority:
+The second argument adds `forwardedProps` for that one request. They are shallow-merged with the base `forwardedProps` of the client, and the values of the message win:
```typescript
-import { ChatClient, fetchServerSentEvents } from '@tanstack/ai-client'
+import { ChatClient, fetchServerSentEvents } from "@tanstack/ai-client";
const client = new ChatClient({
- connection: fetchServerSentEvents('/api/chat'),
- forwardedProps: { model: 'gpt-5' }, // Base forwarded props
-})
+ connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
+ forwardedProps: { model: "gpt-6.1-sol" },
+});
-// Override model for this specific message
-await client.sendMessage('Analyze this complex problem', {
- model: 'gpt-5',
+await client.sendMessage("Analyze this complex problem", {
temperature: 0.2,
-})
+});
```
-> **Note:** The legacy `body` constructor option is still supported but deprecated. New code should use `forwardedProps`. Both populate the same wire field.
+### Validate messages from a request
-### React Example
+`request.json()` returns `any`. TanStack AI does not ship a runtime message validator. Define a schema with a Standard Schema library (Zod, Valibot, ArkType, and more), and parse the body before you give it to `chat()`:
-Here's how to use multimodal messages in a React component:
-
-```tsx
-import { useChat } from '@tanstack/ai-react'
-import { fetchServerSentEvents } from '@tanstack/ai-client'
-import { useState } from 'react'
-
-function ChatWithImages() {
- const [imageUrl, setImageUrl] = useState('')
- const { sendMessage, messages } = useChat({
- connection: fetchServerSentEvents('/api/chat'),
- })
-
- const handleSendWithImage = () => {
- if (imageUrl) {
- sendMessage({
- content: [
- { type: 'text', content: 'What do you see in this image?' },
- { type: 'image', source: { type: 'url', value: imageUrl } }
- ]
- })
- }
- }
-
- return (
-
- setImageUrl(e.target.value)}
- />
- Send with Image
-
- )
-}
-```
-
-### File Upload Example
+```typescript ignore
+import { chat } from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
+import { z } from "zod";
-Here's how to handle file uploads and send them as multimodal content:
+const ContentPartSchema = z.discriminatedUnion("type", [
+ z.object({ type: z.literal("text"), content: z.string() }),
+ z.object({
+ type: z.literal("image"),
+ source: z.object({ type: z.enum(["url", "data"]), value: z.string() }),
+ }),
+]);
-```tsx
-import { useChat } from '@tanstack/ai-react'
-import { fetchServerSentEvents } from '@tanstack/ai-client'
+const MessageSchema = z.object({
+ // `ModelMessage.role` is 'user' | 'assistant' | 'tool'. There is no
+ // 'system' role. Pass system instructions with the `systemPrompts`
+ // option of `chat()`.
+ role: z.enum(["user", "assistant", "tool"]),
+ content: z.union([z.string(), z.array(ContentPartSchema)]),
+});
-function ChatWithFileUpload() {
- const { sendMessage } = useChat({
- connection: fetchServerSentEvents('/api/chat'),
- })
-
- const handleFileUpload = async (file: File) => {
- // Convert file to base64
- const base64 = await new Promise((resolve) => {
- const reader = new FileReader()
- reader.onload = () => {
- const result = reader.result as string
- // Remove data URL prefix (e.g., "data:image/png;base64,")
- resolve(result.split(',')[1]!)
- }
- reader.readAsDataURL(file)
- })
-
- // Determine content type based on file type
- const type = file.type.startsWith('image/')
- ? 'image'
- : file.type.startsWith('audio/')
- ? 'audio'
- : file.type.startsWith('video/')
- ? 'video'
- : 'document'
+const BodySchema = z.object({
+ messages: z.array(MessageSchema),
+ threadId: z.string(),
+});
- await sendMessage({
- content: [
- { type: 'text', content: `Please analyze this ${type}` },
- {
- type,
- source: { type: 'data', value: base64, mimeType: file.type }
- }
- ]
- })
- }
+// In an API route handler
+const { messages, threadId } = BodySchema.parse(await request.json());
- return (
- {
- const file = e.target.files?.[0]
- if (file) handleFileUpload(file)
- }}
- />
- )
-}
+const stream = chat({
+ adapter: openaiText("gpt-6.1-sol"),
+ messages,
+ threadId,
+});
```
+The types of `chat()` still limit anything that you add at the call site to the input types of the selected model.
diff --git a/docs/advanced/otel.md b/docs/advanced/otel.md
index 132572ce37..300260b2c5 100644
--- a/docs/advanced/otel.md
+++ b/docs/advanced/otel.md
@@ -2,7 +2,7 @@
title: OpenTelemetry
id: otel
order: 3
-description: "Emit vendor-neutral OpenTelemetry traces and metrics from every TanStack AI chat() call, following the OTel GenAI semantic conventions."
+description: "Send OpenTelemetry traces and metrics from every TanStack AI chat() call and media call. The spans follow the OTel GenAI semantic conventions."
keywords:
- tanstack ai
- opentelemetry
@@ -14,13 +14,15 @@ keywords:
- semantic conventions
---
-The `otelMiddleware` factory wires TanStack AI into your existing OpenTelemetry setup. Every `chat()` call produces a root span, one child span per provider model call (agent-loop turn **or** structured-output finalization), and one grandchild span per tool call — all with [GenAI semantic-convention attributes](https://opentelemetry.io/docs/specs/semconv/gen-ai/). It also records GenAI token and duration histograms when a `Meter` is provided.
+The OpenTelemetry middleware records the model, token counts, and tool time on your existing traces.
-Structured-output calls with no tools skip the agent loop and only run the finalization request. That path still opens an iteration span (via the `structuredOutput` middleware phase) so backends that key off generation spans (e.g. PostHog `$ai_generation`) and `captureContent` both work. Native combined mode (`supportsCombinedToolsAndSchema`) does not fire that phase — the single `beforeModel` span covers the combined call.
+Add `otelMiddleware` to your `chat()` call. Each call then sends spans and metrics with the [GenAI semantic-convention attributes](https://opentelemetry.io/docs/specs/semconv/gen-ai/).
-## Setup
+## Quick setup
-Install `@opentelemetry/api` — it's an optional peer dependency of `@tanstack/ai`:
+### 1. Install the OpenTelemetry API
+
+`@opentelemetry/api` is an optional peer dependency of `@tanstack/ai`:
@@ -35,7 +37,11 @@ octane: @opentelemetry/api
-Wire up your OTel SDK however you already do (e.g. `@opentelemetry/sdk-node`). Then pass a `Tracer` (and optionally a `Meter`) into the middleware. The OTel middleware lives on its own subpath — importing it never affects users who don't need OTel:
+### 2. Start your OpenTelemetry SDK
+
+Start the SDK the way your app already does, for example with `@opentelemetry/sdk-node`. The middleware uses the `Tracer` and `Meter` that you give it.
+
+### 3. Add the middleware to `chat()`
```ts
import { chat } from '@tanstack/ai'
@@ -49,28 +55,87 @@ const otel = otelMiddleware({
})
const { text } = await chat({
- adapter: openaiText('gpt-5.5'),
+ adapter: openaiText('gpt-6.1-sol'),
messages: [{ role: 'user', content: 'hi' }],
+ threadId: 'thread-1',
middleware: [otel],
stream: false,
})
```
-## What gets emitted
+The middleware lives on its own subpath. Apps that do not import it do not need `@opentelemetry/api`.
-### Spans
+Open your trace viewer. Each `chat()` call now shows as one span tree, with the model, the token counts, and each tool call.
+
+## How it works
+
+Each `chat()` call makes a span tree:
```text
-chat gpt-5.5 (root, kind: INTERNAL)
-├── chat gpt-5.5 #0 (iteration, kind: CLIENT)
+chat gpt-6.1-sol (root, kind: INTERNAL)
+├── chat gpt-6.1-sol #0 (iteration, kind: CLIENT)
│ ├── execute_tool get_weather
│ └── execute_tool get_time
-└── chat gpt-5.5 #1 (iteration, kind: CLIENT)
+└── chat gpt-6.1-sol #1 (iteration, kind: CLIENT)
+```
+
+- The **root span** covers the whole `chat()` call.
+- Each **iteration span** is one model call: one turn of the agent loop, or the final structured-output request. The spans are numbered (`#0`, `#1`) in the order of the model calls.
+- Each **tool span** is one tool call.
+
+When you give a `Meter`, the middleware also records two token and duration histograms. See [Metrics](#metrics).
+
+By default, only metadata goes onto the spans. Prompts and answers stay off until you turn them on.
+
+## Capture prompts and completions
+
+Set `captureContent: true` to record prompt and answer content. Pass a `redact` function to remove personal data before anything is recorded:
+
+```ts
+import { otelMiddleware } from '@tanstack/ai/middlewares/otel'
+import { trace } from '@opentelemetry/api'
+
+const tracer = trace.getTracer('my-app')
+
+otelMiddleware({
+ tracer,
+ captureContent: true,
+ redact: (text) => text.replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[SSN]'),
+})
+```
+
+If `redact` throws, the middleware writes the text `"[redaction_failed]"` into the span event and logs a warning. It never falls back to the raw content. So a broken redactor stops the capture, and no prompt leaks to a third-party backend.
+
+For the event names, the length limit, and media parts, see [Content capture details](#content-capture-details).
+
+## Trace media calls
+
+The media activities accept the same `otelMiddleware` value on their `middleware` option:
+
+```ts
+import { generateImage } from '@tanstack/ai'
+import { otelMiddleware } from '@tanstack/ai/middlewares/otel'
+import { openaiImage } from '@tanstack/ai-openai'
+import { trace, metrics } from '@opentelemetry/api'
+
+const otel = otelMiddleware({
+ tracer: trace.getTracer('my-app'),
+ meter: metrics.getMeter('my-app'),
+})
+
+const result = await generateImage({
+ adapter: openaiImage('gpt-image-2'),
+ prompt: 'A serene mountain landscape at sunset',
+ threadId: 'thread-1',
+ middleware: [otel],
+})
```
-Iteration spans are numbered (`#0`, `#1`, ...) in the order model calls are observed, so distinct provider round-trips of the same chat are easy to pick apart in trace viewers.
+A media call is one request and one response (or a submit and a poll, for video). So it makes one `CLIENT` span, not a span tree. You can pass the same `otel` value to `chat()`, `decide()`, and any media activity.
-### Attribute reference
+## Advanced
+
+### Span attributes
| Level | Attribute | Value |
| --- | --- | --- |
@@ -83,83 +148,82 @@ Iteration spans are numbered (`#0`, `#1`, ...) in the order model calls are obse
| iteration | `gen_ai.request.max_tokens` | from config |
| iteration | `gen_ai.usage.input_tokens` | per iteration |
| iteration | `gen_ai.usage.output_tokens` | per iteration |
-| root / iteration | `gen_ai.usage.total_tokens` | provider-reported total |
-| root / iteration | `gen_ai.usage.cost` | provider-reported cost, when available |
+| root / iteration | `gen_ai.usage.total_tokens` | total from the provider |
+| root / iteration | `gen_ai.usage.cost` | cost from the provider, when available |
| root / iteration | `gen_ai.usage.cache_read.input_tokens` | cached prompt tokens, when reported |
| root / iteration | `gen_ai.usage.cache_creation.input_tokens` | cache-write prompt tokens, when reported |
-| root / iteration | `gen_ai.usage.reasoning.output_tokens` | reasoning/thinking tokens, when reported |
-| root / iteration | `tanstack.ai.usage.billed_quantity` | non-token billed quantity, when reported |
+| root / iteration | `gen_ai.usage.reasoning.output_tokens` | reasoning or thinking tokens, when reported |
+| root / iteration | `tanstack.ai.usage.billed_quantity` | billed quantity that is not tokens, when reported |
| root / iteration | `tanstack.ai.usage.billed_unit` | unit of the billed quantity (`seconds`, `units`, ...) |
-| root / iteration | `tanstack.ai.usage.duration_seconds` | deprecated duration count; read `billed_quantity`/`billed_unit` instead |
-| root / iteration | `tanstack.ai.usage.units_billed` | deprecated bare unit count; read `billed_quantity`/`billed_unit` instead |
-| root / iteration | `tanstack.ai.usage.upstream_cost` | gateway upstream cost (e.g. OpenRouter), when reported |
-| root / iteration | `tanstack.ai.usage.upstream_input_cost` | upstream input cost split, when reported |
-| root / iteration | `tanstack.ai.usage.upstream_output_cost` | upstream output cost split, when reported |
+| root / iteration | `tanstack.ai.usage.upstream_cost` | upstream cost of a gateway (for example OpenRouter), when reported |
+| root / iteration | `tanstack.ai.usage.upstream_input_cost` | upstream input cost, when reported |
+| root / iteration | `tanstack.ai.usage.upstream_output_cost` | upstream output cost, when reported |
| iteration | `gen_ai.response.finish_reasons` | `[stop]`, `[tool_calls]`, ... |
-| root | `gen_ai.usage.input_tokens` | rolled up |
-| root | `gen_ai.usage.output_tokens` | rolled up |
+| root | `gen_ai.usage.input_tokens` | sum of all iterations |
+| root | `gen_ai.usage.output_tokens` | sum of all iterations |
| root | `tanstack.ai.iterations` | iteration count |
| tool | `gen_ai.tool.name` | tool name |
| tool | `gen_ai.tool.call.id` | tool call id |
| tool | `gen_ai.tool.type` | `function` |
| tool | `tanstack.ai.tool.outcome` | `success` / `error` |
-Usage attributes beyond input/output tokens are emitted only when the provider reports them, so spans stay clean otherwise. Cache and reasoning breakdowns use the official GenAI semconv names; `gen_ai.usage.cost` and `gen_ai.usage.total_tokens` are de-facto extensions consumed directly by backends like PostHog — without them, backends re-derive cost from their own price tables and lose cache discounts and gateway markup. Fields with no established convention (the billed quantity/unit pair, the upstream cost split, and the deprecated bare counts) are TanStack-namespaced.
+How the usage attributes work:
-For non-token billing (seconds of video or transcription, fal's endpoint units, ...), `tanstack.ai.usage.billed_quantity` and `tanstack.ai.usage.billed_unit` are emitted as a pair from `usage.billed`, so backends can label and aggregate media usage without knowing the provider. The deprecated `duration_seconds` / `units_billed` attributes carry the same quantities without the unit and remain emitted for backward compatibility.
+- Usage attributes other than input and output tokens appear only when the provider reports them.
+- The cache and reasoning attributes use the official GenAI semconv names.
+- `gen_ai.usage.cost` and `gen_ai.usage.total_tokens` are common extensions that backends like PostHog read. Without them, a backend calculates the cost from its own price table, and loses cache discounts and gateway markup.
+- Fields with no standard name (the billed quantity and unit, and the upstream cost split) use the `tanstack.ai` namespace.
-### Metrics
+For billing that is not in tokens (seconds of video or transcription, fal endpoint units), the middleware sends `tanstack.ai.usage.billed_quantity` and `tanstack.ai.usage.billed_unit` as a pair from `usage.billed`. Backends can then label and add up media usage without knowing the provider.
-Two GenAI-standard histograms:
+### Structured output spans
-- `gen_ai.client.operation.duration` (seconds) — recorded **once per `chat()` call**, covering all agent-loop iterations and tool execution. On error or abort the record carries an `error.type` attribute (the thrown error's `name`, or `"cancelled"` for aborts).
-- `gen_ai.client.token.usage` (tokens) — recorded **once per iteration** (two records: input and output), tagged with `gen_ai.token.type`.
+A structured-output call with no tools skips the agent loop and runs only the final request. That path still opens an iteration span, through the `structuredOutput` middleware phase. So backends that read generation spans (for example PostHog `$ai_generation`) and `captureContent` both work.
-Both `gen_ai.response.id` and `gen_ai.response.model` are deliberately excluded from metric attributes to keep cardinality low (per-request custom-model names and request IDs would blow up the series set).
+Native combined mode (`supportsCombinedToolsAndSchema`) does not run that phase. The single `beforeModel` span covers the combined call.
-## Privacy: capturing prompts and completions
+### Metrics
-By default, only metadata lands on spans. To record prompt and completion content, set `captureContent: true`. Content is captured as OTel span events following the GenAI convention:
+When you give a `Meter`, the middleware records two GenAI-standard histograms:
-- `gen_ai.user.message`, `gen_ai.system.message`, `gen_ai.assistant.message`, `gen_ai.tool.message`, `gen_ai.choice`
+- `gen_ai.client.operation.duration` (seconds): recorded **once for each `chat()` call**. It covers all agent-loop iterations and tool calls. On an error or an abort, the record has an `error.type` attribute: the `name` of the thrown error, or `"cancelled"` for an abort.
+- `gen_ai.client.token.usage` (tokens): recorded **once for each iteration**, as two records (input and output), tagged with `gen_ai.token.type`.
-Pass a `redact` function to strip PII before anything is recorded:
+The metric attributes do not include `gen_ai.response.id` and `gen_ai.response.model`. This keeps the number of series low, because request ids and custom model names change on each request.
-```ts
-import { otelMiddleware } from '@tanstack/ai/middlewares/otel'
-import { trace } from '@opentelemetry/api'
+### Content capture details
-const tracer = trace.getTracer('my-app')
+With `captureContent: true`, content goes onto the spans as OTel span events, with the GenAI names:
-otelMiddleware({
- tracer,
- captureContent: true,
- redact: (text) => text.replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[SSN]'),
-})
-```
+- `gen_ai.user.message`, `gen_ai.system.message`, `gen_ai.assistant.message`
+- `gen_ai.tool.message`, `gen_ai.choice`
-If `redact` throws, the middleware writes the literal sentinel `"[redaction_failed]"` into the span event and logs a warning — it never falls back to the raw content. This is the load-bearing invariant for users who ship traces to third-party backends: a broken redactor should shut off capture, not leak prompts.
+Limits and timing:
-Accumulated assistant text (the `gen_ai.choice` event) is capped at `maxContentLength` characters (default `100 000`); longer completions are truncated with a trailing `"…"` marker.
+- The assistant text (the `gen_ai.choice` event) is limited to `maxContentLength` characters. The default is 100,000. Longer text is cut and ends with `"…"`. Set `maxContentLength: 0` to remove the limit.
+- The prompt, system, and user events fire from `onConfig` at the start of each iteration. So each iteration span has the full history again, the same as the provider gets it.
Multimodal messages (images, audio, video, documents) keep their parts in `gen_ai.input.messages`, in the OTel GenAI part shapes:
- Text: `{ "type": "text", "content": "..." }`. `redact` runs on it.
- URL source: `{ "type": "uri", "modality": "image", "uri": "https://...", "mime_type": "image/png" }`.
- Provider file handle: `{ "type": "file", "modality": "image", "file_id": "..." }`.
-- Inline base64 data or a `data:` URL: a `[image]` text placeholder, so the bytes do not go onto the span.
-
-Span events stay flat strings, with the same placeholders for every media part (`look at this [image]`).
+- Inline base64 data or a `data:` URL: a `[image]` text placeholder, so that the bytes do not go onto the span.
-Prompt/system/user message events fire from `onConfig` at the start of every iteration, which means the full conversation history (as the adapter will re-send it) is re-emitted on each iteration span. This mirrors what the provider actually sees on the wire.
+Span events stay flat strings, with the same placeholders for each media part (`look at this [image]`).
-## Extension points
+### Extension points
-All four extensions are optional. Each wraps user code in try/catch — a thrown callback becomes a log line, never a broken chat.
+All four callbacks are optional. Each one runs inside a try/catch. A callback that throws makes a log line, and the chat continues.
-### `spanNameFormatter(info)`
+| Callback | What it does |
+| --- | --- |
+| `spanNameFormatter(info)` | Returns the span name. `info.kind` is `'chat'`, `'iteration'`, `'tool'`, or `'generation'`. |
+| `attributeEnricher(info)` | Returns extra attributes for each span. Fires once for each span. |
+| `onBeforeSpanStart(info, options)` | Returns the `SpanOptions` for `tracer.startSpan(...)`. Use it to add links, custom start times, or default attributes. |
+| `onSpanEnd(info, span)` | Fires immediately before each `span.end()`. Use it to record custom events, or per-tool metrics with your own `Meter`. |
-Override default span names. `info.kind` is `'chat' | 'iteration' | 'tool'`.
+Change the span names:
```ts
import { otelMiddleware } from '@tanstack/ai/middlewares/otel'
@@ -174,9 +238,7 @@ otelMiddleware({
})
```
-### `attributeEnricher(info)`
-
-Add custom attributes to every span. Fires once per span.
+Add an attribute to each span:
```ts
import { otelMiddleware } from '@tanstack/ai/middlewares/otel'
@@ -193,13 +255,7 @@ otelMiddleware({
})
```
-### `onBeforeSpanStart(info, options)`
-
-Mutate `SpanOptions` immediately before `tracer.startSpan(...)`. Useful for adding links, custom start times, or extra default attributes.
-
-### `onSpanEnd(info, span)`
-
-Fires just before every `span.end()`. Common uses: record custom events, emit per-tool metrics via your own `Meter`.
+Record a metric for each tool call:
```ts
import { otelMiddleware } from '@tanstack/ai/middlewares/otel'
@@ -213,38 +269,22 @@ otelMiddleware({
tracer,
onSpanEnd: (info, span) => {
if (info.kind === 'tool') {
- // span is still recording; read timestamps from your own store if needed
+ // The span still records. Read timestamps from your own store if you need them.
toolDuration.record(1, { 'tool.name': info.toolName })
}
},
})
```
-## Beyond chat: media activities
-
-`otelMiddleware` is not chat-only. The media activities (`generateImage`, `generateVideo`, `generateLiveVideo`, `generateWorld`, `generateAudio`, `generateSpeech`, and `generateTranscription`) accept the **same** `otelMiddleware` value on their `middleware` option. Each is a single request to response (or submit then poll for video), so the middleware emits one span per call instead of the chat span tree:
-
-```ts
-import { generateImage } from '@tanstack/ai'
-import { otelMiddleware } from '@tanstack/ai/middlewares/otel'
-import { openaiImage } from '@tanstack/ai-openai'
-import { trace, metrics } from '@opentelemetry/api'
-
-const otel = otelMiddleware({
- tracer: trace.getTracer('my-app'),
- meter: metrics.getMeter('my-app'),
-})
+### Media spans
-const result = await generateImage({
- adapter: openaiImage('gpt-image-2'),
- prompt: 'A serene mountain landscape at sunset',
- middleware: [otel],
-})
-```
+These activities accept `otelMiddleware`:
-You can pass the same `otel` value to `chat()`, `decide()`, and any media activity. Its shared lifecycle hooks (`onStart` / `onUsage` / `onFinish` / `onAbort` / `onError`) are authored against the activity-agnostic `GenerationMiddlewareContext`, so the one instance works everywhere.
+- `generateImage`, `generateVideo`, and `generateLiveVideo`.
+- `generateWorld` and `generateAudio`.
+- `generateSpeech` and `generateTranscription`.
-Each media call produces one `CLIENT` span tagged with the activity's `gen_ai.operation.name`:
+Each media call makes one `CLIENT` span, tagged with the `gen_ai.operation.name` of the activity:
| Activity | `gen_ai.operation.name` |
| --- | --- |
@@ -258,22 +298,37 @@ Each media call produces one `CLIENT` span tagged with the activity's `gen_ai.op
| `summarize` | `summarize` |
| `decide` | `evaluate` |
-The span carries `gen_ai.system` and `gen_ai.request.model` at start and, on finish, the same `gen_ai.usage.*` / `tanstack.ai.usage.*` attributes documented above — including the `tanstack.ai.usage.billed_quantity` / `tanstack.ai.usage.billed_unit` pair for unit-billed media. When a `Meter` is supplied it records the `gen_ai.client.operation.duration` histogram, tagged per activity. For streaming video the span covers the full create → poll → complete lifecycle. Non-streaming video is two calls, so the submit itself emits no span — the run opens once the provider accepts the job, and the `getVideoJobStatus()` poll that observes a terminal state ends it. If a streaming video consumer abandons the stream before completion, the span is ended via `onAbort` (status `ERROR`, `tanstack.ai.completion.reason = cancelled`) rather than leaked.
+What the span has:
+
+- At the start: `gen_ai.system` and `gen_ai.request.model`.
+- At the end: the same `gen_ai.usage.*` and `tanstack.ai.usage.*` attributes as a chat span. This includes the `billed_quantity` and `billed_unit` pair for media that bills in units.
+- With a `Meter`: the `gen_ai.client.operation.duration` histogram, tagged for each activity.
+
+How video spans work:
-`otelMiddleware` applies the same `spanNameFormatter`, `attributeEnricher`, `onBeforeSpanStart`, and `onSpanEnd` extension points to media spans — the span info is discriminated by `kind`, where media spans report `kind: 'generation'`. For a custom backend, implement the base `GenerationMiddleware` contract directly; its hooks (`onStart` / `onUsage` / `onFinish` / `onAbort` / `onError`) receive the `GenerationMiddlewareContext` and fire for every activity, chat included. The `GenerationMiddleware` types are exported from the package root, while the `otelMiddleware` value lives on the `@tanstack/ai/middlewares/otel` subpath so importing `@tanstack/ai` never requires the optional `@opentelemetry/api` peer.
+- Streaming video: the span covers the full create, poll, and complete cycle.
+- Non-streaming video is two calls. The submit itself makes no span. The span opens when the provider accepts the job. The `getVideoJobStatus()` poll that sees a final state ends it.
+- If a consumer of a streaming video stops before the end, `onAbort` ends the span with status `ERROR` and `tanstack.ai.completion.reason = cancelled`. The span does not leak.
+
+The four extension points also apply to media spans. Media spans report `kind: 'generation'`.
+
+The shared lifecycle hooks (`onStart`, `onUsage`, `onFinish`, `onAbort`, `onError`) use the `GenerationMiddlewareContext`, which is the same for each activity. That is why one instance works everywhere. For a custom backend, implement the base `GenerationMiddleware` contract. Its hooks get the `GenerationMiddlewareContext` and fire for each activity, chat included.
+
+The `GenerationMiddleware` types come from the package root. The `otelMiddleware` value lives on the `@tanstack/ai/middlewares/otel` subpath, so an import of `@tanstack/ai` never needs the optional `@opentelemetry/api` peer.
### Capturing media content
-Set `captureContent: true` to record what a media call was asked for and what came back. The media span then gets the same attributes as a chat iteration span:
+Set `captureContent: true` to record what a media call asked for and what came back. The media span then gets the same attributes as a chat iteration span:
-- `gen_ai.input.messages` and `langfuse.observation.input`: one `user` message. It holds the prompt as a text part and each input image, video, or audio as a part. For `generateSpeech` it holds the text to speak. For `generateTranscription` it holds the source audio.
-- `gen_ai.output.messages` and `langfuse.observation.output`: one `assistant` message. It holds each generated image, audio, or video URL as a `uri` part. For `generateTranscription` it holds the transcript as a text part.
+- `gen_ai.input.messages` and `langfuse.observation.input`: one `user` message. It holds the prompt as a text part, and each input image, video, or audio as a part. For `generateSpeech`, it holds the text to speak. For `generateTranscription`, it holds the source audio.
+- `gen_ai.output.messages` and `langfuse.observation.output`: one `assistant` message. It holds each URL of a generated image, audio, or video as a `uri` part. For `generateTranscription`, it holds the transcript as a text part.
-Media parts use the part shapes from [Privacy: capturing prompts and completions](#privacy-capturing-prompts-and-completions). A URL becomes `{ "type": "uri", "modality": "image", "uri": "https://..." }`. Inline data never goes onto the span. Base64 output, a `data:` URL, a `File`, or a `Blob` becomes a text placeholder such as `[image]`. `redact` and `maxContentLength` apply to each text part.
+Media parts use the part shapes from [Content capture details](#content-capture-details):
-For non-streaming video, the `getVideoJobStatus()` span has the output URL but not the prompt. Use `generateVideo({ stream: true })` to get both on one span.
+- A URL becomes `{ "type": "uri", "modality": "image", "uri": "https://..." }`.
+- Inline data never goes onto the span. Base64 output, a `data:` URL, a `File`, or a `Blob` becomes a text placeholder such as `[image]`.
+- `redact` and `maxContentLength` apply to each text part.
-## Related
+For non-streaming video, the `getVideoJobStatus()` span has the output URL but not the prompt. Use `generateVideo({ stream: true })` to get both on one span.
-- [Middleware](./middleware) — the lifecycle this middleware hooks into
-- [Debug Logging](./debug-logging) — quick console-output diagnostics, complementary to OTel
\ No newline at end of file
+For quick console output while you develop, use [Debug Logging](./debug-logging). To learn the lifecycle that this middleware hooks into, see [Middleware](./middleware).
diff --git a/docs/advanced/per-model-type-safety.md b/docs/advanced/per-model-type-safety.md
index 6e9d92c41c..e55daa8475 100644
--- a/docs/advanced/per-model-type-safety.md
+++ b/docs/advanced/per-model-type-safety.md
@@ -2,7 +2,7 @@
title: Per-Model Type Safety
id: per-model-type-safety
order: 5
-description: "TanStack AI narrows modelOptions and content types to the specific model you select, enforcing capabilities at compile time."
+description: "TanStack AI narrows modelOptions and content types to the model you select. TypeScript rejects an option that the model does not support."
keywords:
- tanstack ai
- type safety
@@ -13,70 +13,80 @@ keywords:
- compile-time
---
-The AI SDK provides **model-specific type safety** for `modelOptions`. Each model's capabilities determine which model options are allowed, and TypeScript will enforce this at compile time.
+Provider options are typed from the model you pass to the adapter. An option that model does not support is a type error.
-> **Tip:** For structured outputs, most users should prefer the first-class `chat({ outputSchema })` option over the raw provider `text` option shown below — it works across providers and validates the result for you. The raw `text` option is for when you need provider-specific control.
+TanStack AI gives `modelOptions` the type of the exact model that you select. Your editor shows only the options that the model has, and TypeScript rejects the others.
-## How It Works
+## Quick setup
-Each adapter factory captures the model literal as a type parameter — `openaiText(model)` — so the adapter carries the exact model you selected at the type level.
-
-The `modelOptions` you pass are then resolved against a per-model map (`ResolveProviderOptions`). Each model's entry declares only the options that model actually supports. A model without a structured-output capability simply has no `text` property in its resolved options type, so TypeScript's excess-property checking rejects `text` for that model — at compile time, with zero runtime cost.
-
-This is the same mechanism described in [Typed Pre-Configured Options](./typed-options) (which captures these resolved options in a reusable object) and [Extend Adapter](./extend-adapter) (which lets you attach the same typed `modelOptions` to custom models).
-
-## Usage Examples
-
-### ✅ Correct Usage
+There is nothing to turn on. Pass the model to the adapter factory, then write `modelOptions`:
```typescript
-import { chat } from "@tanstack/ai";
-import { openaiText } from "@tanstack/ai-openai";
-
-// ✅ gpt-5 supports structured outputs - `text` is allowed
-const validCall = chat({
- adapter: openaiText("gpt-5"),
- messages: [],
+import { chat } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+
+// gpt-6.1-sol supports structured outputs, so `text` is allowed.
+const stream = chat({
+ adapter: openaiText('gpt-6.1-sol'),
+ messages: [{ role: 'user', content: 'Give me a short answer.' }],
+ threadId: 'thread-1',
modelOptions: {
- // OK - text is included for gpt-5
text: {
format: {
- type: "json_schema",
- name: "my_schema",
+ type: 'json_schema',
+ name: 'my_schema',
schema: {
- /* JSON Schema object */
+ type: 'object',
+ properties: { answer: { type: 'string' } },
+ required: ['answer'],
+ additionalProperties: false,
},
},
},
},
-});
+})
```
-### ❌ Incorrect Usage
+Now pick a model without structured outputs. The same option is a type error:
```typescript ignore
-import { chat } from "@tanstack/ai";
-import { openaiText } from "@tanstack/ai-openai";
-
-// ❌ gpt-4-turbo does NOT support structured outputs - `text` is rejected
-const invalidCall = chat({
- adapter: openaiText("gpt-4-turbo"),
- messages: [],
+import { chat } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+
+// gpt-4-turbo does not support structured outputs, so `text` is rejected.
+const stream = chat({
+ adapter: openaiText('gpt-4-turbo'),
+ messages: [{ role: 'user', content: 'Give me a short answer.' }],
+ threadId: 'thread-1',
modelOptions: {
- text: {}, // ❌ TypeScript error: 'text' does not exist in type
+ text: {}, // TypeScript error: 'text' does not exist in type
},
-});
+})
```
-TypeScript will produce:
+TypeScript reports:
```
error TS2353: Object literal may only specify known properties, and 'text' does not exist in type ...'.
```
-
-## Benefits
-- **Compile-time safety**: Catch incorrect model options before deployment
-- **Better IDE experience**: Autocomplete shows only valid options for each model
-- **Self-documenting**: Model capabilities are explicit in the type system
-- **Zero runtime overhead**: All type checking happens at compile time
+> [!TIP]
+> For structured output, use `chat({ outputSchema })` in most apps. It works with every provider and validates the result. The raw `text` option is for control that is specific to the provider. See [Structured Outputs](../chat/structured-outputs).
+
+## What you get
+
+- Wrong model options fail at compile time, not in production.
+- Autocomplete shows only the options of the selected model.
+- The types document what each model can do.
+- There is no runtime cost. All checks happen at compile time.
+
+## How it works
+
+Each adapter factory keeps the model name as a type parameter: `openaiText(model)`. So the adapter knows the exact model at the type level.
+
+`chat()` looks up the options for that model in a map for each model (`ResolveProviderOptions`). Each entry lists only the options that the model supports. A model without structured outputs has no `text` property in its options type. TypeScript then rejects `text` as an unknown property.
+
+The same types also work in these places:
+
+- [Typed Pre-Configured Options](./typed-options): keep the options for one model in a reusable object.
+- [Extend Adapter](./extend-adapter): attach typed `modelOptions` to your own models.
diff --git a/docs/advanced/prompt-caching.md b/docs/advanced/prompt-caching.md
index 6e82e5f1e0..60758f3dce 100644
--- a/docs/advanced/prompt-caching.md
+++ b/docs/advanced/prompt-caching.md
@@ -2,7 +2,7 @@
title: Prompt Caching
id: prompt-caching
order: 17
-description: "chat() asks the provider to cache the stable start of each request by default, so a repeated system prompt, tool list, and history cost less and answer sooner. Set the retention or the key, or turn it off."
+description: "chat() asks the provider to cache the start of each request by default. A repeated system prompt, tool list, and history then cost less and answer sooner. Set the retention or the key, or turn it off."
keywords:
- tanstack ai
- prompt caching
@@ -13,15 +13,17 @@ keywords:
- token cost
---
-Every request of a chat sends the same start again: the system prompt, the tools, and the earlier messages. You pay for those tokens on each call, and the model reads them again before it answers.
+Prompt caching reuses the system prompt, tools, and earlier messages that each request sends again. The provider bills those tokens once while the cache is valid.
-Prompt caching lets the provider keep that start. The next request with the same start reads it from the cache. It costs less, and the answer starts sooner.
+`chat()` turns it on by default. A cache hit also makes the answer start sooner.
-`chat()` turns it on for you. You change nothing.
+## Quick setup
-## Give each conversation a key
+This setup takes about 2 minutes.
-Pass the `threadId` of the request to `chat()`. `chat()` uses it as the cache key:
+### 1. Pass the `threadId` to `chat()`
+
+`chat()` uses the `threadId` as the cache key:
```typescript
import {
@@ -45,17 +47,49 @@ export async function POST(request: Request) {
}
```
-Nothing changes on the client. `useChat` already sends one `threadId` for each conversation.
+The client does not change. `useChat` already sends one `threadId` for each conversation.
-The key is the first of these that you set:
+### 2. See the cache work
-1. `promptCache.key`.
-2. The `threadId` or the `conversationId` that you pass to `chat()`.
-3. No key. Claude does not need one. OpenAI uses the key to route requests, so a stable `threadId` gives more cache hits.
+Log the cached tokens in the [`onUsage`](./middleware-advanced#onusage) hook of a middleware. The hook runs one time for each model call:
-A request reads the cache only up to the first token that differs. Text that changes on each call, such as the current time in the system prompt, ends the match there.
+```typescript
+import { chat } from "@tanstack/ai";
+import type { ChatMiddleware } from "@tanstack/ai";
+import { anthropicText } from "@tanstack/ai-anthropic";
-## Turn it off, or keep it longer
+const cacheLog: ChatMiddleware = {
+ name: "cache-log",
+ onUsage: (ctx, usage) => {
+ const read = usage.promptTokensDetails?.cachedTokens ?? 0;
+ console.log(`Call ${ctx.iteration}: ${read} of ${usage.promptTokens} tokens from the cache`);
+ },
+};
+
+const stream = chat({
+ adapter: anthropicText("claude-sonnet-5-5"),
+ messages: [{ role: "user", content: "Hello!" }],
+ threadId: "thread-1",
+ middleware: [cacheLog],
+});
+```
+
+From the second call of a conversation, most of the input comes from the cache.
+
+## How it works
+
+- The cache key is the first of these that you set:
+ 1. `promptCache.key`.
+ 2. The `threadId` that you pass to `chat()`.
+ 3. No key. Claude does not need one. OpenAI uses the key to route requests, so a stable `threadId` gives more cache hits.
+- A request reads the cache only up to the first token that is different. Text that changes on each call, such as the current time in the system prompt, ends the match there.
+- `usage.promptTokens` is the full input. Two fields split it:
+ - `usage.promptTokensDetails.cachedTokens`: tokens read from the cache.
+ - `usage.promptTokensDetails.cacheWriteTokens`: tokens written to the cache. Claude reports this field.
+
+## Advanced
+
+### Turn it off, or keep it longer
Set `promptCache` on `chat()`:
@@ -74,26 +108,27 @@ import { openaiText } from "@tanstack/ai-openai";
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "Where is my order?" }],
+ threadId: "thread-1",
promptCache: { retention: "long", key: "acme-support" },
});
```
The types come from `@tanstack/ai`: `PromptCacheRetention`, `PromptCacheOptions`, and `ResolvedPromptCache`.
-Need a different cache for one model call only? A middleware can set it. See [Change the prompt cache of a call](./middleware#change-the-prompt-cache-of-a-call).
+To change the cache for one model call only, use a middleware. See [Change the prompt cache of a call](./middleware#change-the-prompt-cache-of-a-call).
-## Know what it costs on Claude
+### Know what it costs on Claude
-On OpenAI and Mistral, a cache write is free and a cache read is cheaper. Keep the default.
+On OpenAI and Mistral, a cache write is free and a cache read costs less. Keep the default.
Claude bills the cache write, and a read costs much less than normal input:
-- With the 5-minute cache (`'short'`), a write costs a bit more than normal input. A start that you send twice already costs less than no cache.
-- With the 1-hour cache (`'long'`), a write costs about twice the normal input. The start must be read more times before the cache saves money.
+- With the 5-minute cache (`'short'`), a write costs a bit more than normal input. A start that you send two times already costs less than no cache.
+- With the 1-hour cache (`'long'`), a write costs about two times the normal input. The start must be read more times before the cache saves money.
Two cases change the choice:
-- **A large prompt that you send one time.** For example, a long document per call that you never send again. Nothing reads that cache, so the write is only extra cost. Use `'none'`.
+- **A large prompt that you send one time.** For example, a long document in each call that you never send again. Nothing reads that cache, so the write is only extra cost. Use `'none'`.
- **Long pauses.** The short Claude cache ends 5 minutes after the last read. If your users often reply later than that, use `'long'`.
```typescript
@@ -102,17 +137,18 @@ import { anthropicText } from "@tanstack/ai-anthropic";
export function reviewContract(contract: string) {
return chat({
- adapter: anthropicText("claude-opus-5-5"),
+ adapter: anthropicText("claude-sonnet-5-5"),
messages: [{ role: "user", content: `Review this contract:\n\n${contract}` }],
+ threadId: "thread-1",
// Sent one time only: skip the cache write.
promptCache: "none",
});
}
```
-Claude caches a start only when it is longer than a model minimum (512 to 4096 tokens). A shorter start costs nothing extra.
+Claude caches a start only when it is longer than a minimum for the model (512 to 4096 tokens). A shorter start costs nothing extra.
-## What each provider gets
+### What each provider gets
| Adapter | Default (`'short'`) | `'long'` |
|---|---|---|
@@ -123,11 +159,11 @@ Claude caches a start only when it is longer than a model minimum (512 to 4096 t
| `openaiChatCompletions` | `prompt_cache_key`, only to `api.openai.com` and its regional hosts | The key and `prompt_cache_retention: '24h'` |
| `mistralText` | `prompt_cache_key`, only to `api.mistral.ai`, on `chat()` and on structured output | The same |
-Other adapters send no cache fields. On gpt-5.6 and later, `openaiText` with `'none'` sends `prompt_cache_options: { mode: 'explicit' }`, so OpenAI caches nothing.
+Other adapters send no cache fields.
-Each adapter page has the details: [Anthropic](../adapters/anthropic#prompt-caching), [Amazon Bedrock](../adapters/bedrock#prompt-caching), [OpenAI](../adapters/openai#prompt-caching), [OpenRouter](../adapters/openrouter#prompt-caching), and [Mistral](../adapters/mistral#prompt-caching).
+Each adapter page has the details: [Anthropic](../adapters/anthropic-advanced#prompt-caching), [Amazon Bedrock](../adapters/bedrock#prompt-caching), [OpenAI](../adapters/openai-advanced#prompt-caching), [OpenRouter](../adapters/openrouter-advanced#prompt-caching), and [Mistral](../adapters/mistral#prompt-caching).
-## Set the cache yourself
+### Set the cache yourself
Your own cache fields win. Then `chat()` adds no automatic fields of that kind to the request:
@@ -136,38 +172,6 @@ Your own cache fields win. Then `chat()` adds no automatic fields of that kind t
- **OpenAI**: `modelOptions.prompt_cache_key` or `modelOptions.prompt_cache_retention`.
- **OpenRouter**: `modelOptions.sessionId`.
-## Add tools without losing the cache
-
-The tools sit near the start of each request. Add a tool during a run, and the next call cannot read the cache from there. Some GPT and Claude models take the new tool inside the conversation instead, so the start stays the same. See [Mid-Conversation Changes](./mid-conversation-changes).
-
-## See the cache work
-
-`usage.promptTokens` is the full input. Two fields split it:
-
-- `usage.promptTokensDetails.cachedTokens`: tokens read from the cache.
-- `usage.promptTokensDetails.cacheWriteTokens`: tokens written to the cache. Claude reports it.
-
-Log them in the [`onUsage`](./middleware#onusage) hook. It runs one time for each model call:
-
-```typescript
-import { chat } from "@tanstack/ai";
-import type { ChatMiddleware } from "@tanstack/ai";
-import { anthropicText } from "@tanstack/ai-anthropic";
-
-const cacheLog: ChatMiddleware = {
- name: "cache-log",
- onUsage: (ctx, usage) => {
- const read = usage.promptTokensDetails?.cachedTokens ?? 0;
- console.log(`Call ${ctx.iteration}: ${read} of ${usage.promptTokens} tokens from the cache`);
- },
-};
-
-const stream = chat({
- adapter: anthropicText("claude-opus-5-5"),
- messages: [{ role: "user", content: "Hello!" }],
- threadId: "thread-1",
- middleware: [cacheLog],
-});
-```
+### Add tools without losing the cache
-From the second call of a conversation on, most of the input now comes from the cache.
+The tools sit near the start of each request. If you add a tool during a run, the next call cannot read the cache from that point. Some GPT and Claude models take the new tool inside the conversation, so the start stays the same. See [Mid-Conversation Changes](./mid-conversation-changes).
diff --git a/docs/advanced/runtime-adapter-switching.md b/docs/advanced/runtime-adapter-switching.md
index 18bb784d3d..102e33cbe9 100644
--- a/docs/advanced/runtime-adapter-switching.md
+++ b/docs/advanced/runtime-adapter-switching.md
@@ -2,7 +2,7 @@
title: Runtime Adapter Switching
id: runtime-adapter-switching
order: 6
-description: "Let users switch between LLM providers at runtime in TanStack AI while keeping full TypeScript type safety for each adapter's model options."
+description: "Let users pick the LLM provider at runtime. Each adapter keeps its types, and the chat history keeps working when the provider changes."
keywords:
- tanstack ai
- runtime switching
@@ -12,84 +12,51 @@ keywords:
- dynamic adapter
---
-Learn how to build interfaces where users can switch between LLM providers at runtime while maintaining full TypeScript type safety.
+One endpoint can call Claude for one turn and GPT for the next. The history stays typed across the switch.
-## The Simple Approach
+Keep a map of adapter factories on the server. The client sends the provider that the user picked, and the server picks the adapter from the map.
-With TanStack AI, the model is passed directly to the adapter factory function. This gives you full type safety and autocomplete at the point of definition:
+## Quick setup
+
+### 1. Pick the adapter on the server
```typescript
-import { chat, toServerSentEventsResponse } from '@tanstack/ai'
+// api/chat.ts
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from '@tanstack/ai'
import { anthropicText } from '@tanstack/ai-anthropic'
import { openaiText } from '@tanstack/ai-openai'
-type Provider = 'openai' | 'anthropic'
-
-// Define adapters with their models - autocomplete works here!
const adapters = {
- anthropic: () => anthropicText('claude-sonnet-4-6'), // ✅ Autocomplete!
- openai: () => openaiText('gpt-5.5'), // ✅ Autocomplete!
+ anthropic: () => anthropicText('claude-sonnet-5-5'),
+ openai: () => openaiText('gpt-6.1-sol'),
}
-async function handleRequest(request: Request) {
- // In your request handler:
- const body = await request.json()
- const provider: Provider = body.forwardedProps?.provider || 'openai'
+type Provider = keyof typeof adapters
- const stream = chat({
- adapter: adapters[provider](),
- messages: body.messages,
- })
+function isProvider(value: unknown): value is Provider {
+ return value === 'anthropic' || value === 'openai'
}
-```
-
-## Keep saved history when you switch
-
-You start a thread on Claude, then switch to GPT. Claude's thinking signatures and tool call IDs mean nothing to OpenAI. If you send them as they are, the request fails.
-
-You do not have to fix this yourself. Keep the message metadata when you save and send the history. `chat()` does the rest.
-
-Each assistant message records where it came from, in `metadata.tanstack.source`:
-
-```json
-{ "provider": "anthropic", "api": "anthropic-messages", "model": "claude-sonnet-5-5" }
-```
-
-When the next call goes to a different source, the adapter changes the request:
-
-- It drops signatures and redacted thinking.
-- It sends readable thinking as plain text.
-- It rewrites tool call IDs to the shape that the new API accepts. Each result keeps its call.
-- When an OpenAI-compatible or OpenRouter adapter knows that the model has no image input, it sends no images from tool results. See [Images in Tool Results](./multimodal-content#images-in-tool-results).
-
-Each request also cleans the history:
-
-- An answer that failed or was aborted is left out, with its tool results.
-- A tool call with no result gets the result `No result provided`.
-
-All of this changes only the request. Your saved history stays the same. A message with no `source` counts as same-source, so its signatures go back as they are.
-
-On the server, read the request with `chatParamsFromRequest`. It keeps the message metadata:
-
-```typescript
-import { chat, chatParamsFromRequest, toServerSentEventsResponse } from '@tanstack/ai'
-import { anthropicText } from '@tanstack/ai-anthropic'
-import { openaiText } from '@tanstack/ai-openai'
export async function POST(request: Request) {
const params = await chatParamsFromRequest(request)
- const adapter =
- params.forwardedProps.provider === 'anthropic'
- ? anthropicText('claude-sonnet-5-5')
- : openaiText('gpt-6.1-sol')
+ const requested = params.forwardedProps.provider
+ const provider: Provider = isProvider(requested) ? requested : 'openai'
- return toServerSentEventsResponse(
- chat({ adapter, messages: params.messages, threadId: params.threadId }),
- )
+ const stream = chat({
+ adapter: adapters[provider](),
+ messages: params.messages,
+ threadId: params.threadId,
+ })
+
+ return toServerSentEventsResponse(stream)
}
```
-On the client, send the provider that the user picked:
+### 2. Send the provider from the client
```tsx
import { useState } from 'react'
@@ -99,6 +66,7 @@ export function ProviderChat() {
const [provider, setProvider] = useState('anthropic')
const { sendMessage } = useChat({
connection: fetchServerSentEvents('/api/chat'),
+ threadId: 'support-chat',
forwardedProps: { provider },
})
@@ -120,47 +88,99 @@ export function ProviderChat() {
Switch the provider in the middle of a thread. The next answer comes from the new model, with the full history.
-## Why This Works
+## How it works
+
+The adapter factory takes the model name as its first argument. The adapter keeps the model, so `chat()` needs no separate `model` option.
+
+- You get autocomplete for the model name where you write the map.
+- A wrong model name is a compile error.
+- Each entry in the map keeps its own types, so you need no casts.
+
+Treat `forwardedProps` as client input. The `isProvider` check accepts only the names in your map.
-Each adapter factory function accepts a model name as its first argument and returns a fully typed adapter:
+## Keep saved history when you switch
+
+You start a thread on Claude, then switch to GPT. The thinking signatures and tool call IDs of Claude mean nothing to OpenAI. If you send them as they are, the request fails.
+
+You do not have to fix this yourself. Keep the message metadata when you save and send the history. `chat()` does the rest. On the server, `chatParamsFromRequest` keeps the message metadata for you.
+
+Each assistant message records where it came from, in `metadata.tanstack.source`:
+
+```json
+{ "provider": "anthropic", "api": "anthropic-messages", "model": "claude-sonnet-5-5" }
+```
+
+When the next call goes to a different source, the adapter changes the request:
+
+- It drops signatures and redacted thinking.
+- It sends readable thinking as plain text.
+- It rewrites tool call IDs to the shape that the new API accepts. Each result keeps its call.
+- An OpenAI-compatible or OpenRouter adapter can know that the model has no image input. Then it sends no images from tool results. See [Images in Tool Results](./multimodal-content#images-in-tool-results).
+
+Each request also cleans the history:
+
+- An answer that failed or was aborted is left out, with its tool results.
+- A tool call with no result gets the result `No result provided`.
+
+All of this changes only the request. Your saved history stays the same. A message with no `source` counts as same-source, so its signatures go back as they are.
+
+## Advanced
+
+### The factory and the class
+
+The factory and the adapter class give the same adapter:
```typescript
import { openaiText, OpenAITextAdapter } from '@tanstack/ai-openai'
-// These are equivalent:
-const adapter1 = openaiText('gpt-5.5')
-const adapter2 = new OpenAITextAdapter({ apiKey: process.env.OPENAI_API_KEY! }, 'gpt-5.5')
+const adapter1 = openaiText('gpt-6.1-sol')
+const adapter2 = new OpenAITextAdapter(
+ { apiKey: process.env.OPENAI_API_KEY ?? '' },
+ 'gpt-6.1-sol',
+)
// The model is stored on the adapter
-console.log(adapter1.model) // 'gpt-5.5'
+console.log(adapter1.model) // 'gpt-6.1-sol'
```
-When you pass an adapter to `chat()`, it uses the model from `adapter.model`. This means:
+`chat()` reads the model from `adapter.model`.
-- **Full autocomplete** - When typing the model name, TypeScript knows valid options
-- **Type validation** - Invalid model names cause compile errors
-- **Clean code** - No separate `model` parameter needed
+### Replace a switch statement
-## Full Example
+Do you have a `switch` that sets an adapter and a model in two variables? Replace it with the map of factories from the quick setup. Each factory creates the adapter with its model, so the `chat()` call needs no casts.
-Here's a complete example showing a multi-provider chat API:
+### Full example with four providers
+
+A TanStack Start route that serves OpenAI, Anthropic, Gemini, and Ollama:
```typescript ignore
import { createFileRoute } from '@tanstack/react-router'
-import { chat, maxIterations, toServerSentEventsResponse } from '@tanstack/ai'
-import { openaiText } from '@tanstack/ai-openai'
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from '@tanstack/ai'
import { anthropicText } from '@tanstack/ai-anthropic'
import { geminiText } from '@tanstack/ai-gemini'
import { ollamaText } from '@tanstack/ai-ollama'
+import { openaiText } from '@tanstack/ai-openai'
-type Provider = 'openai' | 'anthropic' | 'gemini' | 'ollama'
-
-// Define adapters with their models
const adapters = {
- anthropic: () => anthropicText('claude-sonnet-4-6'),
- gemini: () => geminiText('gemini-3-flash-preview'),
+ anthropic: () => anthropicText('claude-sonnet-5-5'),
+ gemini: () => geminiText('gemini-3.8-flash'),
ollama: () => ollamaText('mistral:7b'),
- openai: () => openaiText('gpt-5.5'),
+ openai: () => openaiText('gpt-6.1-sol'),
+}
+
+type Provider = keyof typeof adapters
+
+function isProvider(value: unknown): value is Provider {
+ return (
+ value === 'anthropic' ||
+ value === 'gemini' ||
+ value === 'ollama' ||
+ value === 'openai'
+ )
}
export const Route = createFileRoute('/api/chat')({
@@ -168,17 +188,16 @@ export const Route = createFileRoute('/api/chat')({
handlers: {
POST: async ({ request }) => {
const abortController = new AbortController()
- const body = await request.json()
- // `forwardedProps` is the AG-UI field set by `useChat({ forwardedProps })`.
- // The legacy `body.data.provider` access still works (mirrored on the
- // wire for backward compatibility) but `forwardedProps` is preferred.
- const provider: Provider = body.forwardedProps?.provider || 'openai'
+ const params = await chatParamsFromRequest(request)
+ // `forwardedProps` is the AG-UI field that `useChat({ forwardedProps })` sets.
+ const requested = params.forwardedProps.provider
+ const provider: Provider = isProvider(requested) ? requested : 'openai'
const stream = chat({
adapter: adapters[provider](),
- tools: [...],
- systemPrompts: [...],
- messages: body.messages,
+ systemPrompts: ['You are a helpful assistant.'],
+ messages: params.messages,
+ threadId: params.threadId,
abortController,
})
@@ -189,30 +208,33 @@ export const Route = createFileRoute('/api/chat')({
})
```
-## Using with Image Adapters
+### Image adapters
-The same pattern works for image generation. Unlike the text and summarize adapters above, image adapters don't all accept the same shape of `size` — so it travels alongside its adapter in the provider map instead of being passed once for every branch:
+The same pattern works for image generation. Image adapters do not all accept the same shape of `size`, so each entry in the map carries its own `size`:
```typescript
import { generateImage } from '@tanstack/ai'
-import { openaiImage } from '@tanstack/ai-openai'
import { geminiImage } from '@tanstack/ai-gemini'
-
-type ImageProvider = 'openai' | 'gemini'
+import { openaiImage } from '@tanstack/ai-openai'
const imageAdapters = {
- openai: () => ({ adapter: openaiImage('gpt-image-2'), size: '1024x1024' as const }),
+ openai: () => ({ adapter: openaiImage('gpt-image-2.5-sunburst'), size: '1024x1024' as const }),
gemini: () => ({ adapter: geminiImage('gemini-3.1-flash-image'), size: '16:9_4K' as const }),
}
export async function POST(request: Request) {
- const body = await request.json()
- const provider: ImageProvider = body.provider ?? 'openai'
- const { adapter, size } = imageAdapters[provider]()
+ const body: unknown = await request.json()
+ const requested =
+ typeof body === 'object' && body !== null && 'provider' in body
+ ? body.provider
+ : undefined
+ const { adapter, size } =
+ requested === 'gemini' ? imageAdapters.gemini() : imageAdapters.openai()
const result = await generateImage({
adapter,
prompt: 'A beautiful sunset over mountains',
+ threadId: 'thread-1',
size,
})
@@ -220,32 +242,43 @@ export async function POST(request: Request) {
}
```
-`size` is provider-specific, which is why it cannot be a single literal shared across branches. Gemini 3.x native image models take a `'_'` string (for example `'16:9_4K'`). `gemini-2.5-flash-image` takes a bare ratio with no suffix (for example `'16:9'`). OpenAI and Imagen models take pixel dimensions (for example `'1024x1024'`).
+`size` is specific to the provider:
-## Using with Summarize Adapters
+- Gemini 3.x native image models take a `'_'` string, for example `'16:9_4K'`.
+- OpenAI and Imagen models take pixel dimensions, for example `'1024x1024'`.
-And for summarization:
+### Summarize adapters
+
+The same pattern works for summarization:
```typescript
import { summarize } from '@tanstack/ai'
-import { openaiSummarize } from '@tanstack/ai-openai'
import { anthropicSummarize } from '@tanstack/ai-anthropic'
+import { openaiSummarize } from '@tanstack/ai-openai'
type SummarizeProvider = 'openai' | 'anthropic'
-const summarizeAdapters: Record ReturnType> = {
+const summarizeAdapters: Record<
+ SummarizeProvider,
+ () => ReturnType
+> = {
openai: () => openaiSummarize('gpt-5.4-mini'),
- anthropic: () => anthropicSummarize('claude-sonnet-4-6'),
+ anthropic: () => anthropicSummarize('claude-sonnet-5-5'),
}
export async function POST(request: Request) {
- const body = await request.json()
- const provider: SummarizeProvider = body.provider ?? 'openai'
- const longDocument: string = body.text
+ const body: unknown = await request.json()
+ if (typeof body !== 'object' || body === null) {
+ return new Response('Bad request', { status: 400 })
+ }
+ const provider: SummarizeProvider =
+ 'provider' in body && body.provider === 'anthropic' ? 'anthropic' : 'openai'
+ const text = 'text' in body && typeof body.text === 'string' ? body.text : ''
const result = await summarize({
adapter: summarizeAdapters[provider](),
- text: longDocument,
+ text,
+ threadId: 'thread-1',
maxLength: 100,
style: 'concise',
})
@@ -253,65 +286,3 @@ export async function POST(request: Request) {
return Response.json(result)
}
```
-
-## Migration from Switch Statements
-
-If you have existing code using switch statements, here's how to migrate:
-
-### Before
-
-```typescript ignore
-let adapter
-let model
-
-switch (provider) {
- case 'anthropic':
- adapter = anthropicText()
- model = 'claude-sonnet-4-6'
- break
- case 'openai':
- default:
- adapter = openaiText()
- model = 'gpt-5.5'
- break
-}
-
-const stream = chat({
- adapter: adapter as any,
- model: model as any,
- messages,
-})
-```
-
-### After
-
-```typescript
-import { chat, toServerSentEventsResponse } from '@tanstack/ai'
-import { anthropicText } from '@tanstack/ai-anthropic'
-import { openaiText } from '@tanstack/ai-openai'
-
-type AfterProvider = 'openai' | 'anthropic'
-
-const adapters = {
- anthropic: () => anthropicText('claude-sonnet-4-6'),
- openai: () => openaiText('gpt-5.5'),
-}
-
-export async function POST(request: Request) {
- const body = await request.json()
- const provider: AfterProvider = body.forwardedProps?.provider ?? 'openai'
-
- const stream = chat({
- adapter: adapters[provider](),
- messages: body.messages,
- })
-
- return toServerSentEventsResponse(stream)
-}
-```
-
-The key changes:
-
-1. Replace the switch statement with an object of factory functions
-2. Each factory function creates an adapter with the model included
-3. No more `as any` casts - full type safety!
diff --git a/docs/advanced/runtime-context.md b/docs/advanced/runtime-context.md
index cb9df6b842..8a15acd64a 100644
--- a/docs/advanced/runtime-context.md
+++ b/docs/advanced/runtime-context.md
@@ -2,7 +2,7 @@
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."
+description: "Give your tools and middleware typed runtime values, such as the current user or a database client. The values never go to the model or into the AG-UI context."
keywords:
- tanstack ai
- runtime context
@@ -12,306 +12,355 @@ keywords:
- 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.
+`runtimeContext` passes a user id, tenant, or database client into a tool. Those values stay out of the prompt.
-Runtime context is not prompt context and is not the AG-UI `RunAgentInput.context` field. It is never sent to the model automatically.
+Pass them as runtime context. Your tools and middleware read them from `ctx.context`, with full types. The model never sees them.
-## How Type Safety Works
+## Quick setup
-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.
+### 1. Declare the context that the tool needs
-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.
+Put the context type on `.server()`. The tool reads the values from `ctx.context`:
```typescript
-import { chat, toServerSentEventsResponse, toolDefinition, type ChatMiddleware } from "@tanstack/ai";
-import { openaiText } from "@tanstack/ai-openai";
+// lib/ai/tools.ts
+import { toolDefinition } from '@tanstack/ai'
+import { z } from 'zod'
-type UserContext = {
- userId: string;
-};
+export type AppContext = {
+ userId: string
+}
-type TenantContext = {
- tenantId: string;
-};
+const notesByUser = new Map([['user_123', [{ title: 'Groceries' }]]])
-const currentUserTool = toolDefinition({
- name: "current_user",
- description: "Read the current user",
-}).server((_input, ctx) => {
- return { userId: ctx.context.userId };
-});
+export 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 notesByUser.get(ctx.context.userId) ?? []
+})
+```
-const tenantMiddleware: ChatMiddleware = {
- name: "tenant",
- onStart(ctx) {
- console.log(ctx.context.tenantId);
- },
-};
+### 2. Pass the context to `chat()`
+
+```typescript
+// api/chat.ts
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+import { listNotes } from '../lib/ai/tools'
export async function POST(request: Request) {
- const { messages } = await request.json();
+ const { messages, threadId, runId } = await chatParamsFromRequest(request)
+
+ // Replace this line with your auth check.
+ const userId = request.headers.get('x-user-id') ?? 'user_123'
+
const stream = chat({
- adapter: openaiText("gpt-5.5"),
+ adapter: openaiText('gpt-6.1-sol'),
messages,
- tools: [currentUserTool],
- middleware: [tenantMiddleware],
- context: {
- userId: "user_123",
- tenantId: "tenant_456",
- },
- });
- return toServerSentEventsResponse(stream);
+ threadId,
+ runId,
+ tools: [listNotes],
+ context: { userId },
+ })
+
+ return toServerSentEventsResponse(stream)
}
```
-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.
+Ask "What are my notes?". The tool answers with the notes of the current user. If you remove `context`, TypeScript reports an error, because `listNotes` needs `userId`.
-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.
+## How it works
-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 tools and middleware are the contract. The `context` value is what you supply at runtime.
-The same rule applies on the client:
+- Each tool or middleware declares the context shape that it needs.
+- `chat()` merges these needs and checks your `context` value against the result.
+- The value goes only to your tools and middleware. It is not sent to the model.
+- It is also not the AG-UI `RunAgentInput.context` field. See [AG-UI context](#ag-ui-context).
-```typescript
-import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
-import { toolDefinition } from "@tanstack/ai";
+Declare the type in one of these places:
-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: [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"`.
+- `toolDefinition(...).server(...)` for server tools.
+- `toolDefinition(...).client(...)` for client tools.
+- `ChatMiddleware` for middleware.
## Server Runtime Context
-Define the context type once, use it in server tools and middleware, then pass the matching `context` value to `chat()`.
+Middleware reads the same context. Give it the type with `ChatMiddleware`:
```typescript
import {
chat,
+ chatParamsFromRequest,
toServerSentEventsResponse,
- toolDefinition,
- type ChatMiddleware,
-} from "@tanstack/ai";
-import { openaiText } from "@tanstack/ai-openai";
-import { z } from "zod";
-import { requireUser, db } from "./auth";
+} from '@tanstack/ai'
+import type { ChatMiddleware } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
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,
- });
-});
+ userId: string
+ tenantId: string
+}
const auditMiddleware: ChatMiddleware = {
- name: "audit",
+ name: 'audit',
onStart(ctx) {
- console.log("chat started", {
+ 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 { messages, threadId } = await chatParamsFromRequest(request)
const stream = chat({
- adapter: openaiText("gpt-5.5"),
+ adapter: openaiText('gpt-6.1-sol'),
messages,
- tools: [listNotes],
+ threadId,
middleware: [auditMiddleware],
context: {
- userId: user.id,
- tenantId: user.tenantId,
- db,
+ userId: 'user_123',
+ tenantId: 'tenant_456',
},
- });
+ })
- return toServerSentEventsResponse(stream);
+ 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`.
+You can put any value in the context, for example a database client or an audit logger.
## 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.
+Client tools can have runtime context too. Pass it to `useChat`. It stays in the browser and is not sent to the server.
-```typescript
-import { createChatClientOptions } from "@tanstack/ai-client";
-import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
-import { toolDefinition } from "@tanstack/ai";
+```tsx
+import { toolDefinition } from '@tanstack/ai'
+import { createChatClientOptions } from '@tanstack/ai-client'
+import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
type ClientContext = {
- currentTabId: string;
- toast(message: string): void;
-};
+ currentTabId: string
+ toast(message: string): void
+}
const notifyUser = toolDefinition({
- name: "notify_user",
- description: "Show a notification in the current browser tab",
+ 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 };
-});
+ ctx.context.toast(`Updated tab ${ctx.context.currentTabId}`)
+ return { ok: true }
+})
const chatOptions = createChatClientOptions({
- connection: fetchServerSentEvents("/api/chat"),
+ connection: fetchServerSentEvents('/api/chat'),
+ threadId: 'support-chat',
tools: [notifyUser],
context: {
- currentTabId: "settings",
+ currentTabId: 'settings',
toast: (message) => window.alert(message),
},
-});
+})
-const chat = useChat(chatOptions);
+export function Chat() {
+ const { sendMessage } = useChat(chatOptions)
+ return sendMessage('Update my tab')}>Send
+}
```
-Use client context for local dependencies only. Do not put values there expecting the server to receive them.
+Use client context for local values only. The server does not 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.
+To send a value from the client to the server, use `forwardedProps`. On the server, validate it, then copy it into the server context.
-```typescript
-import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
-import { toolDefinition } from "@tanstack/ai";
+On the client, put the value in `forwardedProps`. Unlike `context`, it goes to the server:
-type ClientContext = {
- currentTabId: string;
- toast(message: string): void;
-};
+```tsx
+import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
-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 };
-});
+export function Chat() {
+ const { sendMessage } = useChat({
+ connection: fetchServerSentEvents('/api/chat'),
+ threadId: 'support-chat',
+ forwardedProps: {
+ tenantId: 'tenant_456',
+ },
+ })
-// Client
-useChat({
- connection: fetchServerSentEvents("/api/chat"),
- tools: [notifyUser],
- forwardedProps: {
- tenantId: "tenant_456",
- },
- context: {
- currentTabId: "settings",
- toast: (message) => window.alert(message),
- },
-});
+ return sendMessage('Hello')}>Send
+}
```
+On the server, read `forwardedProps` and build the context:
+
```typescript
-// Server
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
-} from "@tanstack/ai";
-import { openaiText } from "@tanstack/ai-openai";
-import { requireUser } from "./auth";
-import { serverTools } from "./tools";
+} from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
type AppContext = {
- userId: string;
- tenantId: string;
-};
+ userId: string
+ tenantId: string
+}
export async function POST(request: Request) {
- const params = await chatParamsFromRequest(request);
- const user = await requireUser(request);
+ const params = await chatParamsFromRequest(request)
+
+ // Replace these lines with your auth check and your allowlist of tenants.
+ const userId = 'user_123'
+ const defaultTenantId = 'tenant_default'
const tenantId =
- typeof params.forwardedProps.tenantId === "string"
+ typeof params.forwardedProps.tenantId === 'string'
? params.forwardedProps.tenantId
- : user.defaultTenantId;
+ : defaultTenantId
+
+ const context: AppContext = { userId, tenantId }
const stream = chat({
- adapter: openaiText("gpt-5.5"),
+ adapter: openaiText('gpt-6.1-sol'),
messages: params.messages,
- tools: serverTools,
- context: {
- userId: user.id,
- tenantId,
- } satisfies AppContext,
- });
+ threadId: params.threadId,
+ context,
+ })
- return toServerSentEventsResponse(stream);
+ return toServerSentEventsResponse(stream)
}
```
-Treat `forwardedProps` as client-controlled input. Validate and allowlist every field before using it to build server runtime context.
+> [!WARNING]
+> The client controls `forwardedProps`. Validate and allowlist every field before you put it in the server context.
-## AG-UI Context
+## Advanced
-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 })`.
+### How the type checks work
-TanStack AI does not automatically copy AG-UI `params.aguiContext` into runtime context. If you want to use AG-UI context values, validate and map them yourself. `params.context` is a deprecated alias of `params.aguiContext` kept for backward compatibility.
+The tools and middleware decide what the context must contain. The `context` object alone does not decide this. In this call, the tool needs `UserContext` and the middleware needs `TenantContext`, so `context` must have both:
```typescript
-import { chat, chatParamsFromRequest, toServerSentEventsResponse } from "@tanstack/ai";
-import { openaiText } from "@tanstack/ai-openai";
-import { buildRuntimeContextFrom } from "./context";
-import { serverTools } from "./tools";
+import { chat, toolDefinition } from '@tanstack/ai'
+import type { ChatMiddleware } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+
+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)
+ },
+}
+
+const stream = chat({
+ adapter: openaiText('gpt-6.1-sol'),
+ messages: [{ role: 'user', content: 'Who am I?' }],
+ threadId: 'thread-1',
+ tools: [currentUserTool],
+ middleware: [tenantMiddleware],
+ context: {
+ userId: 'user_123',
+ tenantId: 'tenant_456',
+ },
+})
+```
+
+If you remove `tenantId`, TypeScript reports an error, because `tenantMiddleware` needs it.
+
+More rules:
+
+- Untyped tools and middleware still work. They get `unknown` context and do not make `context` required.
+- The inference also works for tools and middleware that you declare outside the `chat()` call and pass in as arrays.
+- To make the context optional, declare `TContext | undefined`. When all typed consumers accept `undefined`, you can leave out `context`.
+- If you pass a `context` value, it must satisfy every typed consumer.
+
+### The same rules on the client
+
+`useChat()` checks `context` against the client tools. This tool declares `ClientRuntimeContext & { mode: 'debug' }`, so `context` needs `currentTabId` and the literal `mode: 'debug'`:
+
+```tsx
+import { toolDefinition } from '@tanstack/ai'
+import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
+
+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,
+ }
+})
+
+export function DebugChat() {
+ const { sendMessage } = useChat({
+ connection: fetchServerSentEvents('/api/chat'),
+ threadId: 'support-chat',
+ tools: [inspectClientContext],
+ context: {
+ currentTabId: 'settings',
+ mode: 'debug',
+ },
+ })
+
+ return sendMessage('Inspect')}>Send
+}
+```
+
+### AG-UI Context
+
+AG-UI defines its own `RunAgentInput.context` field. It holds context entries for agents at the protocol level. `chatParamsFromRequest` gives you this field as `params.aguiContext`. It is separate from `chat({ context })`.
+
+TanStack AI does not copy `params.aguiContext` into the runtime context. To use these values, validate them and map them yourself:
+
+```typescript ignore
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+import { buildRuntimeContextFrom } from './context'
+import { serverTools } from './tools'
export async function POST(request: Request) {
- const params = await chatParamsFromRequest(request);
+ const params = await chatParamsFromRequest(request)
const stream = chat({
- adapter: openaiText("gpt-5.5"),
+ adapter: openaiText('gpt-6.1-sol'),
messages: params.messages,
+ threadId: params.threadId,
tools: serverTools,
context: buildRuntimeContextFrom(params.aguiContext),
- });
+ })
- return toServerSentEventsResponse(stream);
+ return toServerSentEventsResponse(stream)
}
```
+
+`buildRuntimeContextFrom` is your own function. It reads the entries you trust and returns your context object.
diff --git a/docs/advanced/testing.md b/docs/advanced/testing.md
index e0d58336fd..5207c32d92 100644
--- a/docs/advanced/testing.md
+++ b/docs/advanced/testing.md
@@ -12,13 +12,15 @@ keywords:
- fakeText
---
-Tests that call a real model are slow, cost money, need an API key, and get a different answer each time. `fakeText()` from `@tanstack/ai/testing` fixes that. It is a text adapter that answers from a script you write. Pass it to `chat()` like any other adapter.
+The test adapter returns a scripted stream. Tests then skip the network, the API key, and a different answer on each run.
-## Answer a prompt
+Use `fakeText()` from `@tanstack/ai/testing`. It is a text adapter that answers from a script that you write. Give it to `chat()` like any other adapter.
+
+## Quick setup
Queue the answers, then run `chat()`:
-```ts group=testing
+```ts
import { chat } from '@tanstack/ai'
import { fakeText } from '@tanstack/ai/testing'
@@ -29,22 +31,30 @@ let answer = ''
for await (const chunk of chat({
adapter: fake,
messages: [{ role: 'user', content: 'Hi' }],
+ threadId: 'thread-1',
})) {
if (chunk.type === 'TEXT_MESSAGE_CONTENT') answer += chunk.delta
}
console.log(answer) // 'Hello!'
```
+Run it. It prints `Hello!` with no network and no key.
+
+## How it works
+
- Each model call takes the next answer from the queue.
- An empty queue ends the call with a `RUN_ERROR`: "No more fake responses queued".
-- `appendResponses` adds answers. `pendingResponses()` counts what is left.
+- `appendResponses` adds answers. `pendingResponses()` counts the answers that are left.
+- `fake.state.callCount` counts the model calls that the fake answered.
+- The fake reports token usage on each call: `ceil(characters / 4)` over the request and the answer. So a long message costs many tokens, as with a real model.
## Test a tool
Give an answer with `toolCalls`, and `chat()` runs your tool. Then queue the answer that comes after the tool result:
-```ts group=testing
-import { toolDefinition } from '@tanstack/ai'
+```ts
+import { chat, toolDefinition } from '@tanstack/ai'
+import { fakeText } from '@tanstack/ai/testing'
import { z } from 'zod'
const getWeather = toolDefinition({
@@ -53,64 +63,71 @@ const getWeather = toolDefinition({
inputSchema: z.object({ city: z.string() }),
}).server(async ({ city }) => ({ city, sky: 'sunny' }))
-const weatherFake = fakeText()
-weatherFake.setResponses([
+const fake = fakeText()
+fake.setResponses([
{ toolCalls: [{ name: 'get_weather', input: { city: 'Oslo' } }] },
{ text: 'It is sunny in Oslo.' },
])
for await (const chunk of chat({
- adapter: weatherFake,
+ adapter: fake,
messages: [{ role: 'user', content: 'Weather in Oslo?' }],
+ threadId: 'thread-1',
tools: [getWeather],
})) {
if (chunk.type === 'TOOL_CALL_RESULT') console.log(chunk.content)
}
-console.log(weatherFake.state.callCount) // 2
-```
-
-## Answer from the request
-
-Put a function in the queue to build the answer when the call comes in. It gets the `request` and the `state`:
-
-```ts group=testing
-const echo = fakeText()
-echo.setResponses([
- ({ request, state }) => ({
- text: `Call ${state.callCount} saw ${request.messages.length} messages.`,
- }),
-])
+console.log(fake.state.callCount) // 2
```
## Test an error
Give an answer with `error`, and the call fails with a `RUN_ERROR`:
-```ts group=testing
-const broken = fakeText()
-broken.setResponses([{ error: 'The provider is down' }])
+```ts
+import { chat } from '@tanstack/ai'
+import { fakeText } from '@tanstack/ai/testing'
+
+const fake = fakeText()
+fake.setResponses([{ error: 'The provider is down' }])
for await (const chunk of chat({
- adapter: broken,
+ adapter: fake,
messages: [{ role: 'user', content: 'Hi' }],
+ threadId: 'thread-1',
})) {
if (chunk.type === 'RUN_ERROR') console.log(chunk.message) // 'The provider is down'
}
```
-The fake also reports token usage on each call: `ceil(characters / 4)` over the request and the answer. So a long message costs many tokens, like with a real model.
+## Advanced
+
+### Answer from the request
+
+Put a function in the queue to build the answer when the call comes in. It gets the `request` and the `state`:
+
+```ts
+import { fakeText } from '@tanstack/ai/testing'
+
+const fake = fakeText()
+fake.setResponses([
+ ({ request, state }) => ({
+ text: `Call ${state.callCount} saw ${request.messages.length} messages.`,
+ }),
+])
+```
-## Options
+### Options
| Option | What it does |
|---|---|
| `model` | The model id. Default `'fake-model'`. |
-| `input` | The input kinds the model reads, for example `['text', 'image']`. Read it back as `fake.inputModalities`, to test code that checks what a model accepts. |
+| `input` | The input kinds that the model reads, for example `['text', 'image']`. Read it back as `fake.inputModalities`, to test code that checks what a model accepts. |
| `contextWindow` | The context window in tokens. Read it back as `fake.contextWindow`. |
-| `tokensPerSecond` | Stream the text at this speed, 4 characters per token. |
+| `tokensPerSecond` | Stream the text at this speed, 4 characters for each token. |
| `cache` | With a `threadId`, the part of the request that matches the previous request of the thread counts as cached tokens. |
-## Answer fields
+### Answer fields
| Field | What it does |
|---|---|
@@ -120,4 +137,4 @@ The fake also reports token usage on each call: `ceil(characters / 4)` over the
| `finishReason` | `'stop'`, `'length'`, `'content_filter'`, or `'tool_calls'`. Default `'tool_calls'` with tool calls, else `'stop'`. |
| `error` | Fail the call with a `RUN_ERROR` that has this message. |
-Your tests now run with no network and no key, and they get the same answer every time.
+Your tests now run with no network and no key, and they get the same answer each time.
diff --git a/docs/advanced/tree-shaking.md b/docs/advanced/tree-shaking.md
index 586c51dfa1..3455890d66 100644
--- a/docs/advanced/tree-shaking.md
+++ b/docs/advanced/tree-shaking.md
@@ -2,7 +2,7 @@
title: Tree-Shaking
id: tree-shaking
order: 7
-description: "TanStack AI's tree-shakeable architecture — import only the activities and adapters you use for minimal bundle size across chat, image, and speech."
+description: "Import only the activities and adapters that you use. Your bundle then holds only the code for those features and providers."
keywords:
- tanstack ai
- tree-shaking
@@ -12,291 +12,156 @@ keywords:
- tree-shakeable
---
-TanStack AI is designed from the ground up for maximum tree-shakeability. The entire system—from activity functions to adapters—uses a functional, modular architecture that ensures you only bundle the code you actually use.
+Import one adapter from its package subpath. The bundle then omits image generation and the adapters you do not call.
-## Design Philosophy
+TanStack AI exports each activity and each adapter as a separate named function. Import only the ones you use, and your bundler drops the rest.
-Instead of a monolithic API that includes everything, TanStack AI provides:
+## Quick setup
-- **Individual activity functions** - Import only the activities you need (`chat`, `summarize`, etc.)
-- **Individual adapter functions** - Import only the adapters you need (`openaiText`, `openaiSummarize`, etc.)
-- **Functional API design** - Pure functions that can be easily eliminated by bundlers
-- **Separate modules** - Each activity and adapter lives in its own module
-
-This design means that if you only use `chat` with OpenAI, you won't bundle code for summarization, image generation, or other providers.
-
-## Activity Functions
-
-Each AI activity is exported as a separate function from `@tanstack/ai`:
-
-```ts
-// Import only the activities you need
-import { chat } from '@tanstack/ai' // Chat/text generation
-import { summarize } from '@tanstack/ai' // Summarization
-import { generateImage } from '@tanstack/ai' // Image generation
-import { generateSpeech } from '@tanstack/ai' // Text-to-speech
-import { generateTranscription } from '@tanstack/ai' // Audio transcription
-import { generateVideo } from '@tanstack/ai' // Video generation
-import { generateLiveVideo } from '@tanstack/ai' // Live video sessions
-import { generateWorld } from '@tanstack/ai' // Live world sessions
-```
-
-### Example: Chat Only
-
-If you only need chat functionality:
+1. Import the activity that you use from `@tanstack/ai`.
+2. Import the one adapter that you use from the provider package.
```ts
-// Only chat code is bundled
import { chat } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
const stream = chat({
- adapter: openaiText('gpt-5.5'),
+ adapter: openaiText('gpt-6.1-sol'),
messages: [{ role: 'user', content: 'Hello!' }],
+ threadId: 'thread-1',
})
-```
-
-Your bundle will **not** include:
-- Summarization logic
-- Image generation logic
-- Other activity implementations
-## Adapter Functions
-
-Each provider package exports individual adapter functions for each activity type:
-
-### OpenAI
-
-```ts
-import {
- openaiText, // Chat/text generation
- openaiSummarize, // Summarization
- openaiImage, // Image generation
- openaiSpeech, // Text-to-speech
- openaiTranscription, // Audio transcription
- openaiVideo, // Video generation
-} from '@tanstack/ai-openai'
-```
-
-### Anthropic
-
-```ts
-import {
- anthropicText, // Chat/text generation
- anthropicSummarize, // Summarization
-} from '@tanstack/ai-anthropic'
+for await (const chunk of stream) {
+ console.log(chunk)
+}
```
-### Gemini
+Your bundle now holds:
-```ts
-import {
- geminiText, // Chat/text generation
- geminiSummarize, // Summarization
- geminiImage, // Image generation
- geminiSpeech, // Text-to-speech (experimental)
-} from '@tanstack/ai-gemini'
-```
-
-### Ollama
+- The `chat` function, with its streaming and tool code.
+- The `openaiText` adapter.
-```ts
-import {
- ollamaText, // Chat/text generation
- ollamaSummarize, // Summarization
-} from '@tanstack/ai-ollama'
-```
+It does not hold `summarize`, `generateImage`, the other activities, or the adapters of other providers.
-## Complete Example
+## Use named imports
-Here's how the tree-shakeable design works in practice:
+Import each function by name. Some bundlers cannot drop the unused exports of a namespace import:
```ts
-// Only import what you need
+// Good: only what you use
import { chat } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
-
-// Chat generation - returns AsyncIterable
-const chatResult = chat({
- adapter: openaiText('gpt-5.5'),
- messages: [{ role: 'user', content: 'Hello!' }],
-})
-
-for await (const chunk of chatResult) {
- console.log(chunk)
-}
```
-**What gets bundled:**
-- ✅ `chat` function and its dependencies
-- ✅ `openaiText` adapter and its dependencies
-- ✅ Chat-specific streaming and tool handling logic
-
-**What doesn't get bundled:**
-- ❌ `summarize` function
-- ❌ `generateImage` function
-- ❌ Other adapter implementations (Anthropic, Gemini, etc.)
-- ❌ Other activity implementations
+```ts
+// Avoid: a namespace import can pull in every export
+import * as ai from '@tanstack/ai'
+import * as openai from '@tanstack/ai-openai'
+```
-## Using Multiple Activities
+## Use more than one activity
-If you need multiple activities, import only what you use:
+Import each activity and its adapter. Each one is independent:
```ts
import { chat, summarize } from '@tanstack/ai'
-import {
- openaiText,
- openaiSummarize
-} from '@tanstack/ai-openai'
-
-// Each activity is independent
-const chatResult = chat({
- adapter: openaiText('gpt-5.5'),
+import { openaiSummarize, openaiText } from '@tanstack/ai-openai'
+
+const stream = chat({
+ adapter: openaiText('gpt-6.1-sol'),
messages: [{ role: 'user', content: 'Hello!' }],
+ threadId: 'thread-1',
})
-const summarizeResult = await summarize({
+const summary = await summarize({
adapter: openaiSummarize('gpt-5.4-mini'),
- text: 'Long text to summarize...',
+ text: 'Long text to summarize.',
+ threadId: 'thread-1',
})
```
-Each activity is in its own module, so bundlers can eliminate unused ones.
+The `create*Options` helpers are separate exports too. See [Typed Pre-Configured Options](./typed-options).
-## Type Safety
+## Tips for smaller bundles
-The tree-shakeable design doesn't sacrifice type safety. Each adapter provides full type safety for its supported models:
+- Use a specific adapter function, for example `openaiText`, for each activity.
+- Put different activities in different API routes.
+- Load code-split routes with dynamic imports.
+- In React Native and Expo, keep the chat screens client-only. Import `useChat` and the connection adapters there. Do not import provider SDKs, server response helpers, React DOM UI, devtools UI, or other framework packages. See [Quick Start: React Native](../getting-started/quick-start-react-native) for the server-only provider boundary and the mobile transport setup.
-```ts ignore
-import { openaiText, type OpenAIChatModel } from '@tanstack/ai-openai'
+## How it works
-const adapter = openaiText('gpt-5.5')
+- Each function is a named ES module export, not a default export.
+- Each activity and adapter lives in its own module.
+- `@tanstack/ai` declares `"sideEffects": false` in its `package.json`, so bundlers can drop modules that you do not import.
+- Type-only imports are removed at build time.
-// TypeScript knows the exact models supported
-const model: OpenAIChatModel = 'gpt-5.5' // ✓ Valid
-const model2: OpenAIChatModel = 'invalid' // ✗ Type error
-```
-
-## Create Options Functions
-
-The `create___Options` functions are also tree-shakeable:
-
-```ts
-import {
- createChatOptions,
- createImageOptions
-} from '@tanstack/ai'
-import { openaiText } from '@tanstack/ai-openai'
-
-// Only import what you need
-const chatOptions = createChatOptions({
- adapter: openaiText('gpt-5.5'),
-})
-```
-
-## Bundle Size Benefits
+Bundlers such as Vite, Webpack, Rollup, and esbuild follow the static imports and drop the code that you do not reference.
-The functional, modular design provides significant bundle size benefits:
+## Advanced
-### Importing Everything (Less Efficient)
+### Activity functions
-```ts
-// ❌ Importing more than needed
-import * as ai from '@tanstack/ai'
-import * as openai from '@tanstack/ai-openai'
-
-// This bundles all exports from both packages
-```
+Each activity is a separate export of `@tanstack/ai`:
-### Importing Only What You Need (Recommended)
+| Function | Activity |
+|---|---|
+| `chat` | Chat and text generation |
+| `summarize` | Summarization |
+| `generateImage` | Image generation |
+| `generateSpeech` | Text-to-speech |
+| `generateTranscription` | Audio transcription |
+| `generateVideo` | Video generation |
+| `generateLiveVideo` | Live video sessions |
+| `generateWorld` | Live world sessions |
-```ts
-// ✅ Only what you use gets bundled
-import { chat } from '@tanstack/ai'
-import { openaiText } from '@tanstack/ai-openai'
-
-// You only get:
-// - Chat activity implementation
-// - OpenAI text adapter
-// - Chat-specific dependencies
-```
+### Adapter functions by provider
-### Real-World Impact
+Each provider package exports one function for each activity. Some examples:
-For a typical chat application, importing a single activity and one adapter pulls in substantially less code than bundling every activity and every provider adapter. Because each activity and adapter lives in its own side-effect-free module, your bundler drops everything you don't reference — so the more providers and activities the library supports, the larger the difference between a focused import and a namespace import.
+| Package | Adapters |
+|---|---|
+| `@tanstack/ai-openai` | `openaiText`, `openaiSummarize`, `openaiImage`, `openaiSpeech`, `openaiTranscription`, `openaiVideo`, `openaiEmbedding` |
+| `@tanstack/ai-anthropic` | `anthropicText`, `anthropicSummarize` |
+| `@tanstack/ai-gemini` | `geminiText`, `geminiSummarize`, `geminiImage`, `geminiSpeech` (experimental), `geminiEmbedding` |
+| `@tanstack/ai-ollama` | `ollamaText`, `ollamaSummarize`, `ollamaEmbedding` |
-## How It Works
+Each adapter page lists the full set for its provider.
-The tree-shakeability is achieved through:
+### Adapter types
-1. **ES Module exports** - Each function is a named export, not a default export
-2. **Separate modules** - Each activity and adapter lives in its own file
-3. **No side effects** - Functions are pure and don't have module-level side effects
-4. **Functional composition** - Functions compose together, allowing dead code elimination
-5. **Type-only imports** - Type imports are stripped at build time
+Each adapter type has its own interface and a `kind` property:
-Modern bundlers (Vite, Webpack, Rollup, esbuild) can easily eliminate unused code because:
-
-- Functions are statically analyzable
-- No dynamic imports of unused code
-- No module-level side effects
-- Clear dependency graphs
-
-## Best Practices
-
-1. **Import only what you need** - Don't import entire namespaces
-2. **Use specific adapter functions** - Import `openaiText` not `openai`
-3. **Separate activities by route** - Different API routes can use different activities
-4. **Lazy load when possible** - Use dynamic imports for code-split routes
-5. **Keep mobile chat bundles client-only** - React Native and Expo chat screens
- should import `useChat` and chat connection adapters, not provider SDKs,
- server response helpers, React DOM UI, devtools UI, or other framework
- packages. See [Quick Start: React Native](../getting-started/quick-start-react-native)
- for the server-only provider boundary and mobile transport setup.
+| Interface | Main method | `kind` |
+|---|---|---|
+| `TextAdapter` | `chatStream()` | `'text'` |
+| `SummarizeAdapter` | `summarize()` | `'summarize'` |
+| `ImageAdapter` | `generateImages()` | `'image'` |
+| `TTSAdapter` | `generateSpeech()` | `'tts'` |
+| `TranscriptionAdapter` | `transcribe()` | `'transcription'` |
+| `VideoAdapter` | `createVideoJob()`, `getVideoStatus()`, `getVideo()` | `'video'` |
+| `LiveVideoAdapter` | `createLiveVideo()` | `'liveVideo'` |
+| `WorldAdapter` | `createWorld()` | `'world'` |
```ts
-// ✅ Good - Only imports chat
-import { chat } from '@tanstack/ai'
-import { openaiText } from '@tanstack/ai-openai'
-
-// ❌ Bad - Imports everything
-import * as ai from '@tanstack/ai'
-import * as openai from '@tanstack/ai-openai'
-```
-
-## Adapter Types
+import { openaiSummarize, openaiText } from '@tanstack/ai-openai'
-Each adapter type implements a specific interface:
-
-- `ChatAdapter` - Provides `chatStream()` method for streaming chat responses
-- `SummarizeAdapter` - Provides `summarize()` method for text summarization
-- `ImageAdapter` - Provides `generateImage()` method for image generation
-- `TTSAdapter` - Provides `generateSpeech()` method for text-to-speech
-- `TranscriptionAdapter` - Provides `generateTranscription()` method for audio transcription
-- `VideoAdapter` - Provides `generateVideo()` method for video generation
-- `LiveVideoAdapter` - Provides `createLiveVideo()` method for live video sessions
-- `WorldAdapter` - Provides `createWorld()` method for live world sessions and world jobs
-
-All adapters have a `kind` property that indicates their type:
-
-```ts
-import { openaiText, openaiSummarize } from '@tanstack/ai-openai'
-
-const chatAdapter = openaiText('gpt-5.5')
+const chatAdapter = openaiText('gpt-6.1-sol')
console.log(chatAdapter.kind) // 'text'
const summarizeAdapter = openaiSummarize('gpt-5.4-mini')
console.log(summarizeAdapter.kind) // 'summarize'
```
-## Summary
+### Type safety
-TanStack AI's tree-shakeable design means:
+Separate exports keep the full types. Each adapter knows the models of its provider:
-- ✅ **Smaller bundles** - Only include code you actually use
-- ✅ **Faster load times** - Less JavaScript to download and parse
-- ✅ **Better performance** - Less code means faster execution
-- ✅ **Type safety** - Full TypeScript support without runtime overhead
-- ✅ **Flexibility** - Mix and match activities and adapters as needed
+```ts ignore
+import { openaiText, type OpenAIChatModel } from '@tanstack/ai-openai'
-The functional, modular architecture ensures that modern bundlers can eliminate unused code effectively, resulting in optimal bundle sizes for your application.
+const adapter = openaiText('gpt-6.1-sol')
+
+const model: OpenAIChatModel = 'gpt-6.1-sol' // OK
+const model2: OpenAIChatModel = 'invalid' // Type error
+```
diff --git a/docs/advanced/typed-options.md b/docs/advanced/typed-options.md
index b1a7f5f89c..b9c0288123 100644
--- a/docs/advanced/typed-options.md
+++ b/docs/advanced/typed-options.md
@@ -2,7 +2,7 @@
title: Typed Pre-Configured Options
id: typed-options
order: 11
-description: "Define typed, reusable option objects for chat, summarize, image, video, audio, speech, and transcription with createChatOptions and friends — share configuration across routes without losing per-model type safety."
+description: "Define a typed options object once with createChatOptions and the other create*Options helpers, and use it in many routes. You keep the type checks for each model."
keywords:
- tanstack ai
- createChatOptions
@@ -16,131 +16,112 @@ keywords:
- shared configuration
---
-You have a `chat()` (or `generateImage()`, `generateSpeech()`, …) configuration you want to reuse — across multiple routes, between a server function and its caller, or simply factored out of a handler for clarity. By the end of this guide, you'll have a single typed options object that infers the adapter's model, modalities, and provider options, and that you can spread into any call site without losing type safety.
+`createChatOptions` stores one adapter, system prompt, and tool list. Each route spreads that object and keeps the model types.
-## The pattern
+Define the config once with `createChatOptions`. The object keeps the full type of the adapter, so `modelOptions`, content types, and `outputSchema` stay checked where you spread it.
-Every activity in `@tanstack/ai` ships a paired `createXxxOptions` helper that takes the exact same options object as the activity itself and returns it unchanged — at runtime it's the identity function. The point is **type inference**: the returned object carries the adapter's full type, so when you spread it into the activity, TypeScript still narrows `modelOptions`, content modalities, and `outputSchema` to the adapter you chose.
+## Quick setup
-```typescript
-import { chat, createChatOptions } from '@tanstack/ai'
-import { openaiText } from '@tanstack/ai-openai'
-
-const chatOptions = createChatOptions({
- adapter: openaiText('gpt-5.5'),
- // modelOptions, systemPrompts, tools — all type-checked against the
- // adapter+model pair above. Sampling params (temperature, top_p,
- // max_output_tokens, …) live inside modelOptions, under each provider's
- // native key.
- modelOptions: {
- temperature: 0.3,
- },
- reasoning: 'medium',
-})
-
-// Later, anywhere in your codebase:
-const stream = chat({ ...chatOptions, messages: [{ role: 'user', content: 'Hello' }] })
-```
-
-Without the helper you'd have to either inline the configuration at every call site, or hand-write the full chat options type with its adapter/model generics resolved manually — `createChatOptions` does that for you.
-
-## When to reach for it
-
-- **Sharing a configuration across multiple routes** — define once, spread into each handler.
-- **Passing options through a layer** (a server function, a wrapper, a test fixture) without erasing the adapter's model-specific types.
-- **Branching on a runtime value while keeping types intact** — build different options objects and choose between them, instead of weaving conditionals into a single `chat({...})` call.
-- **Co-locating tools, system prompts, and middleware** with the adapter they target.
-
-If you only call an activity once at one site, you don't need this helper. Inline the options.
-
-## Available helpers
-
-Each helper mirrors the activity it pairs with. Same options, same return type.
-
-| Helper | Activity | Adapter |
-|---|---|---|
-| `createChatOptions` | `chat()` | text adapter (e.g. `openaiText`, `anthropicText`) |
-| `createSummarizeOptions` | `summarize()` | summarize adapter (e.g. `openaiSummarize`) |
-| `createImageOptions` | `generateImage()` | image adapter (e.g. `openaiImage`, `falImage`) |
-| `createAudioOptions` | `generateAudio()` | audio adapter (e.g. `falAudio`, `geminiAudio`) |
-| `createVideoOptions` | `generateVideo()` / `getVideoJobStatus()` | video adapter (e.g. `falVideo`, `openaiVideo`) |
-| `createLiveVideoOptions` | `generateLiveVideo()` | live adapter (e.g. `reactorVideo`, `falLiveVideo`) |
-| `createWorldOptions` | `generateWorld()` | world adapter (e.g. `reactorWorld`, `worldlabsWorld`) |
-| `createSpeechOptions` | `generateSpeech()` | speech adapter (e.g. `openaiSpeech`, `elevenlabsSpeech`) |
-| `createTranscriptionOptions` | `generateTranscription()` | transcription adapter (e.g. `openaiTranscription`, `falTranscription`) |
-
-All helpers are exported from `@tanstack/ai`.
-
-## Example: shared chat configuration across routes
-
-Suppose you have several routes that all hit the same model with the same provider options and tool set. Factor the configuration out once:
+### 1. Define the options once
```typescript
// lib/ai/chat-options.ts
import { createChatOptions, toolDefinition } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { z } from 'zod'
-import { db } from './db'
-const lookupOrderDef = toolDefinition({
+const orders = new Map([['A-100', { id: 'A-100', status: 'shipped' }]])
+
+const lookupOrder = toolDefinition({
name: 'lookupOrder',
description: 'Look up a customer order by ID',
inputSchema: z.object({ orderId: z.string() }),
-})
-
-const lookupOrder = lookupOrderDef.server(async ({ orderId }) => {
- return db.orders.findUnique({ where: { id: orderId } })
-})
+}).server(async ({ orderId }) => orders.get(orderId) ?? null)
export const supportChatOptions = createChatOptions({
- adapter: openaiText('gpt-5.5'),
+ adapter: openaiText('gpt-6.1-sol'),
systemPrompts: ['You are a customer-support assistant for Acme Corp.'],
tools: [lookupOrder],
reasoning: 'medium',
+ // Sampling settings go in modelOptions, under the key of the provider.
+ modelOptions: {
+ temperature: 0.3,
+ },
})
```
-```typescript ignore
+### 2. Spread the options into `chat()`
+
+```typescript
// routes/api/support/chat.ts
-import { chat, toServerSentEventsResponse } from '@tanstack/ai'
-import { supportChatOptions } from '@/lib/ai/chat-options'
+import {
+ chat,
+ chatParamsFromRequest,
+ toServerSentEventsResponse,
+} from '@tanstack/ai'
+import { supportChatOptions } from '../../../lib/ai/chat-options'
export async function POST(request: Request) {
- const { messages } = await request.json()
- const stream = chat({ ...supportChatOptions, messages })
+ const { messages, threadId, runId } = await chatParamsFromRequest(request)
+
+ const stream = chat({ ...supportChatOptions, messages, threadId, runId })
+
return toServerSentEventsResponse(stream)
}
```
-```typescript ignore
-// routes/api/support/draft-reply.ts — same adapter+tools, different schema
+Every route that spreads `supportChatOptions` now uses the same model, prompt, and tools, with full type checks.
+
+## Change one field per route
+
+The spread wins from the right. Add or override any field at the call site. This route uses the same options, and adds a schema for a structured answer:
+
+```typescript
+// routes/api/support/draft-reply.ts
import { chat } from '@tanstack/ai'
-import { supportChatOptions } from '@/lib/ai/chat-options'
import { z } from 'zod'
+import { supportChatOptions } from '../../../lib/ai/chat-options'
export async function POST(request: Request) {
- const { ticket } = await request.json()
+ const body: unknown = await request.json()
+ const ticket =
+ typeof body === 'object' && body !== null && 'ticket' in body
+ ? String(body.ticket)
+ : ''
+
const draft = await chat({
...supportChatOptions,
messages: [{ role: 'user', content: `Draft a reply to: ${ticket}` }],
+ threadId: 'thread-1',
outputSchema: z.object({ subject: z.string(), body: z.string() }),
stream: false,
})
+
return Response.json(draft)
}
```
-Both routes share the adapter, system prompt, tools, and reasoning settings; each adds what it needs. Override or omit any field at the call site — the spread wins on the right.
+## When to use it
+
+- You share one config across many routes.
+- You pass options through a layer (a server function, a wrapper, a test fixture) and want to keep the types of the model.
+- You pick between two configs at runtime. Build two options objects and choose one, so the `chat()` call has no conditions inside it.
+- You keep tools, system prompts, and middleware next to the adapter that they target.
-## Example: typed pre-configured image generation
+If you call an activity once in one place, you do not need the helper. Write the options inline.
+
+## The same pattern for media
+
+Each activity has its own helper. For example, an image:
```typescript
import { createImageOptions, generateImage } from '@tanstack/ai'
import { openaiImage } from '@tanstack/ai-openai'
const heroImageOptions = createImageOptions({
- adapter: openaiImage('gpt-image-2'),
+ adapter: openaiImage('gpt-image-2.5-sunburst'),
prompt: 'A glass sphere refracting a sunset over a calm sea',
+ threadId: 'thread-1',
size: '1536x1024',
numberOfImages: 1,
})
@@ -148,16 +129,39 @@ const heroImageOptions = createImageOptions({
const result = await generateImage(heroImageOptions)
```
-The same pattern works for `createVideoOptions`, `createSpeechOptions`, `createTranscriptionOptions`, `createAudioOptions`, and `createSummarizeOptions` — the adapter is captured in the typed options object and every downstream call is narrowed to it.
+## Available helpers
+
+All helpers come from `@tanstack/ai`. Each one takes the same options as its activity.
+
+| Helper | Activity | Adapter examples |
+|---|---|---|
+| `createChatOptions` | `chat()` | `openaiText`, `anthropicText` |
+| `createSummarizeOptions` | `summarize()` | `openaiSummarize` |
+| `createEmbedOptions` | `embed()` | `openaiEmbedding`, `geminiEmbedding` |
+| `createRerankOptions` | `rerank()` | `cohereRerank` |
+| `createImageOptions` | `generateImage()` | `openaiImage`, `falImage` |
+| `createAudioOptions` | `generateAudio()` | `falAudio`, `geminiAudio` |
+| `createVideoOptions` | `generateVideo()`, `getVideoJobStatus()` | `falVideo`, `openaiVideo` |
+| `createLiveVideoOptions` | `generateLiveVideo()` | `reactorVideo`, `falLiveVideo` |
+| `createWorldOptions` | `generateWorld()` | `reactorWorld`, `worldlabsWorld` |
+| `createSpeechOptions` | `generateSpeech()` | `openaiSpeech`, `elevenlabsSpeech` |
+| `createVoiceOptions` | `generateVoice()` | `elevenlabsVoiceDesign` |
+| `createTranscriptionOptions` | `generateTranscription()` | `openaiTranscription`, `falTranscription` |
+
+## Advanced
+
+### How it works
+
+At runtime, each helper is the identity function: `createChatOptions(opts)` returns `opts`. The value of the helper is in the types. The returned object carries the full type of the adapter and model, so you do not write the generic types of the options yourself.
-## What the helper does NOT do
+### What the helper does not do
-- **No runtime behavior.** `createChatOptions(opts)` is `opts`. There is no validation, freezing, cloning, or memoization. If you mutate the returned object after creation, the next call sees the mutation. Treat the result as immutable by convention.
-- **No partial typing.** The helper expects the full options shape it'll be spread into. If you need to build options up incrementally, type the intermediate state yourself (a `Partial<>` of the full chat options shape) and only call the helper at the boundary where the shape is complete.
-- **No request execution.** The helper does not call the model. Only the activity function (`chat`, `generateImage`, …) makes the request.
+- **No runtime behavior.** There is no validation, freezing, cloning, or memoization. If you change the returned object, the next call sees the change. Treat the object as read-only.
+- **No partial types.** The helper expects the full options shape. To build options step by step, give the partial state your own type (a `Partial<>` of the full options shape). Call the helper only when the shape is complete.
+- **No request.** The helper does not call the model. Only the activity function (`chat`, `generateImage`, and the others) sends the request.
-## Related
+### Related pages
-- [Per-Model Type Safety](./per-model-type-safety) — how the adapter+model pair drives `modelOptions` inference.
-- [Tree-Shaking](./tree-shaking) — why each adapter is exported separately, and how the typed-options pattern keeps your bundle small.
-- [Extend Adapter](./extend-adapter) — when you need to add custom models to an adapter without losing the same typed-options ergonomics.
+- [Per-Model Type Safety](./per-model-type-safety): how the adapter and model choose the type of `modelOptions`.
+- [Tree-Shaking](./tree-shaking): why each adapter and helper is a separate export.
+- [Extend Adapter](./extend-adapter): add custom models to an adapter, with the same typed options.
diff --git a/docs/api/ai-angular.md b/docs/api/ai-angular.md
index b14b8b9b03..0c275db9f4 100644
--- a/docs/api/ai-angular.md
+++ b/docs/api/ai-angular.md
@@ -2,7 +2,7 @@
title: "@tanstack/ai-angular"
id: ai-angular
order: 6
-description: "API reference for @tanstack/ai-angular — Angular signal-based injectables including injectChat for streaming chat with full type safety."
+description: "API reference for @tanstack/ai-angular: the injectChat injectable, generation injectables, and Angular signal bindings for the headless TanStack AI client."
keywords:
- tanstack ai
- "@tanstack/ai-angular"
@@ -13,9 +13,7 @@ keywords:
- api reference
---
-Angular signal-based bindings for TanStack AI, providing convenient Angular bindings for the headless client.
-
-> **Injection context requirement:** Every `inject*` function in this package calls Angular's `inject()` internally. They **must** be called within an Angular injection context — a component or directive class field initializer, the constructor, or inside `runInInjectionContext`. Calling them outside an injection context will throw a runtime error.
+Angular injectables for TanStack AI, built on signals. To build your first chat, start with the [Quick Start](../getting-started/quick-start). For a typed chat layout, see [Angular Chat UI](../ui/angular).
## Installation
@@ -25,643 +23,328 @@ angular: @tanstack/ai-angular
-## `injectRegisterWebMCPTools(tools, options?)`
-
-Register executable client tools for the current Angular injection owner. Angular removes them when it destroys that owner.
-
-For a complete setup and behavior guide, see [WebMCP Tools](../tools/webmcp).
+> [!WARNING]
+> Call every `inject*` function in an Angular injection context: a class field initializer, the constructor, or `runInInjectionContext`. Outside one, the call throws.
```typescript
-import { Component } from "@angular/core";
-import {
- injectRegisterWebMCPTools,
- type InjectRegisterWebMCPToolsOptions,
-} from "@tanstack/ai-angular";
-import { searchProducts } from "./tools";
-
-const tools = [searchProducts];
-const options: InjectRegisterWebMCPToolsOptions = {
- onError(error) {
- console.error(error);
- },
-};
-
-@Component({ selector: "app-products", standalone: true, template: "" })
-export class ProductsComponent {
- registration = injectRegisterWebMCPTools(tools, options);
-}
-```
-
-`InjectRegisterWebMCPToolsOptions` contains `toolOptions`, `context`, and `onError`. The injectable owns the registration signal.
-
-The `context` field is required when a tool declares a required runtime context. Call this function only in an Angular injection context.
-
-## `injectPageWebMCPTools(options?)`
-
-Read the WebMCP tools on the page as client tools. The signal starts empty and updates when the page adds or removes a tool. Pass the signal to `injectChat` as `tools`. Call it in an injection context.
-
-```ts
-import { Component } from "@angular/core";
-import { injectPageWebMCPTools } from "@tanstack/ai-angular";
-
-@Component({ selector: "app-page-tools", standalone: true, template: "" })
-export class PageTools {
- pageTools = injectPageWebMCPTools({
- filter: (tool) => tool.origin === location.origin,
- });
-}
-```
-
-`filter` skips a tool when it returns `false`. `onError` gets a failed WebMCP read. For a complete guide, see [Page WebMCP Tools in Chat](../tools/webmcp-page-tools).
-
-## `injectChat(options?)`
-
-Main injectable for managing chat state in Angular with full type safety.
-
-```typescript
-import { Component } from "@angular/core";
+import { Injector, inject, runInInjectionContext } from "@angular/core";
import { injectChat, fetchServerSentEvents } from "@tanstack/ai-angular";
-@Component({
- selector: "app-chat",
- standalone: true,
- template: `...`,
-})
-export class ChatComponent {
- // injectChat is called in a field initializer — valid injection context.
- chat = injectChat({
- connection: fetchServerSentEvents("/api/chat"),
- });
-}
-```
-
-### Options
-
-Extends `ChatClientOptions` from `@tanstack/ai-client` (minus internal state callbacks):
-
-- `connection` - Connection adapter (required, or use `fetcher`)
-- `fetcher?` - Direct async function for one-shot generation (alternative to `connection`)
-- `tools?` - Array of client tool implementations (with `.client()` method). Reactive: pass a `Signal` or getter to change the tools after the chat is created.
-- `initialMessages?` - Initial messages array
-- `threadId?` - The only identity for this chat. Required when persistence is on. If omitted, minted after mount.
-- `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field. Reactive — accepts a plain value, an Angular `Signal`, or a zero-arg getter; changes sync automatically via `effect`
-- `body?` - **Deprecated.** Use `forwardedProps` instead. Still works for backward compatibility; values are merged into `forwardedProps` on the wire. Reactive (same forms as `forwardedProps`)
-- `byok?` - Optional BYOK keyring from `defineByok`. On each send the client prepares the resolved provider and stamps `x-byok-*` request headers. Keys never go in the body
-- `byokProvider?` - Optional function that returns the provider slug for this chat. If it returns a slug, only that key is prepared and sent. Otherwise the merged `provider` from `forwardedProps`, `body`, and per-call `sendMessage` `body` is used. Later sources win. If no slug resolves, the send throws instead of attaching every stored key
-- `context?` - Typed client-local runtime context passed to client tool implementations. Reactive (same forms). This value is not serialized to the server
-- `live?` - Enable live subscription mode (auto-subscribes/unsubscribes). Reactive (same forms)
-- `outputSchema?` - Standard-schema-compatible schema (Zod, Valibot, ArkType, or JSON Schema). When provided, adds typed `partial` and `final` signals to the return value
-- `persistence?` - Persistence configuration
-- `devtools?` - Display options for TanStack AI Devtools
-- `onResponse?` - Callback when response is received
-- `onChunk?` - Callback when stream chunk is received
-- `onFinish?` - Callback when response finishes
-- `onError?` - Callback when error occurs
-- `onInterruptStateChange?` - Callback when interrupt state changes; context source is `hydrate` for restored state and `live` for streamed or client-initiated updates
-- `onCustomEvent?` - Callback for custom stream events
-- `streamProcessor?` - Stream processing configuration
-
-**Reactive options** (`body`, `forwardedProps`, `context`, `live`) accept a `ReactiveOption`, which is one of:
-
-```typescript
-import type { Signal } from "@angular/core";
-
-type ReactiveOption = T | Signal | (() => T);
-```
-
-A plain value becomes a constant; a `Signal` is read directly; a zero-arg getter is wrapped in `computed` so any signals read inside it are tracked.
-
-**Note:** Client tools are automatically executed — no `onToolCall` callback needed!
-
-### Returns
-
-```typescript
-import type { Signal } from "@angular/core";
-import type {
- UIMessage,
- MultimodalContent,
- DeepPartial,
-} from "@tanstack/ai-angular";
-import type { ModelMessage, InferSchemaType } from "@tanstack/ai/client";
-import type {
- ChatClientState,
- ConnectionStatus,
- SendMessageOptions,
-} from "@tanstack/ai-client";
-type TSchema = any;
-
-interface InjectChatResult {
- messages: Signal>;
- sendMessage: (
- content: string | MultimodalContent,
- options?: SendMessageOptions,
- ) => Promise;
- append: (message: ModelMessage | UIMessage) => Promise;
- addToolResult: (result: {
- toolCallId: string;
- tool: string;
- output: any;
- state?: "output-available" | "output-error";
- errorText?: string;
- }) => Promise;
- addToolApprovalResponse: (response: {
- id: string;
- approved: boolean;
- }) => Promise;
- reload: () => Promise;
- stop: () => void;
- clear: () => void;
- setMessages: (messages: ReadonlyArray) => void;
- isLoading: Signal;
- error: Signal;
- status: Signal;
- isSubscribed: Signal;
- connectionStatus: Signal;
- sessionGenerating: Signal;
- isHydrating: Signal;
- // Only present when outputSchema is supplied:
- partial: Signal>>;
- final: Signal | null>;
-}
-```
-
-**Note:** All reactive state (`messages`, `isLoading`, `error`, `status`, `isSubscribed`, `connectionStatus`, `sessionGenerating`, `isHydrating`) is exposed as read-only Angular `Signal`s. Read them by calling them as functions (e.g., `chat.messages()`, `chat.isLoading()`). Cleanup is automatic via `DestroyRef.onDestroy`.
-
-## `injectByok(client)`
-
-Subscribe to a `ByokClient` snapshot in Angular. Call it in an injection context. The return value is a read-only `Signal`.
-
-```typescript
-import { Component } from "@angular/core";
-import { injectByok } from "@tanstack/ai-angular";
-import { byok } from "./byok";
+export class MyComponent {
+ // Field initializer
+ chat = injectChat({ connection: fetchServerSentEvents("/api/chat"), threadId: "support-chat" });
-@Component({
- selector: "app-key-status",
- standalone: true,
- template: `
{{ last4() }}
`,
-})
-export class KeyStatusComponent {
- snapshot = injectByok(byok);
+ private injector = inject(Injector);
- last4() {
- const openai = this.snapshot().status.openai;
- return openai && "masked" in openai ? openai.masked : "No key";
+ // Later, outside the constructor
+ openSecondChat() {
+ return runInInjectionContext(this.injector, () =>
+ injectChat({ connection: fetchServerSentEvents("/api/chat"), threadId: "support-chat" }),
+ );
}
}
```
-`snapshot()` has `status`, `locked`, and `prompt`. Call `byok.update(provider, value)` from your own UI to save a key. See [Bring Your Own Key](../advanced/byok).
-
-## Connection Adapters
+## `injectChat(options)`
-Re-exported from `@tanstack/ai-client` for convenience:
-
-```typescript
-import {
- fetchServerSentEvents,
- fetchHttpStream,
- fetchJson,
- xhrServerSentEvents,
- xhrHttpStream,
- stream,
- rpcStream,
- type ConnectionAdapter,
-} from "@tanstack/ai-angular";
-```
-
-## Example: Basic Chat
+Holds the messages and the run state of one chat, and talks to your server route.
```typescript
import { Component } from "@angular/core";
-import { CommonModule } from "@angular/common";
import { injectChat, fetchServerSentEvents } from "@tanstack/ai-angular";
@Component({
selector: "app-chat",
standalone: true,
- imports: [CommonModule],
template: `
-
- @for (message of chat.messages(); track message.id) {
-
- }
`,
})
export class ChatComponent {
chat = injectChat({
connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
});
}
```
-## Example: Tool Approval
+### Options
+
+You must pass `threadId`. You must also pass `connection` or `fetcher`, not both.
+
+| Option | What it does |
+| --- | --- |
+| `connection` | Connection adapter, for example `fetchServerSentEvents("/api/chat")`. See [Transports](../transports/overview). |
+| `fetcher` | A function that returns a `Response` or a stream. Use it with server functions. |
+| `tools` | Client tools from `.client()`. They run when the model calls them. Reactive. See [Client Tools](../tools/client-tools). |
+| `threadId` | Required. The id of this chat. Use a stable id for each conversation, for example the id from your route. |
+| `initialMessages` | The messages to show first. |
+| `forwardedProps` | JSON sent to the server in the AG-UI `forwardedProps` field, for example `{ model: "gpt-6.1-sol" }`. Reactive. |
+| `persistence` | `true` to load the thread from the server, or a storage adapter. See [Chat Persistence](../persistence/chat-persistence). |
+| `outputSchema` | A Standard Schema. Adds typed `partial` and `final` signals to the return value. See [Streaming structured output](../structured-outputs/streaming). |
+| `context` | Client-only runtime context for client tools. It is not sent to the server. Reactive. |
+| `live` | Subscribe while `live` is `true`. Reactive. |
+| `queue` | What `sendMessage` does while a run is busy. Default: queue the message. See [Queueing](../chat/queueing). |
+| `interrupts` | Definitions from `defineInterrupt()`. See [Interrupts](../interrupts/overview). |
+| `byok`, `byokProvider` | A keyring from `defineByok()`. See [Bring Your Own Key](../advanced/byok). |
+| `devtools` | Display options for TanStack AI Devtools. |
+
+The injectable also takes the `ChatClient` callbacks (`onResponse`, `onChunk`, `onFinish`, `onError`, `onInterruptStateChange`, `onCustomEvent`) and the other client options. See [`ChatClient` options](./ai-client#constructor-options).
+
+A reactive option is a `ReactiveOption`: a plain value, a `Signal`, or a getter `() => T`. A getter runs in `computed`, so the signals it reads are tracked. A change updates the current chat.
```typescript
-import { Component } from "@angular/core";
-import { CommonModule } from "@angular/common";
+import { Component, signal } from "@angular/core";
import { injectChat, fetchServerSentEvents } from "@tanstack/ai-angular";
-@Component({
- selector: "app-approval-chat",
- standalone: true,
- imports: [CommonModule],
- template: `
- @for (message of chat.messages(); track message.id) {
- @for (part of message.parts; track $index) {
- @if (
- part.type === 'tool-call' &&
- part.state === 'approval-requested' &&
- part.approval
- ) {
-
-
Approve: {{ part.name }}
-
- Approve
-
-
- Deny
-
-
- }
- }
- }
- `,
-})
-export class ApprovalChatComponent {
+@Component({ selector: "app-model-chat", standalone: true, template: "" })
+export class ModelChatComponent {
+ model = signal("gpt-6.1-sol");
+
chat = injectChat({
connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
+ forwardedProps: () => ({ model: this.model() }),
});
}
```
-## Example: Client Tools with Type Safety
+### Returns
+
+State values are read-only Angular `Signal`s. Call them to read the value, for example `chat.messages()`. Cleanup runs in `DestroyRef.onDestroy`.
+
+| Field | What it is |
+| --- | --- |
+| `messages` | The `UIMessage` array of the chat. |
+| `sendMessage(content, options?)` | Sends a user message. `content` is a string or `{ content, id?, metadata? }`. `options` takes `whenBusy` and `body`. |
+| `isLoading` | `true` while a response streams. |
+| `error` | The last error, or `undefined`. |
+| `status` | `"ready"`, `"submitted"`, `"streaming"`, or `"error"`. |
+| `stop()` | Stops the current response. |
+| `reload()` | Runs the last user message again. |
+| `append(message)` | Adds a `UIMessage` or `ModelMessage` and starts a run. |
+| `addToolResult(result)` | Gives the result of a client tool by hand: `{ toolCallId, tool, output, state?, errorText? }`. |
+| `setMessages(messages)`, `clear()` | Replace or clear the messages. |
+| `queue`, `cancelQueued(id)` | Messages that wait while a run is busy. See [Queueing](../chat/queueing). |
+| `runId` | The id of the run in flight, or `null`. |
+| `interrupts` | Pending interrupts. Each item has `resolveInterrupt(...)`. See [Interrupts](../interrupts/overview). |
+| `resolveInterrupts`, `cancelInterrupts`, `retryInterrupts`, `resumeInterruptsUnsafe` | Act on all pending interrupts at once. |
+| `interruptErrors`, `resuming` | Errors from the last resume, and `true` while a resume runs. |
+| `isHydrating` | `true` while the chat loads from persistence. |
+| `hasOlderMessages`, `loadOlderMessages()` | Page in older messages. See [Chat Persistence](../persistence/chat-persistence). |
+| `isSubscribed`, `connectionStatus`, `sessionGenerating` | State of the live subscription. |
+| `partial`, `final` | Structured output. Only when you pass `outputSchema`. |
+
+### Example: tool approval
+
+A tool with `needsApproval: true` pauses the run. The pending call shows up in `interrupts`.
```typescript
import { Component } from "@angular/core";
-import { CommonModule } from "@angular/common";
-import { injectChat, fetchServerSentEvents } from "@tanstack/ai-angular";
-import {
- createChatClientOptions,
- type InferChatMessages,
-} from "@tanstack/ai-client";
import { toolDefinition } from "@tanstack/ai";
+import { injectChat, fetchServerSentEvents } from "@tanstack/ai-angular";
import { z } from "zod";
-const updateUIDef = toolDefinition({
- name: "updateUI",
- description: "Show a notification in the UI",
- inputSchema: z.object({ message: z.string(), type: z.string() }),
-});
-
-const saveToStorageDef = toolDefinition({
- name: "saveToStorage",
- description: "Save a value to localStorage",
- inputSchema: z.object({ key: z.string(), value: z.string() }),
+const deleteFileTool = toolDefinition({
+ name: "delete_file",
+ description: "Delete a file",
+ needsApproval: true,
+ inputSchema: z.object({ path: z.string() }),
});
@Component({
- selector: "app-typed-chat",
+ selector: "app-approval-chat",
standalone: true,
- imports: [CommonModule],
template: `
- @for (message of chat.messages(); track message.id) {
- @for (part of message.parts; track $index) {
- @if (part.type === 'tool-call' && part.name === 'updateUI') {
-
- }
- `,
-})
-export class ReactiveChatComponent {
- language = signal("en");
+This package does not export `createChatHook` from its main entry. `@tanstack/ai-angular/ui` has a `createChatHook` that binds chat options and typed UI components. See [Angular Chat UI](../ui/angular).
- // forwardedProps is reactive — the signal is read on every request
- chat = injectChat({
- connection: fetchServerSentEvents("/api/chat"),
- forwardedProps: () => ({ language: this.language() }),
- });
+## Generation hooks
- toggleLanguage() {
- this.language.set(this.language() === "en" ? "fr" : "en");
- }
-}
-```
+Each injectable runs one generation request and keeps its result. See [Generation Hooks](../media/generation-hooks).
-## Example: Structured Output
+| Injectable | `generate()` input | `result` |
+| --- | --- | --- |
+| `injectGenerateImage` | `ImageGenerateInput` | `ImageGenerationResult` |
+| `injectGenerateAudio` | `AudioGenerateInput` | `AudioGenerationResult` |
+| `injectGenerateSpeech` | `SpeechGenerateInput` | `TTSResult` |
+| `injectTranscription` | `TranscriptionGenerateInput` | `TranscriptionResult` |
+| `injectSummarize` | `SummarizeGenerateInput` | `SummarizationResult` |
+| `injectGenerateVideo` | `VideoGenerateInput` | `VideoGenerateResult` |
+| `injectGeneration` | Your own input type | Your own result type |
```typescript
import { Component } from "@angular/core";
-import { injectChat, fetchServerSentEvents } from "@tanstack/ai-angular";
-import { z } from "zod";
-
-const recipeSchema = z.object({
- title: z.string(),
- ingredients: z.array(z.string()),
- steps: z.array(z.string()),
-});
+import { injectGenerateImage, fetchServerSentEvents } from "@tanstack/ai-angular";
@Component({
- selector: "app-recipe-chat",
+ selector: "app-image",
standalone: true,
template: `
- Ask
- @if (chat.partial().title) {
-
{{ chat.partial().title }}
- }
- @if (chat.final()) {
-
- @for (step of chat.final()!.steps; track $index) {
-
{{ step }}
- }
-
- }
+
+ Generate
+
`,
})
-export class RecipeChatComponent {
- chat = injectChat({
- connection: fetchServerSentEvents("/api/chat"),
- outputSchema: recipeSchema,
+export class ImageComponent {
+ image = injectGenerateImage({
+ connection: fetchServerSentEvents("/api/generate/image"),
+ threadId: "image-studio",
});
}
```
-## Generation Injectables
+Options: `threadId` (required), `connection` or `fetcher`, `body` (reactive), `persistence`, `byok`, `byokProvider`, `devtools`, `onResult`, `onError`, `onProgress`, `onChunk`.
-Angular injectables for one-shot generation tasks (images, audio, speech, transcription, summarization, video). All share the same pattern: provide a `connection` or `fetcher`, call `generate()`, and read reactive signals.
+Returns: `generate(input)`, `result`, `isLoading`, `error`, `status`, `stop()`, `reset()`, `runId`. The state fields are read-only signals. Cleanup runs in `DestroyRef.onDestroy`.
-### `injectGeneration(options)`
+`injectGenerateVideo` also returns `jobId` and `videoStatus` signals, and takes `onJobCreated` and `onStatusUpdate`.
-Base injectable for custom generation types. All specialized injectables below are built on this.
+## Realtime chat
-```typescript
-import { Component } from "@angular/core";
-import { injectGeneration } from "@tanstack/ai-angular";
-import { fetchServerSentEvents } from "@tanstack/ai-client";
+This package does not export a realtime injectable. Use `RealtimeClient`, which this package re-exports. See [Realtime Chat](../media/realtime-chat).
-@Component({ selector: "app-custom", standalone: true, template: `...` })
-export class CustomGenerationComponent {
- gen = injectGeneration({
- connection: fetchServerSentEvents("/api/generate/custom"),
- });
-
- // Call gen.generate(input), read gen.result(), gen.isLoading(), etc.
-}
-```
+## `injectAudioRecorder(options?)`
-**Options:** `connection?`, `fetcher?`, `threadId?`, `body?` (reactive), `devtools?`, `onResult?`, `onError?`, `onProgress?`, `onChunk?`
+Records audio from the microphone. See [Audio Recording](../media/audio-recording).
-**Returns:** `generate`, `result`, `isLoading`, `error`, `status`, `stop`, `reset`, `runId`. All reactive state is a read-only `Signal`.
+Returns `recording` and `isRecording` (signals), `isSupported`, `start()`, `stop()`, and `cancel()`. Pass `onComplete` to transform the recording. Then `recording` and `stop()` use the transformed type.
-### `injectGenerateImage(options)`
+## `injectByok(client)`
-Image generation injectable. `generate()` accepts `ImageGenerateInput`, result is `ImageGenerationResult`.
+Subscribes to a BYOK keyring and returns a read-only `Signal` of its snapshot. See [Bring Your Own Key](../advanced/byok).
```typescript
-import { Component } from "@angular/core";
-import { injectGenerateImage } from "@tanstack/ai-angular";
-import { fetchServerSentEvents } from "@tanstack/ai-client";
+import { Component, computed } from "@angular/core";
+import { injectByok } from "@tanstack/ai-angular";
+import { byok } from "./byok";
@Component({
- selector: "app-image",
+ selector: "app-key-status",
standalone: true,
- template: `
-
- Generate
-
- @if (gen.result()) {
-
- }
- `,
+ template: `
{{ last4() }}
`,
})
-export class ImageComponent {
- gen = injectGenerateImage({
- connection: fetchServerSentEvents("/api/generate/image"),
- });
-}
-```
-
-### `injectGenerateAudio(options)`
-
-Audio generation injectable (music, sound effects). `generate()` accepts `AudioGenerateInput`, result is `AudioGenerationResult`.
-
-```typescript
-import { Component } from "@angular/core";
-import { injectGenerateAudio } from "@tanstack/ai-angular";
-import { fetchServerSentEvents } from "@tanstack/ai-client";
+export class KeyStatusComponent {
+ snapshot = injectByok(byok);
-@Component({
- selector: "app-audio",
- standalone: true,
- template: `
-
- Generate
-
- @if (gen.result()) {
-
- }
- `,
-})
-export class AudioComponent {
- gen = injectGenerateAudio({
- connection: fetchServerSentEvents("/api/generate/audio"),
+ last4 = computed(() => {
+ const openai = this.snapshot().status.openai;
+ return openai && "masked" in openai ? openai.masked : "No key";
});
}
```
-### `injectGenerateSpeech(options)`
+The snapshot has `status`, `locked`, `prompt`, and `storageError`. Save a key from your own UI with `byok.update(provider, value)`.
-Text-to-speech injectable. `generate()` accepts `SpeechGenerateInput`, result is `TTSResult`.
+## MCP App bridge
-### `injectTranscription(options)`
+This package does not export a bridge injectable. Use `createMcpAppBridge`, which this package re-exports. See [MCP Apps](../mcp/apps).
-Audio transcription injectable. `generate()` accepts `TranscriptionGenerateInput`, result is `TranscriptionResult`.
-
-### `injectSummarize(options)`
-
-Text summarization injectable. `generate()` accepts `SummarizeGenerateInput`, result is `SummarizationResult`.
-
-### `injectGenerateVideo(options)`
+## `injectRegisterWebMCPTools(tools, options?)`
-Video generation injectable with job polling. Returns additional `jobId` and `videoStatus` signals. Accepts extra `onJobCreated?` and `onStatusUpdate?` callbacks.
+Exposes client tools to the browser WebMCP API for the current injection owner. Angular removes them when it destroys that owner. See [WebMCP Tools](../tools/webmcp).
```typescript
import { Component } from "@angular/core";
-import { injectGenerateVideo } from "@tanstack/ai-angular";
-import { fetchServerSentEvents } from "@tanstack/ai-client";
+import { injectRegisterWebMCPTools } from "@tanstack/ai-angular";
+import { searchProducts } from "./tools";
-@Component({
- selector: "app-video",
- standalone: true,
- template: `
-
- Generate
-
- @if (gen.videoStatus()) {
-
Status: {{ gen.videoStatus()!.status }}
- }
- @if (gen.result()) {
-
- }
- `,
-})
-export class VideoComponent {
- gen = injectGenerateVideo({
- connection: fetchServerSentEvents("/api/generate/video"),
- onJobCreated: (jobId) => console.log("Job created:", jobId),
+@Component({ selector: "app-products", standalone: true, template: "" })
+export class ProductsComponent {
+ registration = injectRegisterWebMCPTools([searchProducts], {
+ onError: (error) => console.error(error),
});
}
```
-**Additional returns (video only):**
-- `jobId: Signal` — The polling job ID, once the server creates it
-- `videoStatus: Signal` — Real-time status updates from the polling loop
+Options: `toolOptions`, `context`, and `onError`. `context` is required when a tool declares a required runtime context.
-All generation injectables automatically clean up via `DestroyRef.onDestroy`.
-
-## Injection Context
+## `injectPageWebMCPTools(options?)`
-Angular's DI system requires that `inject()` is called during component construction. Every `inject*` function in this package calls `inject()` internally. Valid call sites:
+Reads the WebMCP tools that the page registered, as client tools. The signal starts empty and updates when the page adds or removes a tool. Pass the signal to `injectChat` as `tools`. See [Page WebMCP Tools in Chat](../tools/webmcp-page-tools).
```typescript
-import { inject, runInInjectionContext, Injector } from "@angular/core";
-import { injectChat, fetchServerSentEvents } from "@tanstack/ai-angular";
-
-const injector = inject(Injector);
-
-// Field initializer (recommended)
-export class MyComponent {
- chat = injectChat({ connection: fetchServerSentEvents("/api/chat") });
-}
+import { Component } from "@angular/core";
+import { injectPageWebMCPTools } from "@tanstack/ai-angular";
-// Constructor
-export class MyComponentAlt {
- chat: ReturnType;
- constructor() {
- this.chat = injectChat({ connection: fetchServerSentEvents("/api/chat") });
- }
+@Component({ selector: "app-page-tools", standalone: true, template: "" })
+export class PageToolsComponent {
+ pageTools = injectPageWebMCPTools({
+ filter: (tool) => tool.origin === location.origin,
+ });
}
-
-// Inside runInInjectionContext
-const chat = runInInjectionContext(injector, () =>
- injectChat({ connection: fetchServerSentEvents("/api/chat") }),
-);
```
-## `createChatClientOptions(options)`
+Options: `filter` (return `false` to skip a tool) and `onError`.
-Helper to create typed chat options (re-exported from `@tanstack/ai-client`).
+## Re-exports from `@tanstack/ai-client`
-```typescript
-import {
- createChatClientOptions,
- type InferChatMessages,
-} from "@tanstack/ai-client";
-import { fetchServerSentEvents } from "@tanstack/ai-angular";
-import { tool1, tool2 } from "./tools";
-
-// Create typed tools array (no 'as const' needed!)
-const tools = [tool1, tool2];
-
-const chatOptions = createChatClientOptions({
- connection: fetchServerSentEvents("/api/chat"),
- tools,
-});
+You can import these from `@tanstack/ai-angular` directly. See [`@tanstack/ai-client`](./ai-client) for details.
-type Messages = InferChatMessages;
-```
+| Group | Exports |
+| --- | --- |
+| Connection adapters | `fetchServerSentEvents`, `fetchHttpStream`, `fetchJson`, `xhrServerSentEvents`, `xhrHttpStream`, `webSocket`, `stream`, `rpcStream` |
+| Typed options | `createChatClientOptions`, `clientTools` |
+| Persistence | `localStoragePersistence`, `sessionStoragePersistence`, `indexedDBPersistence` |
+| Clients | `ChatClient`, `GenerationClient`, `VideoGenerationClient`, `RealtimeClient`, `AudioRecorder`, `InterruptManager` |
+| WebMCP and MCP Apps | `registerWebMCPTools`, `getWebMCPTools`, `subscribeWebMCPTools`, `createMcpAppBridge` |
+| Errors | `UnsupportedResponseStreamError`, `StreamTruncatedError`, `DurableStreamIncompleteError`, `StreamReconnectLimitError`, `StorageUnavailableError` |
+| Stream helpers | `StreamProcessor`, chunk strategies, `parsePartialJSON`, message converters |
+
+BYOK helpers (`defineByok`, `defaultByokStorage`, and more) are on the `@tanstack/ai-angular/byok` subpath.
## Types
-Re-exported from `@tanstack/ai-angular` (sourced from `@tanstack/ai-client`):
-
-- `UIMessage` - Message type with tool type parameter
-- `InjectChatOptions` - Chat injectable options
-- `InjectChatResult` - Chat injectable return type
-- `ReactiveOption` - Union of `T | Signal | (() => T)` for reactive option fields
-- `DeepPartial` - Recursive partial; used to type the in-flight `partial` value
-- `ChatRequestBody` - Request body type
-- `MultimodalContent` - Multimodal content type for `sendMessage`
-- `ConnectionAdapter` - Connection adapter interface
-- `InferChatMessages` - Extract message type from options
-- `GenerationClientState` - Generation lifecycle state
-- `ImageGenerateInput` - Image generation input type
-- `AudioGenerateInput` - Audio generation input type
-- `SpeechGenerateInput` - Speech generation input type
-- `TranscriptionGenerateInput` - Transcription input type
-- `SummarizeGenerateInput` - Summarization input type
-- `VideoGenerateInput` - Video generation input type
-- `VideoGenerateResult` - Video generation result type
-- `VideoStatusInfo` - Video job status info
-
-Tool authoring types — import directly from `@tanstack/ai` (not re-exported by `@tanstack/ai-angular`):
-
-- `toolDefinition()` - Create isomorphic tool definition
-- `ToolDefinitionInstance` - Tool definition type
-- `ClientTool` - Client tool type
-- `ServerTool` - Server tool type
-
-## Next Steps
-
-- [Getting Started](../getting-started/quick-start) - Learn the basics
-- [Tools Guide](../tools/tools) - Learn about the isomorphic tool system
-- [Client Tools](../tools/client-tools) - Learn about client-side tools
+| Type | What it is |
+| --- | --- |
+| `InjectChatOptions`, `InjectChatResult` | The options and return value of `injectChat`. |
+| `ReactiveOption` | `T`, `Signal`, or `() => T`. |
+| `UIMessage` | A chat message with typed parts. |
+| `InferChatMessages` | The message type for a `createChatClientOptions` result. |
+| `MultimodalContent`, `SendMessageOptions`, `WhenBusy`, `QueueOption`, `QueuedMessage` | Send and queue types. |
+| `DeepPartial` | The type of `partial` while structured output streams. |
+| `InjectOptions`, `InjectResult` | Options and return types for the generation and recorder injectables. The WebMCP injectables export only an options type. |
+
+Pass a plain tools array to `injectChat` or `createChatClientOptions`. Tool names, inputs, and outputs stay typed without `as const`. Import `toolDefinition` from `@tanstack/ai`. This package does not re-export it.
diff --git a/docs/api/ai-client.md b/docs/api/ai-client.md
index 97f191379e..1602461ff7 100644
--- a/docs/api/ai-client.md
+++ b/docs/api/ai-client.md
@@ -2,7 +2,7 @@
title: "@tanstack/ai-client"
slug: /api/ai-client
order: 2
-description: "API reference for @tanstack/ai-client — the framework-agnostic headless client for managing chat state and streaming transports."
+description: "API reference for @tanstack/ai-client: ChatClient, connection adapters, generation clients, and BYOK, with no UI framework."
keywords:
- tanstack ai
- "@tanstack/ai-client"
@@ -13,9 +13,7 @@ keywords:
- api reference
---
-Framework-agnostic headless client for managing chat state and streaming.
-
-For typed headless chat UI types and selectors, import `@tanstack/ai-client/ui`. See [Custom Chat UI Adapters](../ui/custom-adapters).
+The headless client under every framework package. Use it directly for vanilla JavaScript or a custom binding. For typed chat UI types and selectors, import `@tanstack/ai-client/ui` and see [Custom Chat UI Adapters](../ui/custom-adapters).
## Installation
@@ -32,89 +30,30 @@ octane: @tanstack/ai-client
-## `registerWebMCPTools(tools, options)`
-
-Expose executable client tools through the browser WebMCP API. Abort the required signal to remove all tools from this registration.
-
-For a complete setup and behavior guide, see [WebMCP Tools](../tools/webmcp).
-
-```typescript
-import {
- registerWebMCPTools,
- type RegisterWebMCPToolsOptions,
-} from "@tanstack/ai-client";
-import { searchProducts } from "./tools";
-
-const controller = new AbortController();
-const tools = [searchProducts];
-const options: RegisterWebMCPToolsOptions = {
- signal: controller.signal,
- toolOptions: {
- searchProducts: {
- title: "Search products",
- annotations: { readOnlyHint: true },
- },
- },
-};
-
-await registerWebMCPTools(tools, options);
-
-// Remove these tools when their owner is no longer active.
-controller.abort();
-```
-
-The function returns `Promise`. It resolves without registration during server rendering, in an insecure context, or when WebMCP is unavailable.
-
-It validates Standard Schema inputs and outputs. A tool with `needsApproval: true` causes registration to fail.
-
-### Public option types
-
-- `RegisterWebMCPToolsOptions` - Requires `signal`. It also contains `toolOptions` and the client tool runtime `context`.
-- `WebMCPToolOptionsByName` - A partial options map keyed by the inferred tool names.
-- `WebMCPToolOptions` - Contains the optional `title` and `annotations` fields for one tool.
-- `WebMCPToolAnnotations` - Contains the optional `readOnlyHint` and `untrustedContentHint` fields.
-
-`context` is required when a tool declares a required runtime context. If registration fails, the function removes tools that it registered during the call.
-
## `ChatClient`
-The main client class for managing chat state.
+Holds the messages and the run state of one chat, and talks to your server route.
```typescript
-import {
- ChatClient,
- fetchServerSentEvents,
- type UIMessage,
-} from "@tanstack/ai-client";
-import { myClientTool } from "./tools";
+import { ChatClient, fetchServerSentEvents } from "@tanstack/ai-client";
const client = new ChatClient({
connection: fetchServerSentEvents("/api/chat"),
- initialMessages: [],
- tools: [myClientTool],
- onMessagesChange: (messages: UIMessage[]) => {
- console.log("Messages updated:", messages);
- },
+ threadId: "support-chat",
+});
+
+const unsubscribe = client.subscribeSnapshot(() => {
+ const { messages, isLoading } = client.getSnapshot();
+ console.log(messages.length, isLoading);
});
-// A new client is IDLE. Attach it when your view appears, detach when it goes.
client.attach();
+await client.sendMessage("Hello!");
```
### Lifecycle: `attach()` and `detach()`
-One page can hold many chats. A browser allows only about six connections to one
-origin, and a chat that is tailing a run holds one for as long as that run lasts. If
-every chat held a connection, a handful of open views would use every slot and every
-other request would queue behind them, including the request that loads your messages.
-
-So the connection follows the view. A new client holds none, `attach()` starts it, and
-`detach()` stops it.
-
-If you use a framework package (`@tanstack/ai-react`, `-vue`, `-solid`, `-svelte`,
-`-preact`, `-angular`), the hook already does this: it attaches when its view mounts
-and detaches when it unmounts. Call these yourself only when you use `ChatClient`
-directly.
+A new client opens no connection. When your view appears, call `attach()`. When it goes away, call `detach()`. The framework hooks do this for you on mount and unmount.
```typescript
import { ChatClient, fetchServerSentEvents } from "@tanstack/ai-client";
@@ -125,70 +64,89 @@ const client = new ChatClient({
persistence: true,
});
-client.attach(); // start: rejoin a run in progress, and load the thread
-client.detach(); // stop: drop the connection, keep messages and the run pointer
-```
-
-What each one guarantees:
-
-- `attach()` is safe to call more than once. Attaching an attached client does nothing.
-- `detach()` keeps the transcript, the resume pointer and the run id. The run keeps
- going on the server while nobody watches, so re-attaching repaints at once and picks
- it back up from the durable log.
-- `detach()` is neither `stop()` (which ends the run) nor `dispose()` (which ends the
- client). It says only that no view is watching right now.
-- A chat with no persistence has no resume pointer and no stored thread, so `attach()`
- issues no request at all.
-
-#### Migrating from constructor tailing
-
-Earlier versions started tailing inside the constructor. If you build a `ChatClient`
-yourself, add `client.attach()` where your view appears and `client.detach()` where it
-goes away. Users of the framework hooks need no change.
-
-### Constructor Options
-
-- `connection` - Connection adapter for streaming
-- `initialMessages?` - Initial messages array
-- `threadId?` - The only identity for this chat. Required when persistence is on. If omitted, minted after mount.
-- `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
-- `byok?` - Optional BYOK keyring from [`defineByok`](#definebyok). On each send the client prepares the resolved provider and stamps `x-byok-*` request headers. Keys never go in the body
-- `byokProvider?` - Optional function that returns the provider slug for this chat. If it returns a slug, only that key is prepared and sent. Otherwise the merged `provider` from `forwardedProps`, `body`, and per-call `sendMessage` `body` is used. Later sources win. If no slug resolves, the send throws instead of attaching every stored key
-- `context?` - Typed client-local runtime context passed to client tool implementations. This value is not serialized to the server
-- `tools?` - Registered `.client()` tool implementations. The client automatically executes matching tools when the model calls them
-- `onResponse?` - Callback when response is received
-- `onChunk?` - Callback when stream chunk is received
-- `onFinish?` - Callback when response finishes
-- `onError?` - Callback when error occurs
-- `onInterruptStateChange?` - Callback when interrupt state changes; context source is `hydrate` for restored state and `live` for streamed or client-initiated updates
-- `onMessagesChange?` - Callback when messages change
-- `onLoadingChange?` - Callback when loading state changes
-- `onHydratingChange?` - Callback when the client starts or stops loading the chat from persistence
-- `onErrorChange?` - Callback when error state changes
-- `streamProcessor?` - Stream processing configuration
+client.attach(); // rejoin a run in progress, and load the thread
+client.detach(); // drop the connection, keep the messages and the run pointer
+```
+
+The connection follows the view because a browser allows only about six connections to one origin. A chat that follows a run holds one connection for as long as the run lasts. If every chat on a page held one, the other requests wait behind them.
+
+- `attach()` is safe to call more than once. A second call does nothing.
+- `detach()` keeps the messages, the resume pointer, and the run id. The run goes on at the server, and a later `attach()` picks it up from the durable log.
+- `detach()` does not end the run (that is `stop()`) or the client (that is `dispose()`).
+- A chat with no persistence has nothing to rejoin, so `attach()` sends no request.
+
+### Constructor options
+
+You must pass `threadId`. You must also pass `connection` or `fetcher`, not both.
+
+| Option | What it does |
+| --- | --- |
+| `connection` | Connection adapter. See [Connection adapters](#connection-adapters). |
+| `fetcher` | A function that gets `{ messages, data, threadId, runId, ... }` and an abort `signal`, and returns a `Response` or a stream. Use it with server functions. |
+| `tools` | Client tools from `.client()`. The client runs them when the model calls them. See [Client Tools](../tools/client-tools). |
+| `threadId` | Required. The id of this chat. Use a stable id for each conversation, for example the id from your route. |
+| `persistence` | `true` to load the thread from the server, or a storage adapter. See [Chat Persistence](../persistence/chat-persistence). |
+| `history` | `{ pageSize }`: load only the newest messages first. Only with `persistence: true`. |
+| `initialMessages` | The messages to show first. |
+| `forwardedProps` | JSON sent to the server in the AG-UI `RunAgentInput.forwardedProps` field. |
+| `context` | Client-only runtime context for client tools. It is not sent to the server. |
+| `queue` | What `sendMessage` does while a run is busy: `"queue"` (default), `"drop"`, `"interrupt"`, a config, or a function. See [Queueing](../chat/queueing). |
+| `interrupts` | Definitions from `defineInterrupt()`. See [Interrupts](../interrupts/overview). |
+| `byok` | A keyring from [`defineByok`](#definebyok). The client sends the key in `x-byok-*` headers, never in the body. |
+| `byokProvider` | A function that returns the provider slug for this chat. If it returns nothing, the client uses `provider` from the merged `forwardedProps` and send `body`. If no slug resolves, the send throws. It does not send every stored key. |
+| `streamProcessor` | `{ chunkStrategy }`. Controls when text updates are applied. Default: `ImmediateStrategy`, every chunk. |
+| `initialResumeSnapshot` | A saved resume state to restore pending interrupts after a reload. |
+| `devtools`, `devtoolsBridgeFactory` | Devtools metadata, and the bridge factory from `@tanstack/ai-client/devtools`. |
+
+Callbacks:
+
+| Callback | When it runs |
+| --- | --- |
+| `onResponse`, `onChunk`, `onFinish`, `onError` | When a response arrives, a chunk arrives, a response ends, or an error occurs. |
+| `onMessagesChange`, `onLoadingChange`, `onErrorChange`, `onStatusChange` | When that piece of state changes. |
+| `onSubscriptionChange`, `onConnectionStatusChange`, `onSessionGeneratingChange` | When the live subscription changes. |
+| `onHydratingChange` | When loading from persistence starts or stops. |
+| `onQueueChange`, `onRunIdChange` | When the queue or the run id changes. |
+| `onInterruptStateChange` | When interrupt state changes. The second argument is `{ source: "hydrate" }` for restored state and `{ source: "live" }` otherwise. |
+| `onResumeStateChange` | When the resume state or pending interrupts change. |
+| `onCustomEvent` | When a server tool calls `emitCustomEvent()`. See [Custom Events](../protocol/custom-events). |
### Methods
-#### `sendMessage(content: string | MultimodalContent, body?, sendOptions?)`
+| Method | What it does |
+| --- | --- |
+| `sendMessage(content, body?, sendOptions?)` | Sends a user message and starts a run. |
+| `append(message)` | Adds a `UIMessage` or `ModelMessage` and starts a run. |
+| `reload()` | Removes the answer after the last user message and runs that message again. |
+| `stop()` | Aborts the request in flight. Client tool results for that turn are ignored. |
+| `clear()` | Clears all messages. |
+| `setMessagesManually(messages)` | Replaces the messages. |
+| `addToolResult(result)` | Gives a client tool result by hand: `{ toolCallId, tool, output, state?, errorText? }`. |
+| `attach()`, `detach()` | Start and stop the connection for a view. |
+| `subscribe(options?)`, `unsubscribe()` | Start and stop the live subscription loop. |
+| `getSnapshot()`, `subscribeSnapshot(listener)` | Read the UI state, and listen for changes. |
+| `getInterrupts()` | Pending interrupts, each with `resolveInterrupt(...)`. |
+| `resolveInterrupts(...)`, `cancelInterrupts()`, `retryInterrupts()`, `resumeInterruptsUnsafe(resume, state?)` | Act on all pending interrupts at once. |
+| `cancelQueued(id)`, `getQueue()` | Cancel or read queued messages. |
+| `loadOlderMessages()`, `getHasOlderMessages()` | Page in older messages. |
+| `updateOptions(options)` | Change the transport, `forwardedProps`, `tools`, `context`, `queue`, BYOK, or callbacks. |
+| `dispose()` | Ends the client: stops the subscription and releases devtools. |
-Sends a user message and starts the run.
+Read single values with `getMessages()`, `getIsLoading()`, `getStatus()`, `getError()`, `getIsHydrating()`, `getIsSubscribed()`, `getConnectionStatus()`, `getSessionGenerating()`, and `getCurrentRunId()`.
-`MultimodalContent` is `{ content, id?, metadata? }`. The string form has no metadata. Pass the object form to stamp `metadata` on the user `UIMessage`. TanStack writes the `tanstack` key. Your keys stay at the top of the bag.
+#### `sendMessage(content, body?, sendOptions?)`
-The second argument is extra JSON merged into this request's `forwardedProps`. The third argument is `SendMessageOptions`. `{ whenBusy }` overrides the queue policy for this send. `{ body }` is extra JSON too.
+`content` is a string or `MultimodalContent`: `{ content, id?, metadata? }`. Use the object form to put `metadata` on the user `UIMessage`. TanStack writes the `tanstack` key in `metadata`. Your keys stay at the top level.
-Chat-level `body`, the positional argument, and `sendOptions.body` shallow-merge. `sendOptions.body` wins on key collisions.
+`body` and `sendOptions.body` are extra JSON for this request. They shallow-merge with the chat `forwardedProps`. On a key collision, `sendOptions.body` wins. `sendOptions.whenBusy` overrides the queue policy for this send.
-Framework hooks expose `sendMessage(content, sendOptions)` with no positional body. Pass `{ body }` there. It merges with the hook's `body` the same way.
+The framework hooks expose `sendMessage(content, sendOptions)` with no positional `body`. Pass `{ body }` there.
```typescript
import { client } from "./client";
-await client.sendMessage("Hello!");
-
await client.sendMessage({
- content: "Show me failed logins",
+ content: "Summarize the attached files",
metadata: { author: { id: "user-42", name: "Dana" } },
});
@@ -198,7 +156,7 @@ await client.sendMessage("Summarize the attached files", undefined, {
});
```
-Your server reads the merged JSON from `chatParamsFromRequest`:
+On the server, read the merged JSON from `chatParamsFromRequest`:
```typescript
import {
@@ -209,451 +167,236 @@ import {
import { openaiText } from "@tanstack/ai-openai";
export async function POST(request: Request) {
- const { messages, forwardedProps } = await chatParamsFromRequest(request);
+ const { messages, forwardedProps, threadId } = await chatParamsFromRequest(request);
+ const attachmentIds = forwardedProps.attachmentIds;
+ if (Array.isArray(attachmentIds)) {
+ // Look up the uploads. Do not add them to `messages`.
+ }
const stream = chat({
- adapter: openaiText("gpt-5.5"),
+ adapter: openaiText("gpt-6.1-sol"),
messages,
+ threadId,
});
- if (
- forwardedProps &&
- typeof forwardedProps === "object" &&
- "attachmentIds" in forwardedProps
- ) {
- const { attachmentIds } = forwardedProps;
- if (Array.isArray(attachmentIds) && attachmentIds.length > 0) {
- // Look up the uploads. Do not add them to `messages`.
- }
- }
return toServerSentEventsResponse(stream);
}
```
-#### `append(message: ModelMessage | UIMessage)`
-
-Appends a message to the conversation. If you pass a `UIMessage`, `append` copies `uiMessage.metadata` onto the stored message.
-
-`append()` resolves after the full HTTP response is processed when this call starts the stream. If a stream is already in progress, this call queues the send and the returned promise can resolve before that queued response is processed. A `RUN_FINISHED` with `finishReason: "tool_calls"` does not end the wait when the agent loop continues in that response.
-
-```typescript
-import { client } from "./client";
-import type { UIMessage } from "@tanstack/ai-client";
-
-await client.append({
- role: "user",
- content: "Additional context",
-});
-
-const stamped: UIMessage = {
- id: "user-1",
- role: "user",
- parts: [{ type: "text", content: "Show me failed logins" }],
- metadata: { author: { id: "user-42", name: "Dana" } },
-};
-await client.append(stamped);
-```
-
-#### `reload()`
-
-Reloads the last assistant message.
-
-```typescript
-import { client } from "./client";
+If `append()` starts the stream, it resolves after the full HTTP response is processed. If a stream is already in progress, the call queues the send, and the promise can resolve before the queued response is processed. A `RUN_FINISHED` with `finishReason: "tool_calls"` does not end the wait when the agent loop goes on in the same response.
-await client.reload();
-```
+`append()` copies `metadata` from a `UIMessage` onto the stored message.
-#### `attach()`
+After `stop()`, a later `addToolResult()` for the stopped turn is ignored. A new user message starts a new turn, and `addToolResult()` applies to that turn.
-Start tailing. Rejoins a run that is still in progress and, in server-authoritative
-mode, loads the stored thread. Idempotent. See
-[Lifecycle](#lifecycle-attach-and-detach).
+#### `getSnapshot()` and `subscribeSnapshot(listener)`
-#### `detach()`
+The snapshot has `messages`, `status`, `isLoading`, `error`, `queue`, `runId`, `isSubscribed`, `connectionStatus`, `sessionGenerating`, `hasOlderMessages`, `isHydrating`, and `interruptState`.
-Stop tailing and drop the connection. Keeps messages, the run pointer and the run
-id, so a later `attach()` continues where it left off. See
-[Lifecycle](#lifecycle-attach-and-detach).
+- The snapshot, `messages`, `queue`, and their entries are frozen copies. `messages.push` throws. Use `setMessagesManually`.
+- `interruptState` is not copied.
+- `subscribeSnapshot(listener)` returns an unsubscribe function. It does not fire with the current value, so read `getSnapshot()` first.
+- `subscribeSnapshot` is not `subscribe()`. `subscribe()` starts the live connection loop.
-#### `getSnapshot()`
+## Connection adapters
-Returns the current `ChatClientSnapshot`. The field names are
-`messages`, `status`, `isLoading`, `error`, `queue`, `runId`, `isSubscribed`,
-`connectionStatus`, `sessionGenerating`, `hasOlderMessages`, `isHydrating`,
-and `interruptState`. The snapshot object, `messages`, `queue`, and their
-entries are frozen copies. `interruptState` is not copied. In-place
-`messages.push` throws. Use `setMessages`.
+Each adapter pairs with a server response helper from `@tanstack/ai`. See [Transports](../transports/overview).
-`subscribeSnapshot(listener)` returns an unsubscribe function. It does not
-fire with the current value. Read `getSnapshot()` first. This is not
-`subscribe()`, which starts the live connection loop.
+| Adapter | Server pair | Use it when |
+| --- | --- | --- |
+| `fetchServerSentEvents(url, options?)` | `toServerSentEventsResponse()` | Default for browsers. |
+| `fetchHttpStream(url, options?)` | `toHttpResponse()` | You want newline-delimited JSON. |
+| `fetchJson(url, options?)` | `toJsonResponse()` | The host cannot stream. See [JSON](../transports/json). |
+| `xhrHttpStream(url, options?)` | `toHttpResponse()` | React Native and Expo. See [React Native](../transports/react-native). |
+| `xhrServerSentEvents(url, options?)` | `toServerSentEventsResponse()` | XHR progress events work better than streaming `fetch`. |
+| `webSocket(url, options?)` | `toWebSocketResponse()` | You want one socket for many runs. See [WebSockets](../transports/websockets). |
+| `stream(factory)`, `rpcStream(call)` | Any `AsyncIterable` | You call a server function directly. See [Server Functions](../transports/server-functions). |
```typescript
-import { ChatClient, fetchServerSentEvents } from "@tanstack/ai-client";
-
-const client = new ChatClient({
- connection: fetchServerSentEvents("/api/chat"),
-});
-
-function logSnapshot() {
- const snapshot = client.getSnapshot();
- console.log(snapshot.status, snapshot.messages.length);
-}
-
-logSnapshot();
+import { fetchServerSentEvents } from "@tanstack/ai-client";
-const unsubscribe = client.subscribeSnapshot(() => {
- logSnapshot();
+const connection = fetchServerSentEvents("/api/chat", {
+ headers: { Authorization: "Bearer token" },
});
-
-client.attach();
-client.sendMessage("Hello");
```
-`GenerationClient`, `VideoGenerationClient`, `RealtimeClient`, and
-`ByokClient` use `subscribe(listener)` / `getSnapshot()`. The listener takes
-no argument. Call `getSnapshot()` inside it. A listener that throws does not
-skip the other listeners. The error goes to `reportError` in the browser and
-to `console.error` in other runtimes.
+`url` can be a string or a function that returns one. `options` can be an object or a function that returns one per request.
-When `RealtimeClient.connect()` connects, it clears the previous transcript.
-A transcript that arrives while the microphone starts is kept. A failed
-connection keeps the previous transcript. Snapshot `parts` are frozen at
-runtime, but their TypeScript type stays mutable. Tool `input`, `output`, and
-subagent transcripts on a snapshot are copies.
+### Adapter options
-`AudioRecorder.subscribe` still passes the new state:
-`subscribe((state) => ...)`. `getSnapshot()` returns that same state value.
+Fetch adapters take `headers`, `credentials`, `signal`, `body`, `fetchClient`, and `reconnect`.
-#### `stop()`
+XHR adapters take `headers`, `withCredentials`, `signal`, `body`, `xhrFactory`, and `reconnect`.
-Stops the current response generation. It aborts the in-flight request.
-Pending client-tool results for that turn are ignored. A later
-`addToolResult()` call for that turn is ignored. It does not start a resume.
+`fetchJson()` also takes `pollIntervalMs` (default 250). When a reply has `done: false`, it waits that long and sends `GET ?runId=&offset=`. Serve that `GET` with `resumeJsonResponse()`.
-```typescript
-import { client } from "./client";
+The adapter `body` merges into the AG-UI `forwardedProps`. The chat `forwardedProps` and the per-send `body` override it.
-client.stop();
-```
+`fetchHttpStream()` needs streaming `fetch`, `Response.body.getReader()`, and `TextDecoder`. If the runtime has none of these, it throws `UnsupportedResponseStreamError`. Use the XHR adapters in React Native and Expo.
-#### `clear()`
+### `stream(factory)`
-Clears all messages.
+Builds a custom adapter from a function that returns `AsyncIterable`. The function gets `(messages, data, abortSignal, input)`. `data` is the merged `forwardedProps`. `input` is the AG-UI `RunAgentInput` for the run, the same body the HTTP adapters send. Read it on the server with `chatParamsFromRequestBody`.
```typescript
-import { client } from "./client";
+import { stream } from "@tanstack/ai-client";
+import { chatOnServer } from "./server";
-client.clear();
+const connection = stream((_messages, _data, signal, input) =>
+ chatOnServer({ input, signal }),
+);
```
-#### `setMessagesManually(messages: ReadonlyArray)`
-
-Manually sets the messages array.
+### Stream errors
-```typescript
-import { client } from "./client";
-import type { UIMessage } from "@tanstack/ai-client";
+- `UnsupportedResponseStreamError`: a fetch adapter found no `Response.body`, `getReader()`, or `TextDecoder`.
+- `StreamTruncatedError`: an SSE or NDJSON stream ended in the middle of a line. A server, a proxy, or the network cut the connection.
+- `DurableStreamIncompleteError` and `StreamReconnectLimitError`: a resumable stream did not finish. See [Resumable Streams](../resumable-streams/overview).
-const newMessages: UIMessage[] = [];
-client.setMessagesManually([...newMessages]);
-```
+## Persistence adapters
-#### `addToolResult(result)`
+Browser storage adapters for `persistence`. Each one keeps the messages and the resume state for a `threadId`. See [Client Persistence](../persistence/client-persistence).
-Adds the result of a client-side tool execution. After `stop()`, a result for
-the stopped turn is ignored. A new user message starts a new turn. Then
-`addToolResult()` applies to that turn.
-
-```typescript
-import { client } from "./client";
+| Adapter | Storage |
+| --- | --- |
+| `localStoragePersistence(options?)` | `localStorage` |
+| `sessionStoragePersistence(options?)` | `sessionStorage` |
+| `indexedDBPersistence(options?)` | IndexedDB. The default `databaseName` is `"tanstack-ai"`. |
-await client.addToolResult({
- toolCallId: "call_123",
- tool: "toolName",
- output: { result: "..." },
- state: "output-available",
-});
-```
+They throw `StorageUnavailableError` when the browser storage is not available.
-#### `addToolApprovalResponse(response)`
+## `createChatClientOptions(options)`
-Responds to a tool approval request.
+Returns the options unchanged, with full type inference. Use it to define options outside a hook and to get the message type.
```typescript
-import { client } from "./client";
+import {
+ createChatClientOptions,
+ fetchServerSentEvents,
+ type InferChatMessages,
+} from "@tanstack/ai-client";
+import { searchProducts } from "./tools";
-await client.addToolApprovalResponse({
- id: "approval_123",
- approved: true,
+const chatOptions = createChatClientOptions({
+ connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
+ tools: [searchProducts],
});
-```
-
-### Properties
-
-- `messages: UIMessage[]` - Current messages
-- `isLoading: boolean` - Whether a response is being generated
-- `isHydrating: boolean` - Whether the chat is loading from persistence. Read it with `getIsHydrating()`
-- `error: Error | undefined` - Current error, if any
-
-## Connection Adapters
-For a complete transport walkthrough, see
-[Transports](../transports/overview). For React Native and Expo,
-see [React Native](../transports/react-native) and
-[Quick Start: React Native](../getting-started/quick-start-react-native).
-
-### `fetchServerSentEvents(url, options?)`
-
-Creates an SSE connection adapter.
-
-```typescript
-import { fetchServerSentEvents } from "@tanstack/ai-client";
-
-const adapter = fetchServerSentEvents("/api/chat", {
- headers: {
- Authorization: "Bearer token",
- },
-});
+type ChatMessages = InferChatMessages;
```
-### `fetchHttpStream(url, options?)`
-
-Creates a newline-delimited JSON HTTP stream connection adapter. Pair it with
-`toHttpResponse()` on the server.
+A plain tools array keeps tool names, inputs, and outputs typed. You do not need `as const`. When you narrow with `part.name === "searchProducts"`, `part.input` and `part.output` get their types from the schemas of that tool.
-```typescript
-import { fetchHttpStream } from "@tanstack/ai-client";
+Client context stays typed too. If a `.client()` tool needs context, `context` becomes required. It stays on the client. To send values to the server, use `forwardedProps`, then validate them and pass them to `chat({ context })`.
-const adapter = fetchHttpStream("/api/chat");
-```
+## `clientTools(...tools)`
-`fetchHttpStream()` requires a runtime with streaming `fetch`,
-`Response.body.getReader()`, and `TextDecoder`. If the runtime cannot expose an
-incremental response body, it throws `UnsupportedResponseStreamError`; use the
-XHR adapters in React Native or Expo.
-
-### `fetchJson(url, options?)`
-
-Creates a connection adapter for one JSON body per request. Pair it with
-`toJsonResponse()` on the server. It does not need a streaming response body.
+Returns its arguments as a typed tuple. A plain array already gives the same types, so use this only to build a shared tools tuple outside the options call.
```typescript
-import { fetchJson } from "@tanstack/ai-client";
+import { clientTools } from "@tanstack/ai-client";
+import { searchProducts, showToast } from "./tools";
-const adapter = fetchJson("/api/chat", {
- pollIntervalMs: 500,
-});
+export const tools = clientTools(searchProducts, showToast);
```
-It takes the fetch adapter options, plus `pollIntervalMs` (default 250). When a
-reply has `done: false`, the adapter waits `pollIntervalMs` and sends
-`GET ?runId=&offset=`. Serve that `GET` with `resumeJsonResponse()`.
-See [JSON](../transports/json).
+## `GenerationClient`
-### `xhrHttpStream(url, options?)`
-
-Creates an `XMLHttpRequest`-backed newline-delimited JSON stream adapter. This
-is the recommended default for React Native and Expo chat screens. Pair it with
-`toHttpResponse()` on the server.
+Runs one generation request (image, audio, speech, transcription, summary, or your own type) and keeps its result. The framework generation hooks wrap it. See [Generation Hooks](../media/generation-hooks).
```typescript
-import { xhrHttpStream } from "@tanstack/ai-client";
+import type { ImageGenerationResult } from "@tanstack/ai";
+import {
+ GenerationClient,
+ fetchServerSentEvents,
+ type ImageGenerateInput,
+} from "@tanstack/ai-client";
-const adapter = xhrHttpStream("http://192.168.1.10:8787/chat/http", {
- headers: { Authorization: "Bearer token" },
- withCredentials: true,
+const generation = new GenerationClient({
+ connection: fetchServerSentEvents("/api/generate/image"),
+ threadId: "generation",
+ onResult: (result) => console.log(result.images.length),
});
-```
-
-### `xhrServerSentEvents(url, options?)`
-
-Creates an `XMLHttpRequest`-backed SSE adapter for runtimes where XHR progress
-events are more reliable than streaming `fetch`. Pair it with
-`toServerSentEventsResponse()` on the server.
-```typescript
-import { xhrServerSentEvents } from "@tanstack/ai-client";
-
-const adapter = xhrServerSentEvents("http://192.168.1.10:8787/chat/sse");
+await generation.generate({ prompt: "A mountain at sunset" });
```
-### Adapter options
+- Options: `threadId` (required), `connection` or `fetcher`, `body`, `persistence`, `byok`, `byokProvider`, `devtools`, `onResult`, `onError`, `onProgress`, `onChunk`.
+- Methods: `generate(input)`, `stop()`, `reset()`, `updateOptions(options)`, `dispose()`, `getResult()`, `getIsLoading()`, `getError()`, `getStatus()`.
+- Snapshot: `result`, `isLoading`, `error`, `status` (`"idle"`, `"generating"`, `"success"`, or `"error"`), and `runId`.
-Fetch adapters accept:
+`VideoGenerationClient` does the same for video jobs. Its snapshot adds `jobId` and `videoStatus`, and it takes `onJobCreated` and `onStatusUpdate`.
-- `headers?: Record | Headers`
-- `credentials?: RequestCredentials`
-- `signal?: AbortSignal`
-- `body?: Record`
-- `fetchClient?: typeof globalThis.fetch`
+## `RealtimeClient`
-XHR adapters accept:
+Runs a voice conversation with a realtime model. Pass `getToken` and `adapter`. Methods: `connect()`, `disconnect()`, `startListening()`, `stopListening()`, `interrupt()`, `sendText(text)`, `sendImage(data, mimeType)`, `updateSession(config)`, and `destroy()`. See [Realtime Chat](../media/realtime-chat).
-- `headers?: Record | Headers`
-- `withCredentials?: boolean`
-- `signal?: AbortSignal`
-- `body?: Record`
-- `xhrFactory?: () => XMLHttpRequest`
+When `connect()` succeeds, it clears the previous transcript. A transcript that arrives while the microphone starts is kept. A failed connection keeps the previous transcript. Snapshot `parts` are frozen at runtime, but their TypeScript type stays mutable. Tool `input`, `output`, and subagent transcripts on a snapshot are copies.
-`body` is merged into the AG-UI `forwardedProps` payload. Values from
-`forwardedProps` on the client and per-message `sendMessage` `body` (positional
-or `sendOptions.body`) override static adapter `body` values.
+## `AudioRecorder`
-### Stream errors
+Records audio from the microphone. Methods: `start()`, `stop()` (resolves to the recording), and `cancel()`. See [Audio Recording](../media/audio-recording).
-- `UnsupportedResponseStreamError` - thrown by fetch-based adapters when
- `Response.body`, `Response.body.getReader()`, or `TextDecoder` is missing.
-- `StreamTruncatedError` - thrown when an SSE or NDJSON stream ends with
- unterminated trailing data, usually because the server, proxy, or network cut
- the connection mid-line.
+### Listeners on the other clients
-### `stream(connectFn)`
-
-Creates a custom connection adapter.
-
-```typescript ignore
-import { stream } from "@tanstack/ai-client";
+`GenerationClient`, `VideoGenerationClient`, `RealtimeClient`, and `ByokClient` use `subscribe(listener)` and `getSnapshot()`:
-const adapter = stream(async (messages, data, signal) => {
- // `data` here carries the merged forwardedProps. The fetch-based
- // adapters serialize it as the AG-UI `RunAgentInput.forwardedProps`
- // field on the wire (with a backward-compat `data` mirror).
- const response = await fetch("/api/chat", {
- method: "POST",
- body: JSON.stringify({ messages, forwardedProps: data }),
- signal,
- });
- return processStream(response);
-});
-```
+- The listener gets no argument. Call `getSnapshot()` inside it.
+- A listener that throws does not stop the other listeners. The error goes to `reportError` in the browser and to `console.error` in other runtimes.
-## Helper Functions
+`AudioRecorder.subscribe` passes the new state: `subscribe((state) => ...)`. `getSnapshot()` returns that same state.
-### `clientTools(...tools)`
+## `registerWebMCPTools(tools, options)`
-**Optional.** A plain array — `tools: [tool1, tool2]` — already narrows tool names, inputs and outputs without any wrapper or `as const`. `clientTools()` is an identity helper that performs the same capture explicitly; reach for it only when you want to build a shared, reusable tools tuple outside the hook/options call.
+Exposes client tools through the browser WebMCP API. Abort `options.signal` to remove the tools. See [WebMCP Tools](../tools/webmcp).
```typescript
-import {
- clientTools,
- createChatClientOptions,
- fetchServerSentEvents,
- type UIMessage,
-} from "@tanstack/ai-client";
-import { toolDefinition } from "@tanstack/ai";
-import { z } from "zod";
-
-const messages: UIMessage[] = [];
-
-const myTool1 = toolDefinition({
- name: "myTool1",
- description: "First tool",
- inputSchema: z.object({ query: z.string() }),
- outputSchema: z.object({ result: z.string() }),
-});
-
-const myTool2 = toolDefinition({
- name: "myTool2",
- description: "Second tool",
- inputSchema: z.object({ query: z.string() }),
- outputSchema: z.object({ result: z.string() }),
-});
-
-// Create client implementations
-const tool1Client = myTool1.client((input) => {
- // Implementation
- return { result: input.query };
-});
-
-const tool2Client = myTool2.client((input) => {
- // Implementation
- return { result: input.query };
-});
+import { registerWebMCPTools } from "@tanstack/ai-client";
+import { searchProducts } from "./tools";
-// The explicit-capture form (equivalent to `[tool1Client, tool2Client]`).
-const tools = clientTools(tool1Client, tool2Client);
+const controller = new AbortController();
-// Now when you use these tools in chat options:
-const chatOptions = createChatClientOptions({
- connection: fetchServerSentEvents("/api/chat"),
- tools, // Fully typed with literal tool names
+await registerWebMCPTools([searchProducts], {
+ signal: controller.signal,
+ toolOptions: {
+ searchProducts: {
+ title: "Search products",
+ annotations: { readOnlyHint: true },
+ },
+ },
});
-// In your component:
-messages.forEach((message) => {
- message.parts.forEach((part) => {
- if (part.type === "tool-call" && part.name === "myTool1") {
- // ✅ TypeScript knows part.name is literally "myTool1"
- // ✅ part.input is typed from myTool1's input schema
- // ✅ part.output is typed from myTool1's output schema
- }
- });
-});
+// Later, when the owner of these tools goes away:
+controller.abort();
```
-### `createChatClientOptions(options)`
-
-Helper function to create typed chat client options with proper type inference.
-
-```typescript
-import {
- createChatClientOptions,
- fetchServerSentEvents,
- type InferChatMessages,
-} from "@tanstack/ai-client";
-import { tool1, tool2 } from "./tools";
-
-const tools = [tool1, tool2];
-
-const chatOptions = createChatClientOptions({
- connection: fetchServerSentEvents("/api/chat"),
- tools,
-});
+| Option | What it does |
+| --- | --- |
+| `signal` | Required. Aborting it removes all tools of this call. |
+| `toolOptions` | Per tool name: `title` and `annotations` (`readOnlyHint`, `untrustedContentHint`). |
+| `context` | Client tool runtime context. Required when a tool declares a required context. |
-// Use InferChatMessages to extract message types
-type ChatMessages = InferChatMessages;
-```
+- It returns `Promise`. During server rendering, in an insecure context, or when WebMCP is not there, it resolves and registers nothing.
+- It validates Standard Schema inputs and outputs.
+- A tool with `needsApproval: true` makes the registration fail. If registration fails, the function removes the tools it registered during the call.
-`createChatClientOptions` also preserves typed client runtime context:
+Option types: `RegisterWebMCPToolsOptions`, `WebMCPToolOptionsByName`, `WebMCPToolOptions`, `WebMCPToolAnnotations`.
-```typescript
-import {
- createChatClientOptions,
- fetchServerSentEvents,
-} from "@tanstack/ai-client";
-import { toolDefinition } from "@tanstack/ai";
-import { z } from "zod";
+## `getWebMCPTools(options?)` and `subscribeWebMCPTools(listener, options)`
-type ClientContext = {
- activeProjectId: string;
-};
+Read the tools that the page registered with WebMCP, as client tools. See [Page WebMCP Tools in Chat](../tools/webmcp-page-tools).
-const projectTool = toolDefinition({
- name: "projectAction",
- description: "Run a project action",
- inputSchema: z.object({ action: z.string() }),
- outputSchema: z.object({ ok: z.boolean() }),
-});
+- `getWebMCPTools({ filter? })` resolves to the current tools, or `[]` when WebMCP is not there.
+- `subscribeWebMCPTools(listener, { signal, filter?, onError? })` calls `listener` with the tools each time the page adds or removes one. Abort `signal` to stop.
-const tool = projectTool.client((input, ctx: { context: ClientContext }) => {
- console.log(ctx.context.activeProjectId, input.action);
- return { ok: true };
-});
+`filter` gets a `WebMCPPageTool` (`name`, `title`, `description`, `inputSchema`, `origin`, `annotations`). Return `false` to skip it.
-const chatOptions = createChatClientOptions({
- connection: fetchServerSentEvents("/api/chat"),
- tools: [tool],
- context: {
- activeProjectId: "project_123",
- },
-});
-```
+## `createMcpAppBridge(options)`
-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 })`.
+Creates the bridge that lets an MCP App widget call tools and send chat messages. Options: `threadId`, `callEndpoint`, `chat`, `fetchImpl`, `onLink`. See [MCP Apps](../mcp/apps).
## `defineByok`
-Factory for a headless BYOK keyring. Import it from `@tanstack/ai-client/byok`. Pass the instance into `ChatClient`, `useChat`, or a generation hook. See [Bring Your Own Key](../advanced/byok) for a full client and relay walkthrough.
+Creates a headless BYOK keyring. Import it from `@tanstack/ai-client/byok`. Pass the keyring to `ChatClient`, `useChat`, or a generation hook. See [Bring Your Own Key](../advanced/byok).
```typescript
import { defineByok, defaultByokStorage } from "@tanstack/ai-client/byok";
@@ -665,28 +408,29 @@ export const byok = defineByok({
### Factory options
-- `storage?` - A `KeyringStorage` implementation. Default is `memoryStorage()` (session only, not saved)
-- `providers?` - Descriptors whose `with` companions the store expands. With `[cloudflareByok, cloudflareAccountByok]`, a send for `cloudflare` carries the token and the account id headers, and `prepare` prompts for each missing one
+- `storage`: a `KeyringStorage`. Default: `memoryStorage()`, which keeps keys for the session only.
+- `providers`: descriptors whose `with` companions the keyring adds. With `[cloudflareByok, cloudflareAccountByok]`, a send for `cloudflare` carries the token and the account id headers. `prepare` asks for each missing one.
### Methods
-- `update(provider, key)` - Persist a key for a provider slug (`[a-z][a-z0-9-]{0,63}`), then update the snapshot. Throws if the id is not a slug, the key is empty, or storage fails
-- `update(key)` - Persist a key for the current `prompt` provider. Throws if `prompt` is null
-- `clear(provider?)` - Persist the removal, then drop one key, or all keys when you omit `provider`
-- `unlock()` - Decrypt unlockable storage (passkey). No-op for memory storage
-- `headers(provider)` - Return `x-byok-*` headers for that slug and its companions. Chat and generation clients throw if no slug resolves — they do not send every stored key
-- `prepare(provider?)` - Wait for hydration, then unlock if needed. If `provider` is set and the key for it or a companion is empty with no server coverage, throw `ByokBlockedError` and set `prompt` for that slug
-- `ready()` - Resolve when constructor hydration (peek/load) finishes
-- `setServerCoverage(flags)` - `true` means do not block a send when the browser has no key (the relay can fill from env). `false` restores the default: block. A record merges per-slug flags
-- `request(provider, reason)` - Set `prompt` to `{ provider, reason }` (`missing` | `locked` | `invalid`)
-- `getSnapshot()` - Return the current [`ByokSnapshot`](#snapshot)
-- `subscribe(listener)` - Call `listener` on each change. Returns an unsubscribe function
-- `keys()` - Return a copy of the raw keyring. Do not render this in the UI
+| Method | What it does |
+| --- | --- |
+| `update(provider, key)` | Saves a key for a provider slug (`[a-z][a-z0-9-]{0,63}`), then updates the snapshot. Throws if the id is not a slug, the key is empty, or storage fails. |
+| `update(key)` | Saves a key for the current `prompt` provider. Throws if `prompt` is null. |
+| `clear(provider?)` | Removes one key, or all keys when you omit `provider`. |
+| `unlock()` | Decrypts unlockable storage (passkey). Does nothing for memory storage. |
+| `headers(provider)` | Returns the `x-byok-*` headers for that slug and its companions. |
+| `prepare(provider?)` | Waits for hydration, then unlocks if needed. If the key for `provider` or a companion is empty and the server does not cover it, it throws `ByokBlockedError` and sets `prompt`. |
+| `ready()` | Resolves when the first load from storage ends. |
+| `setServerCoverage(flags)` | `true`: do not block a send when the browser has no key, because the relay can use its env. `false`: block (default). A record sets flags per slug. |
+| `request(provider, reason)` | Sets `prompt` to `{ provider, reason }`. `reason` is `"missing"`, `"locked"`, or `"invalid"`. |
+| `getSnapshot()`, `subscribe(listener)` | Read the [snapshot](#snapshot), and listen for changes. |
+| `keys()` | Returns a copy of the raw keyring. Do not show it in the UI. |
+
+The chat and generation clients throw if no provider slug resolves. They do not send every stored key.
### Snapshot
-`getSnapshot()` (and framework readers such as `useByok`) return:
-
```typescript ignore
type ByokSnapshot = {
status: Partial>;
@@ -705,96 +449,56 @@ type KeyStatus =
| { state: "error"; masked: string; message: string };
```
-`status` is sparse: only slugs that have a key, a lock, or a validation result appear. A missing entry means no key. `masked` is the last four characters of the key (`maskKey`). Read it with `"masked" in status` — `{ state: "empty" }` has no `masked`. `storageError` is set when peek/load fails. The snapshot never includes the raw key.
+- `status` is sparse. Only slugs with a key, a lock, or a validation result appear. A missing entry means no key.
+- `masked` is the last four characters of the key. `{ state: "empty" }` has no `masked`, so check with `"masked" in status`.
+- `storageError` is set when loading from storage fails.
+- The snapshot never holds the raw key.
### Storage
-- `defaultByokStorage(options?)` - Passkey-encrypted IndexedDB when WebAuthn is available in a secure context. Otherwise `memoryStorage()` with a warning. WebAuthn support is not the same as PRF — first save still throws if the authenticator lacks PRF
-- `memoryStorage()` - In-memory on the `ByokClient` instance. This backend does not persist. Keys are gone when the client is dropped or the page reloads
-- `passkeyStorage(options?)` - Encrypt the keyring with a WebAuthn passkey. First save throws if the authenticator does not support PRF
-- `KeyringStorage` - `{ id, label, persistent, unlockable?, peek?, load, save, clear }`
+| Storage | What it does |
+| --- | --- |
+| `defaultByokStorage(options?)` | Passkey-encrypted IndexedDB when WebAuthn is available in a secure context. Otherwise `memoryStorage()`, with a warning. The first save still throws if the authenticator has no PRF support. |
+| `memoryStorage()` | Keeps keys on the `ByokClient` instance. Keys are gone when the page reloads. |
+| `passkeyStorage(options?)` | Encrypts the keyring with a WebAuthn passkey. The first save throws if the authenticator has no PRF support. |
+| `KeyringStorage` | The storage interface: `{ id, label, persistent, unlockable?, peek?, load, save, clear }`. |
-This library does not ship a dialog. Call `byok.update(provider, value)` from your own UI.
+The library ships no key dialog. Call `byok.update(provider, value)` from your own UI.
## Types
### `UIMessage`
```typescript ignore
-interface UIMessage {
+interface UIMessage {
id: string;
- role: "system" | "user" | "assistant";
- parts: MessagePart[];
+ role: "system" | "user" | "assistant" | "activity";
name?: string;
+ parts: Array>;
createdAt?: Date;
metadata?: Record;
}
```
-`metadata` is an optional AG-UI bag (`Record`). TanStack writes the `tanstack` key. Your keys stay at the top.
+`metadata` is an AG-UI bag. TanStack writes the `tanstack` key. Your keys stay at the top level.
### `MessagePart`
-```typescript ignore
-type MessagePart = TextPart | ThinkingPart | ToolCallPart | ToolResultPart;
-```
-
-### `TextPart`
-
-```typescript
-interface TextPart {
- type: "text";
- content: string;
-}
-```
-
-### `ThinkingPart`
-
-```typescript
-interface ThinkingPart {
- type: "thinking";
- content: string;
-}
-```
-
-Thinking parts represent the model's internal reasoning process. They are typically displayed in a collapsible format and automatically collapse when the response text appears. Thinking parts are UI-only and are not sent back to the model in subsequent requests.
-
-**Note:** Thinking parts are only available when using models that support reasoning/thinking (e.g., Anthropic Claude with thinking enabled, OpenAI GPT-5 with reasoning enabled).
+| Part | `type` | What it holds |
+| --- | --- | --- |
+| `TextPart` | `"text"` | `content`: the text. |
+| `ThinkingPart` | `"thinking"` | `content`: the model reasoning. See [Thinking Content](../chat/thinking-content). |
+| `ToolCallPart` | `"tool-call"` | `id`, `name`, `arguments` (JSON string, can be partial while it streams), `input`, `output`, `state`, and `approval` for tools with `needsApproval: true`. |
+| `ToolResultPart` | `"tool-result"` | `toolCallId`, `content`, `state`, `error`, `outcome`, `metadata`. |
+| `ImagePart`, `AudioPart`, `VideoPart`, `DocumentPart` | media types | Media content. See [Multimodal Content](../advanced/multimodal-content). |
+| `StructuredOutputPart` | `"structured-output"` | Typed `data` and `partial`. See [Structured Outputs](../structured-outputs/overview). |
+| `ActivityPart`, `UIResourcePart`, `SubagentPart` | other | Activity records, MCP App resources, and subagent runs. |
-### `ToolCallPart`
+Thinking parts are for the UI only. The client does not send them back to the model.
-```typescript ignore
-interface ToolCallPart {
- type: "tool-call";
- id: string;
- name: string;
- arguments: string; // JSON string (may be incomplete during streaming)
- input?: any; // Parsed tool input (typed from tool's inputSchema)
- state: ToolCallState;
- approval?: ApprovalRequest; // only on tools declared `needsApproval: true`
- output?: any; // Tool execution output (typed from tool's outputSchema)
-}
-```
+With a typed tools array, `ToolCallPart` is a union with `name` as the key. Narrow on `part.name` to get typed `input` and `output`. The `approval` field exists only on tools with `needsApproval: true`, so narrow by `part.name` or check `"approval" in part` first.
-When you pass a typed `tools` array (a plain array works — `clientTools()` is optional), the `input` and `output` fields are automatically typed based on your tool's Zod schemas, and `name` becomes a discriminated union enabling type narrowing. The `approval` field is present **only** on parts for tools declared with `needsApproval: true` — narrow by `part.name` (or guard with `'approval' in part`) before accessing it.
-
-### `ToolResultPart`
-
-```typescript ignore
-interface ToolResultPart {
- type: "tool-result";
- id?: string;
- name?: string;
- toolCallId: string;
- content: string | ContentPart[];
- state: ToolResultState;
- error?: string;
- metadata?: Record;
- createdAt?: Date;
-}
-```
-
-### `ToolCallState`
+### States
```typescript ignore
type ToolCallState =
@@ -803,40 +507,8 @@ type ToolCallState =
| "input-complete"
| "approval-requested"
| "approval-responded"
- | "complete";
-```
-
-### `ToolResultState`
-
-```typescript ignore
-type ToolResultState =
- | "streaming"
| "complete"
| "error";
-```
-
-## Stream Processing
-
-Configure stream processing with chunk strategies:
-```typescript
-import {
- ChatClient,
- ImmediateStrategy,
- fetchServerSentEvents,
-} from "@tanstack/ai-client";
-
-const client = new ChatClient({
- connection: fetchServerSentEvents("/api/chat"),
- streamProcessor: {
- chunkStrategy: new ImmediateStrategy(), // Emit every chunk
- },
-});
+type ToolResultState = "streaming" | "complete" | "error";
```
-
-## Next Steps
-
-- [Getting Started](../getting-started/quick-start) - Learn the basics
-- [Bring Your Own Key](../advanced/byok) - Store keys in the browser and send `x-byok-*` headers
-- [Transports](../transports/overview) - Learn about adapters
-- [@tanstack/ai-react API](./ai-react) - React hooks wrapper
diff --git a/docs/api/ai-octane.md b/docs/api/ai-octane.md
index 6f99cdb537..d88930563b 100644
--- a/docs/api/ai-octane.md
+++ b/docs/api/ai-octane.md
@@ -2,7 +2,7 @@
title: "@tanstack/ai-octane"
id: ai-octane
order: 8
-description: "API reference for @tanstack/ai-octane. Octane hooks including useChat for streaming chat with full type safety."
+description: "API reference for @tanstack/ai-octane: the useChat hook, generation hooks, and Octane bindings for the headless TanStack AI client."
keywords:
- tanstack ai
- "@tanstack/ai-octane"
@@ -12,7 +12,7 @@ keywords:
- api reference
---
-Install `@tanstack/ai-octane`, then call `useChat` the same way you would in React. The hook modules are `.tsrx` and compile in your Octane plugin.
+Octane hooks for TanStack AI. They work like the React hooks. To build your first chat, start with the [Quick Start](../getting-started/quick-start). For a typed chat layout, see [Octane Chat UI](../ui/octane).
## Installation
@@ -22,323 +22,311 @@ octane: @tanstack/ai-octane octane
-`octane` is a required peer. This package publishes uncompiled source, like Svelte packages that ship `.svelte`.
+`octane` is a required peer. The package publishes uncompiled `.tsrx` source, and your Octane plugin compiles it.
-## `useRegisterWebMCPTools(tools, options?)`
-
-Register executable client tools after the Octane component mounts. Octane removes them on cleanup and replaces them when `tools` or `options` change.
+## `useChat(options)`
-For a complete setup and behavior guide, see [WebMCP Tools](../tools/webmcp).
+Holds the messages and the run state of one chat, and talks to your server route.
```tsx
-import {
- useRegisterWebMCPTools,
- type UseRegisterWebMCPToolsOptions,
-} from '@tanstack/ai-octane'
-import { searchProducts } from './tools'
-
-const tools = [searchProducts]
-const options: UseRegisterWebMCPToolsOptions = {
- onError(error) {
- console.error(error)
- },
-}
+import { useState } from "octane";
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-octane";
-function ProductsPage() {
- useRegisterWebMCPTools(tools, options)
- return null
+export function Chat() {
+ const [input, setInput] = useState("");
+ const { messages, sendMessage, isLoading } = useChat({
+ connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
+ });
+
+ return (
+
+ );
}
```
-`UseRegisterWebMCPToolsOptions` contains `toolOptions`, `context`, and `onError`. The hook owns the registration signal.
+### Options
-The `context` field is required when a tool declares a required runtime context. Keep `tools` and `options` stable when their values do not change.
+You must pass `threadId`. You must also pass `connection` or `fetcher`, not both.
-## `usePageWebMCPTools(options?)`
+| Option | What it does |
+| --- | --- |
+| `connection` | Connection adapter, for example `fetchServerSentEvents("/api/chat")`. See [Transports](../transports/overview). |
+| `fetcher` | A function that returns a `Response` or a stream. Use it with server functions. |
+| `tools` | Client tools from `.client()`. They run when the model calls them. See [Client Tools](../tools/client-tools). |
+| `threadId` | Required. The id of this chat. Use a stable id for each conversation, for example the id from your route. |
+| `initialMessages` | The messages to show first. |
+| `forwardedProps` | JSON sent to the server in the AG-UI `forwardedProps` field, for example `{ model: "gpt-6.1-sol" }`. |
+| `persistence` | `true` to load the thread from the server, or a storage adapter. See [Chat Persistence](../persistence/chat-persistence). |
+| `outputSchema` | A Standard Schema. Adds typed `partial` and `final` to the return value. See [Streaming structured output](../structured-outputs/streaming). |
+| `context` | Client-only runtime context for client tools. It is not sent to the server. |
+| `live` | Subscribe on mount and unsubscribe on unmount. |
+| `queue` | What `sendMessage` does while a run is busy. Default: queue the message. See [Queueing](../chat/queueing). |
+| `interrupts` | Definitions from `defineInterrupt()`. See [Interrupts](../interrupts/overview). |
+| `byok`, `byokProvider` | A keyring from `defineByok()`. See [Bring Your Own Key](../advanced/byok). |
+| `devtools` | Display options for TanStack AI Devtools. The hook always tags `framework: 'octane'`. |
-Read the WebMCP tools on the page as client tools. The array starts empty and updates when the page adds or removes a tool. Pass it to `useChat` as `tools`.
+The hook also takes the `ChatClient` callbacks (`onResponse`, `onChunk`, `onFinish`, `onError`, `onInterruptStateChange`, `onCustomEvent`) and the other client options. See [`ChatClient` options](./ai-client#constructor-options).
-```tsx
-import { usePageWebMCPTools } from "@tanstack/ai-octane"
+When `connection`, `fetcher`, `forwardedProps`, `tools`, `context`, or `queue` change, the hook updates the current chat. A new `threadId` creates a new chat.
-export function useSameOriginPageTools() {
- return usePageWebMCPTools({
- filter: (tool) => tool.origin === location.origin,
- })
-}
-```
+### Returns
-`filter` skips a tool when it returns `false`. `onError` gets a failed WebMCP read. For a complete guide, see [Page WebMCP Tools in Chat](../tools/webmcp-page-tools).
+State values are plain Octane values. The hook renders again when they change.
+
+| Field | What it is |
+| --- | --- |
+| `messages` | The `UIMessage` array of the chat. |
+| `sendMessage(content, options?)` | Sends a user message. `content` is a string or `{ content, id?, metadata? }`. `options` takes `whenBusy` and `body`. |
+| `isLoading` | `true` while a response streams. |
+| `error` | The last error, or `undefined`. |
+| `status` | `"ready"`, `"submitted"`, `"streaming"`, or `"error"`. |
+| `stop()` | Stops the current response. |
+| `reload()` | Runs the last user message again. |
+| `append(message)` | Adds a `UIMessage` or `ModelMessage` and starts a run. |
+| `addToolResult(result)` | Gives the result of a client tool by hand: `{ toolCallId, tool, output, state?, errorText? }`. |
+| `setMessages(messages)`, `clear()` | Replace or clear the messages. |
+| `queue`, `cancelQueued(id)` | Messages that wait while a run is busy. See [Queueing](../chat/queueing). |
+| `runId` | The id of the run in flight, or `null`. |
+| `interrupts` | Pending interrupts. Each item has `resolveInterrupt(...)`. See [Interrupts](../interrupts/overview). |
+| `resolveInterrupts`, `cancelInterrupts`, `retryInterrupts`, `resumeInterruptsUnsafe` | Act on all pending interrupts at once. |
+| `interruptErrors`, `resuming` | Errors from the last resume, and `true` while a resume runs. |
+| `isHydrating` | `true` while the chat loads from persistence. |
+| `hasOlderMessages`, `loadOlderMessages()` | Page in older messages. See [Chat Persistence](../persistence/chat-persistence). |
+| `isSubscribed`, `connectionStatus`, `sessionGenerating` | State of the live subscription. |
+| `partial`, `final` | Structured output. Only when you pass `outputSchema`. |
+
+### Example: tool approval
+
+A tool with `needsApproval: true` pauses the run. The pending call shows up in `interrupts`.
-## `useChat(options)`
+```tsx
+import { toolDefinition } from "@tanstack/ai";
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-octane";
+import { z } from "zod";
-Manages chat state in an Octane component.
+const deleteFileTool = toolDefinition({
+ name: "delete_file",
+ description: "Delete a file",
+ needsApproval: true,
+ inputSchema: z.object({ path: z.string() }),
+});
-```tsx
-import { useState } from 'octane'
-import { useChat, fetchServerSentEvents } from '@tanstack/ai-octane'
-import {
- createChatClientOptions,
- type InferChatMessages,
-} from '@tanstack/ai-client'
-import { toolDefinition } from '@tanstack/ai'
-import { z } from 'zod'
-
-const updateUIDef = toolDefinition({
- name: 'updateUI',
- description: 'Show a notification in the UI',
- inputSchema: z.object({ message: z.string() }),
-})
-
-export function ChatComponent() {
- const [notification, setNotification] = useState(null)
- const updateUI = updateUIDef.client((input) => {
- setNotification(input.message)
- return { success: true }
- })
- const tools = [updateUI]
-
- const chatOptions = createChatClientOptions({
- connection: fetchServerSentEvents('/api/chat'),
- tools,
- })
-
- type ChatMessages = InferChatMessages
-
- const { messages, sendMessage, isLoading, error, addToolApprovalResponse } =
- useChat(chatOptions)
+export function ChatWithApproval() {
+ const { interrupts } = useChat({
+ connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
+ tools: [deleteFileTool],
+ });
return (
- )
+ );
}
```
-The matching server route still runs `chat({ adapter, messages })` and returns SSE. See [Quick Start](../getting-started/quick-start).
+See [Tool Approval](../interrupts/tool-approval) for the server side.
+
+## `createChatHook(options)`
+
+Binds chat options once at module scope and returns a `useChat` hook that uses them.
-### Options you pass first
+```tsx
+import { createChatHook, fetchServerSentEvents } from "@tanstack/ai-octane";
-Extends `ChatClientOptions` from `@tanstack/ai-client`. Pass `connection` or `fetcher`, not both.
+const { useChat: useSupportChat } = createChatHook({
+ connection: fetchServerSentEvents("/api/chat"),
+});
-- `connection` or `fetcher` - how the hook talks to your server
-- `tools?` - client tool implementations from `.client()`
-- `threadId?` - the only identity for this chat. Required when persistence is on
-- `initialMessages?` - starting transcript
-- `forwardedProps?` - JSON sent to the server on the AG-UI `forwardedProps` field
+export function SupportScreen() {
+ const chat = useSupportChat({ threadId: "support-1" });
+ return
{chat.messages.length} messages
;
+}
+```
-### Options you add later
+The bound hook takes these per-call overrides: `threadId`, `initialMessages`, `live`, and `forwardedProps`. Keep `tools`, `interrupts`, and `outputSchema` on the factory options.
-- `live?` - subscribe on mount, unsubscribe on unmount
-- `queue?` - what to do when `sendMessage` runs while a turn is in flight. Default queues
-- `interrupts?` - typed interrupt definitions
-- `context?` - client-only runtime context for client tools. Not sent to the server
-- `onResponse?` / `onChunk?` / `onFinish?` / `onError?` / `onInterruptStateChange?`
-- `devtools?` - display options. The hook always tags `framework: 'octane'`
-- `body?` - deprecated. Use `forwardedProps`
+`@tanstack/ai-octane/ui` has its own `createChatHook` that also returns typed UI components. See [Octane Chat UI](../ui/octane).
-Client tools run automatically. There is no `onToolCall` callback.
+## Generation hooks
-Changing `connection` or `fetcher` updates the live `ChatClient`. Changing `threadId` creates a new client.
+Each hook runs one generation request and keeps its result. See [Generation Hooks](../media/generation-hooks).
-### Returns
+| Hook | `generate()` input | `result` |
+| --- | --- | --- |
+| `useGenerateImage` | `ImageGenerateInput` | `ImageGenerationResult` |
+| `useGenerateAudio` | `AudioGenerateInput` | `AudioGenerationResult` |
+| `useGenerateSpeech` | `SpeechGenerateInput` | `TTSResult` |
+| `useTranscription` | `TranscriptionGenerateInput` | `TranscriptionResult` |
+| `useSummarize` | `SummarizeGenerateInput` | `SummarizationResult` |
+| `useGenerateVideo` | `VideoGenerateInput` | `VideoGenerateResult` |
+| `useGeneration` | Your own input type | Your own result type |
+
+```tsx
+import { useGenerateImage, fetchServerSentEvents } from "@tanstack/ai-octane";
+
+export function ImageButton() {
+ const image = useGenerateImage({
+ connection: fetchServerSentEvents("/api/generate/image"),
+ threadId: "image-studio",
+ });
-```typescript
-import type { UIMessage } from '@tanstack/ai-octane'
-import type { ModelMessage } from '@tanstack/ai/client'
-import type {
- MultimodalContent,
- ChatClientState,
- ConnectionStatus,
- QueuedMessage,
- SendMessageOptions,
-} from '@tanstack/ai-client'
-
-interface UseChatReturn {
- messages: ReadonlyArray
- sendMessage: (
- content: string | MultimodalContent,
- options?: SendMessageOptions,
- ) => Promise
- append: (message: ModelMessage | UIMessage) => Promise
- addToolResult: (result: {
- toolCallId: string
- tool: string
- output: unknown
- state?: 'output-available' | 'output-error'
- errorText?: string
- }) => Promise
- addToolApprovalResponse: (response: {
- id: string
- approved: boolean
- }) => Promise
- reload: () => Promise
- stop: () => void
- isLoading: boolean
- error: Error | undefined
- status: ChatClientState
- isSubscribed: boolean
- connectionStatus: ConnectionStatus
- sessionGenerating: boolean
- isHydrating: boolean
- setMessages: (messages: ReadonlyArray) => void
- clear: () => void
- queue: ReadonlyArray
- cancelQueued: (id: string) => void
- runId: string | null
+ return (
+ void image.generate({ prompt: "A mountain at sunset" })}
+ type="button"
+ >
+ Generate
+
+ );
}
```
-`queue` holds sends that wait while a run is busy. `runId` is the in-flight turn, or `null`. Interrupt helpers (`interrupts`, `resolveInterrupts`, `cancelInterrupts`, `retryInterrupts`) are on the same object when you pass `interrupts`.
+Options: `threadId` (required), `connection` or `fetcher`, `body`, `devtools`, `onResult`, `onError`, `onProgress`, `onChunk`.
+
+Returns: `generate(input)`, `result`, `isLoading`, `error`, `status`, `stop()`, and `reset()`.
-## Connection adapters
+`useGenerateVideo` also returns `jobId` and `videoStatus`, and takes `onJobCreated` and `onStatusUpdate`.
-Re-exported from `@tanstack/ai-client`:
+## `useRealtimeChat(options)`
-```typescript
-import {
- fetchServerSentEvents,
- fetchHttpStream,
- fetchJson,
- stream,
- type ConnectionAdapter,
-} from '@tanstack/ai-octane'
+Runs a voice conversation with a realtime model. Pass `getToken` and `adapter`. See [Realtime Chat](../media/realtime-chat).
+
+```tsx
+import { useRealtimeChat } from "@tanstack/ai-octane";
+import { openaiRealtime } from "@tanstack/ai-openai";
+
+export function VoiceChat() {
+ const chat = useRealtimeChat({
+ getToken: () => fetch("/api/realtime-token").then((r) => r.json()),
+ adapter: openaiRealtime(),
+ });
+
+ return chat.status === "idle" ? (
+ void chat.connect()} type="button">Start
+ ) : (
+ void chat.disconnect()} type="button">Stop
+ );
+}
```
-## Example: basic chat
+Returns `status`, `error`, `mode`, `messages`, `connect()`, `disconnect()`, `startListening()`, `stopListening()`, `interrupt()`, `sendText(text)`, `sendImage(data, mimeType)`, `updateSession(config)`, plus audio levels and frequency data for visualizers.
+
+## `useAudioRecorder(options?)`
+
+Records audio from the microphone. See [Audio Recording](../media/audio-recording).
+
+Returns `recording`, `isRecording`, `isSupported`, `start()`, `stop()`, and `cancel()`. Pass `onComplete` to transform the recording. Then `recording` and `stop()` use the transformed type.
+
+## BYOK
+
+This package does not export a BYOK hook. Read the keyring with `byok.getSnapshot()` and `byok.subscribe(listener)`. BYOK helpers (`defineByok`, `defaultByokStorage`, and more) are on the `@tanstack/ai-octane/byok` subpath. See [Bring Your Own Key](../advanced/byok).
+
+## `useMcpAppBridge(options)`
+
+Returns a stable MCP App bridge for one thread. It always calls the latest `chat.sendMessage` and `onLink`. Options: `threadId`, `callEndpoint`, `chat`, `fetchImpl`, `onLink`. See [MCP Apps](../mcp/apps).
+
+This package has no `mcp-apps` subpath, so it does not ship the `MCPAppResource` component.
+
+## `useRegisterWebMCPTools(tools, options?)`
+
+Exposes client tools to the browser WebMCP API while the component is mounted. See [WebMCP Tools](../tools/webmcp).
```tsx
-import { useState } from 'octane'
-import { useChat, fetchServerSentEvents } from '@tanstack/ai-octane'
+import { useRegisterWebMCPTools } from "@tanstack/ai-octane";
+import { searchProducts } from "./tools";
-export function Chat() {
- const [input, setInput] = useState('')
- const { messages, sendMessage, isLoading } = useChat({
- connection: fetchServerSentEvents('/api/chat'),
- })
+const tools = [searchProducts];
- return (
-
- )
+export function ProductsPage() {
+ useRegisterWebMCPTools(tools, {
+ onError: (error) => console.error(error),
+ });
+ return null;
}
```
-## Example: tool approval
+Options: `toolOptions`, `context`, and `onError`. `context` is required when a tool declares a required runtime context. The hook registers the tools again when `tools` or `options` change, so keep them stable.
-```tsx
-import { useChat, fetchServerSentEvents } from '@tanstack/ai-octane'
+## `usePageWebMCPTools(options?)`
-export function ChatWithApproval() {
- const { messages, sendMessage, addToolApprovalResponse } = useChat({
- connection: fetchServerSentEvents('/api/chat'),
- })
+Reads the WebMCP tools that the page registered, as client tools. The array starts empty and updates when the page adds or removes a tool. Pass it to `useChat` as `tools`. See [Page WebMCP Tools in Chat](../tools/webmcp-page-tools).
- return (
-
- )
+```tsx
+import { usePageWebMCPTools } from "@tanstack/ai-octane";
+
+export function useSameOriginPageTools() {
+ return usePageWebMCPTools({
+ filter: (tool) => tool.origin === location.origin,
+ });
}
```
-## Other hooks
+Options: `filter` (return `false` to skip a tool) and `onError`.
-The package also exports `useRealtimeChat`, `useMcpAppBridge`, `useGeneration`, `useGenerateImage`, `useGenerateAudio`, `useGenerateSpeech`, `useGenerateVideo`, `useTranscription`, `useSummarize`, and `useAudioRecorder`.
+## Re-exports from `@tanstack/ai-client`
-The `./mcp-apps` React `AppRenderer` subpath is not in this package. `useMcpAppBridge` is.
+You can import these from `@tanstack/ai-octane` directly. See [`@tanstack/ai-client`](./ai-client) for details.
-## Types
+| Group | Exports |
+| --- | --- |
+| Connection adapters | `fetchServerSentEvents`, `fetchHttpStream`, `fetchJson`, `xhrServerSentEvents`, `xhrHttpStream`, `webSocket`, `stream`, `rpcStream` |
+| Typed options | `createChatClientOptions`, `clientTools` |
+| Clients | `ChatClient`, `GenerationClient`, `VideoGenerationClient`, `RealtimeClient`, `AudioRecorder`, `InterruptManager` |
+| WebMCP and MCP Apps | `registerWebMCPTools`, `getWebMCPTools`, `subscribeWebMCPTools`, `createMcpAppBridge` |
+| Errors | `UnsupportedResponseStreamError`, `StreamTruncatedError`, `DurableStreamIncompleteError`, `StreamReconnectLimitError` |
+| Stream helpers | `StreamProcessor`, chunk strategies, `parsePartialJSON`, message converters |
-Re-exported from `@tanstack/ai-client`:
+The browser persistence adapters (`localStoragePersistence` and the others) are not re-exported here. Import them from `@tanstack/ai-client`.
-- `UIMessage`
-- `ChatClientOptions`
-- `InferChatMessages`
-- `QueuedMessage`, `SendMessageOptions`, `WhenBusy`
+## Types
-## Next
+| Type | What it is |
+| --- | --- |
+| `UseChatOptions`, `UseChatReturn` | The options and return value of `useChat`. |
+| `UIMessage` | A chat message with typed parts. |
+| `InferChatMessages` | The message type for a `createChatClientOptions` result. |
+| `SendMessageOptions`, `WhenBusy`, `QueueOption`, `QueuedMessage` | Send and queue types. |
+| `DeepPartial` | The type of `partial` while structured output streams. |
+| `UseOptions`, `UseReturn` | Options and return types for the generation, realtime, and recorder hooks. The WebMCP hooks export only an options type. |
-- [Quick Start](../getting-started/quick-start)
-- [Tools](../tools/tools)
-- [Client tools](../tools/client-tools)
+Pass a plain tools array to `useChat` or `createChatClientOptions`. Tool names, inputs, and outputs stay typed without `as const`. Import `toolDefinition` from `@tanstack/ai`.
diff --git a/docs/api/ai-preact.md b/docs/api/ai-preact.md
index de8ca0dd3d..e08b28193f 100644
--- a/docs/api/ai-preact.md
+++ b/docs/api/ai-preact.md
@@ -2,7 +2,7 @@
title: "@tanstack/ai-preact"
slug: /api/ai-preact
order: 5
-description: "API reference for @tanstack/ai-preact — Preact hooks including useChat for streaming chat with full type safety in Preact apps."
+description: "API reference for @tanstack/ai-preact: the useChat hook and Preact bindings for the headless TanStack AI client."
keywords:
- tanstack ai
- "@tanstack/ai-preact"
@@ -12,7 +12,7 @@ keywords:
- api reference
---
-Preact hooks for TanStack AI, providing convenient Preact bindings for the headless client.
+Preact hooks for TanStack AI. To build your first chat, start with the [Quick Start](../getting-started/quick-start). For a typed chat layout, see [Preact Chat UI](../ui/preact).
## Installation
@@ -22,161 +22,184 @@ preact: @tanstack/ai-preact
-## `useRegisterWebMCPTools(tools, options?)`
-
-Register executable client tools after the Preact component mounts. Preact removes them on cleanup and replaces them when `tools` or `options` change.
+## `useChat(options)`
-For a complete setup and behavior guide, see [WebMCP Tools](../tools/webmcp).
+Holds the messages and the run state of one chat, and talks to your server route.
```tsx
-import {
- useRegisterWebMCPTools,
- type UseRegisterWebMCPToolsOptions,
-} from "@tanstack/ai-preact";
-import { searchProducts } from "./tools";
+import { useState } from "preact/hooks";
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-preact";
-const tools = [searchProducts];
-const options: UseRegisterWebMCPToolsOptions = {
- onError(error) {
- console.error(error);
- },
-};
-
-function ProductsPage() {
- useRegisterWebMCPTools(tools, options);
- return null;
+export function Chat() {
+ const [input, setInput] = useState("");
+ const { messages, sendMessage, isLoading } = useChat({
+ connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
+ });
+
+ return (
+
+ );
}
```
-`UseRegisterWebMCPToolsOptions` contains `toolOptions`, `context`, and `onError`. The hook owns the registration signal.
+### Options
-The `context` field is required when a tool declares a required runtime context. Keep `tools` and `options` stable when their values do not change.
+You must pass `threadId`. You must also pass `connection` or `fetcher`, not both.
+
+| Option | What it does |
+| --- | --- |
+| `connection` | Connection adapter, for example `fetchServerSentEvents("/api/chat")`. See [Transports](../transports/overview). |
+| `fetcher` | A function that returns a `Response` or a stream. Use it with server functions. |
+| `tools` | Client tools from `.client()`. They run when the model calls them. See [Client Tools](../tools/client-tools). |
+| `threadId` | Required. The id of this chat. Use a stable id for each conversation, for example the id from your route. |
+| `initialMessages` | The messages to show first. |
+| `forwardedProps` | JSON sent to the server in the AG-UI `forwardedProps` field, for example `{ model: "gpt-6.1-sol" }`. |
+| `persistence` | `true` to load the thread from the server, or a storage adapter. See [Chat Persistence](../persistence/chat-persistence). |
+| `outputSchema` | A Standard Schema. In Preact it only labels the chat in devtools. Read structured output from the `structured-output` part in `messages`. |
+| `context` | Client-only runtime context for client tools. It is not sent to the server. |
+| `live` | Subscribe on mount and unsubscribe on unmount. |
+| `queue` | What `sendMessage` does while a run is busy. Default: queue the message. See [Queueing](../chat/queueing). |
+| `interrupts` | Definitions from `defineInterrupt()`. See [Interrupts](../interrupts/overview). |
+| `byok`, `byokProvider` | A keyring from `defineByok()`. See [Bring Your Own Key](../advanced/byok). |
+| `devtools` | Display options for TanStack AI Devtools. |
+
+The hook also takes the `ChatClient` callbacks (`onResponse`, `onChunk`, `onFinish`, `onError`, `onInterruptStateChange`, `onCustomEvent`) and the other client options. See [`ChatClient` options](./ai-client#constructor-options).
+
+When `forwardedProps`, `tools`, `context`, or `queue` change, the hook updates the current chat.
-## `usePageWebMCPTools(options?)`
+### Returns
-Read the WebMCP tools on the page as client tools. The array starts empty and updates when the page adds or removes a tool. Pass it to `useChat` as `tools`.
+State values are plain Preact values. The hook renders again when they change.
+
+| Field | What it is |
+| --- | --- |
+| `messages` | The `UIMessage` array of the chat. |
+| `sendMessage(content, options?)` | Sends a user message. `content` is a string or `{ content, id?, metadata? }`. `options` takes `whenBusy` and `body`. |
+| `isLoading` | `true` while a response streams. |
+| `error` | The last error, or `undefined`. |
+| `status` | `"ready"`, `"submitted"`, `"streaming"`, or `"error"`. |
+| `stop()` | Stops the current response. |
+| `reload()` | Runs the last user message again. |
+| `append(message)` | Adds a `UIMessage` or `ModelMessage` and starts a run. |
+| `addToolResult(result)` | Gives the result of a client tool by hand: `{ toolCallId, tool, output, state?, errorText? }`. |
+| `setMessages(messages)`, `clear()` | Replace or clear the messages. |
+| `queue`, `cancelQueued(id)` | Messages that wait while a run is busy. See [Queueing](../chat/queueing). |
+| `runId` | The id of the run in flight, or `null`. |
+| `interrupts` | Pending interrupts. Each item has `resolveInterrupt(...)`. See [Interrupts](../interrupts/overview). |
+| `resolveInterrupts`, `cancelInterrupts`, `retryInterrupts`, `resumeInterruptsUnsafe` | Act on all pending interrupts at once. |
+| `interruptErrors`, `resuming` | Errors from the last resume, and `true` while a resume runs. |
+| `isHydrating` | `true` while the chat loads from persistence. |
+| `hasOlderMessages`, `loadOlderMessages()` | Page in older messages. See [Chat Persistence](../persistence/chat-persistence). |
+| `isSubscribed`, `connectionStatus`, `sessionGenerating` | State of the live subscription. |
+
+### Example: tool approval
+
+A tool with `needsApproval: true` pauses the run. The pending call shows up in `interrupts`.
```tsx
-import { usePageWebMCPTools } from "@tanstack/ai-preact";
+import { toolDefinition } from "@tanstack/ai";
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-preact";
+import { z } from "zod";
-export function useSameOriginPageTools() {
- return usePageWebMCPTools({
- filter: (tool) => tool.origin === location.origin,
+const deleteFileTool = toolDefinition({
+ name: "delete_file",
+ description: "Delete a file",
+ needsApproval: true,
+ inputSchema: z.object({ path: z.string() }),
+});
+
+export function ChatWithApproval() {
+ const { interrupts } = useChat({
+ connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
+ tools: [deleteFileTool],
});
+
+ return (
+
+ );
}
```
-`filter` skips a tool when it returns `false`. `onError` gets a failed WebMCP read. For a complete guide, see [Page WebMCP Tools in Chat](../tools/webmcp-page-tools).
+See [Tool Approval](../interrupts/tool-approval) for the server side.
-## `useChat(options?)`
+## `createChatHook(options)`
-Main hook for managing chat state in Preact with full type safety.
+Binds chat options once at module scope and returns a `useChat` hook that uses them.
```tsx
-import { useChat, fetchServerSentEvents } from "@tanstack/ai-preact";
-import {
- createChatClientOptions,
- type InferChatMessages
-} from "@tanstack/ai-client";
-import { toolDefinition } from "@tanstack/ai";
-import { z } from "zod";
-import { useState } from "preact/hooks";
+import { createChatHook, fetchServerSentEvents } from "@tanstack/ai-preact";
-const updateUIDef = toolDefinition({
- name: "updateUI",
- description: "Show a notification in the UI",
- inputSchema: z.object({ message: z.string() }),
+const { useChat: useSupportChat } = createChatHook({
+ connection: fetchServerSentEvents("/api/chat"),
});
-function ChatComponent() {
- const [, setNotification] = useState(null);
- // Create client tool implementations
- const updateUI = updateUIDef.client((input) => {
- setNotification(input.message);
- return { success: true };
- });
-
- // Create typed tools array (no 'as const' needed!)
- const tools = [updateUI];
+export function SupportScreen() {
+ const chat = useSupportChat({ threadId: "support-1" });
+ return
{chat.messages.length} messages
;
+}
+```
- const chatOptions = createChatClientOptions({
- connection: fetchServerSentEvents("/api/chat"),
- tools,
- });
+The bound hook takes these per-call overrides: `threadId`, `initialMessages`, `live`, and `forwardedProps`. Keep `tools` and `interrupts` on the factory options.
- // Fully typed messages!
- type ChatMessages = InferChatMessages;
+`@tanstack/ai-preact/ui` has its own `createChatHook` that also returns typed UI components. See [Preact Chat UI](../ui/preact).
- const { messages, sendMessage, isLoading, error, addToolApprovalResponse } =
- useChat(chatOptions);
+## Generation hooks
- return
{/* Chat UI with typed messages */}
;
-}
-```
+This package does not export generation hooks. Use `GenerationClient` or `VideoGenerationClient`, which this package re-exports. See [`GenerationClient`](./ai-client#generationclient).
-### Options
+## Realtime chat
-Extends `ChatClientOptions` from `@tanstack/ai-client`:
-
-- `connection` - Connection adapter (required)
-- `tools?` - Array of client tool implementations (with `.client()` method)
-- `initialMessages?` - Initial messages array
-- `threadId?` - The only identity for this chat. Required when persistence is on. If omitted, minted after mount.
-- `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field (e.g., `{ provider: 'openai', model: 'gpt-5.5' }`)
-- `body?` - **Deprecated.** Use `forwardedProps` instead. Still works for backward compatibility; values are merged into `forwardedProps` on the wire
-- `byok?` - Optional BYOK keyring from `defineByok`. On each send the client prepares the resolved provider and stamps `x-byok-*` request headers. Keys never go in the body
-- `byokProvider?` - Optional function that returns the provider slug for this chat. If it returns a slug, only that key is prepared and sent. Otherwise the merged `provider` from `forwardedProps`, `body`, and per-call `sendMessage` `body` is used. Later sources win. If no slug resolves, the send throws instead of attaching every stored key
-- `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
-- `onError?` - Callback when error occurs
-- `onInterruptStateChange?` - Callback when interrupt state changes; context source is `hydrate` for restored state and `live` for streamed or client-initiated updates
-- `streamProcessor?` - Stream processing configuration
-
-**Note:** Client tools are now automatically executed - no `onToolCall` callback needed!
+This package does not export a realtime hook. Use `RealtimeClient`, which this package re-exports. See [Realtime Chat](../media/realtime-chat).
-### Returns
+## Audio recorder
-```typescript
-import type { UIMessage } from "@tanstack/ai-preact";
-import type { ModelMessage } from "@tanstack/ai/client";
-import type {
- MultimodalContent,
- SendMessageOptions,
-} from "@tanstack/ai-client";
-
-interface UseChatReturn {
- messages: ReadonlyArray;
- sendMessage: (
- content: string | MultimodalContent,
- options?: SendMessageOptions,
- ) => Promise;
- append: (message: ModelMessage | UIMessage) => Promise;
- addToolResult: (result: {
- toolCallId: string;
- tool: string;
- output: any;
- state?: "output-available" | "output-error";
- errorText?: string;
- }) => Promise;
- addToolApprovalResponse: (response: {
- id: string;
- approved: boolean;
- }) => Promise;
- reload: () => Promise;
- stop: () => void;
- isLoading: boolean;
- isHydrating: boolean;
- error: Error | undefined;
- setMessages: (messages: ReadonlyArray) => void;
- clear: () => void;
-}
-```
+This package does not export a recorder hook. Use `AudioRecorder`, which this package re-exports. See [Audio Recording](../media/audio-recording).
## `useByok(client)`
-Subscribe to a `ByokClient` snapshot in Preact.
+Subscribes to a BYOK keyring and returns its snapshot. See [Bring Your Own Key](../advanced/byok).
```tsx
import { useByok } from "@tanstack/ai-preact";
@@ -185,247 +208,78 @@ import { byok } from "./byok";
export function KeyStatus() {
const snapshot = useByok(byok);
const openai = snapshot.status.openai;
- const last4 = openai && "masked" in openai ? openai.masked : "No key";
- return
{last4}
;
+ return
{openai && "masked" in openai ? openai.masked : "No key"}
;
}
```
-`snapshot` has `status`, `locked`, and `prompt`. Call `byok.update(provider, value)` from your own UI to save a key. See [Bring Your Own Key](../advanced/byok).
+The snapshot has `status`, `locked`, `prompt`, and `storageError`. Save a key from your own UI with `byok.update(provider, value)`.
-## Connection Adapters
+## `useMcpAppBridge(options)`
-Re-exported from `@tanstack/ai-client` for convenience:
+Returns a stable MCP App bridge for one thread. It always calls the latest `chat.sendMessage` and `onLink`. Options: `threadId`, `callEndpoint`, `chat`, `fetchImpl`, `onLink`. See [MCP Apps](../mcp/apps).
-```typescript
-import {
- fetchServerSentEvents,
- fetchHttpStream,
- fetchJson,
- stream,
- type ConnectionAdapter,
-} from "@tanstack/ai-preact";
-```
+The `MCPAppResource` component, which renders a `ui-resource` part, is on the `@tanstack/ai-preact/mcp-apps` subpath.
+
+## `useRegisterWebMCPTools(tools, options?)`
-## Example: Basic Chat
+Exposes client tools to the browser WebMCP API while the component is mounted. See [WebMCP Tools](../tools/webmcp).
```tsx
-import { useState } from "preact/hooks";
-import { useChat, fetchServerSentEvents } from "@tanstack/ai-preact";
+import { useRegisterWebMCPTools } from "@tanstack/ai-preact";
+import { searchProducts } from "./tools";
-export function Chat() {
- const [input, setInput] = useState("");
+const tools = [searchProducts];
- const { messages, sendMessage, isLoading } = useChat({
- connection: fetchServerSentEvents("/api/chat"),
+export function ProductsPage() {
+ useRegisterWebMCPTools(tools, {
+ onError: (error) => console.error(error),
});
-
- const handleSubmit = (e: Event) => {
- e.preventDefault();
- if (input.trim() && !isLoading) {
- sendMessage(input);
- setInput("");
- }
- };
-
- return (
-
- );
-}
-```
+## `usePageWebMCPTools(options?)`
-## Example: Client Tools with Type Safety
+Reads the WebMCP tools that the page registered, as client tools. The array starts empty and updates when the page adds or removes a tool. Pass it to `useChat` as `tools`. See [Page WebMCP Tools in Chat](../tools/webmcp-page-tools).
```tsx
-import { useChat, fetchServerSentEvents } from "@tanstack/ai-preact";
-import {
- createChatClientOptions,
- type InferChatMessages
-} from "@tanstack/ai-client";
-import { toolDefinition } from "@tanstack/ai";
-import { z } from "zod";
-import { useState } from "preact/hooks";
-
-const updateUIDef = toolDefinition({
- name: "updateUI",
- description: "Show a notification in the UI",
- inputSchema: z.object({ message: z.string(), type: z.string() }),
-});
-
-const saveToStorageDef = toolDefinition({
- name: "saveToStorage",
- description: "Save a value to localStorage",
- inputSchema: z.object({ key: z.string(), value: z.string() }),
-});
-
-export function ChatWithClientTools() {
- const [notification, setNotification] = useState<{ message: string; type: string } | null>(null);
-
- // Create client implementations
- const updateUI = updateUIDef.client((input) => {
- // ✅ input is fully typed!
- setNotification({ message: input.message, type: input.type });
- return { success: true };
- });
-
- const saveToStorage = saveToStorageDef.client((input) => {
- localStorage.setItem(input.key, input.value);
- return { saved: true };
- });
-
- // Create typed tools array (no 'as const' needed!)
- const tools = [updateUI, saveToStorage];
+import { usePageWebMCPTools } from "@tanstack/ai-preact";
- const { messages, sendMessage } = useChat({
- connection: fetchServerSentEvents("/api/chat"),
- tools, // ✅ Automatic execution, full type safety
+export function useSameOriginPageTools() {
+ return usePageWebMCPTools({
+ filter: (tool) => tool.origin === location.origin,
});
-
- return (
-
- {messages.map((message) =>
- message.parts.map((part) => {
- if (part.type === "tool-call" && part.name === "updateUI") {
- // ✅ part.input and part.output are fully typed!
- return
Tool executed: {part.name}
;
- }
- return null;
- })
- )}
-
- );
}
```
-## `createChatClientOptions(options)`
+Options: `filter` (return `false` to skip a tool) and `onError`.
-Helper to create typed chat options (re-exported from `@tanstack/ai-client`).
+## Re-exports from `@tanstack/ai-client`
-```typescript
-import {
- createChatClientOptions,
- type InferChatMessages
-} from "@tanstack/ai-client";
-import { fetchServerSentEvents } from "@tanstack/ai-preact";
-import { tool1, tool2 } from "./tools";
+You can import these from `@tanstack/ai-preact` directly. See [`@tanstack/ai-client`](./ai-client) for details.
-// Create typed tools array (no 'as const' needed!)
-const tools = [tool1, tool2];
+| Group | Exports |
+| --- | --- |
+| Connection adapters | `fetchServerSentEvents`, `fetchHttpStream`, `fetchJson`, `xhrServerSentEvents`, `xhrHttpStream`, `webSocket`, `stream`, `rpcStream` |
+| Typed options | `createChatClientOptions`, `clientTools` |
+| Persistence | `localStoragePersistence`, `sessionStoragePersistence`, `indexedDBPersistence` |
+| Clients | `ChatClient`, `GenerationClient`, `VideoGenerationClient`, `RealtimeClient`, `AudioRecorder`, `InterruptManager` |
+| WebMCP and MCP Apps | `registerWebMCPTools`, `getWebMCPTools`, `subscribeWebMCPTools`, `createMcpAppBridge` |
+| Errors | `UnsupportedResponseStreamError`, `StreamTruncatedError`, `DurableStreamIncompleteError`, `StreamReconnectLimitError`, `StorageUnavailableError` |
+| Stream helpers | `StreamProcessor`, chunk strategies, `parsePartialJSON`, message converters |
-const chatOptions = createChatClientOptions({
- connection: fetchServerSentEvents("/api/chat"),
- tools,
-});
-
-type Messages = InferChatMessages;
-```
+BYOK helpers (`defineByok`, `defaultByokStorage`, and more) are on the `@tanstack/ai-preact/byok` subpath.
## Types
-Re-exported from `@tanstack/ai-client`:
-
-- `UIMessage` - Message type with tool type parameter
-- `MessagePart` - Message part with tool type parameter
-- `TextPart` - Text content part
-- `ThinkingPart` - Thinking content part
-- `ToolCallPart` - Tool call part (discriminated union)
-- `ToolResultPart` - Tool result part
-- `ChatClientOptions` - Chat client options with typed client runtime context
-- `ConnectionAdapter` - Connection adapter interface
-- `InferChatMessages` - Extract message type from options
-
-Re-exported from `@tanstack/ai`:
-
-- `toolDefinition()` - Create isomorphic tool definition
-- `ToolDefinitionInstance` - Tool definition type
-- `ClientTool` - Client tool type
-- `ServerTool` - Server tool type
-
-## Next Steps
+| Type | What it is |
+| --- | --- |
+| `UseChatOptions`, `UseChatReturn` | The options and return value of `useChat`. |
+| `UIMessage` | A chat message with typed parts. |
+| `InferChatMessages` | The message type for a `createChatClientOptions` result. |
+| `SendMessageOptions`, `WhenBusy`, `QueueOption`, `QueuedMessage` | Send and queue types. |
+| `UseRegisterWebMCPToolsOptions`, `UsePageWebMCPToolsOptions`, `UseMcpAppBridgeOptions` | Options for the WebMCP and bridge hooks. |
-- [Getting Started](../getting-started/quick-start) - Learn the basics
-- [Tools Guide](../tools/tools) - Learn about the isomorphic tool system
-- [Client Tools](../tools/client-tools) - Learn about client-side tools
+Pass a plain tools array to `useChat` or `createChatClientOptions`. Tool names, inputs, and outputs stay typed without `as const`. Import `toolDefinition` from `@tanstack/ai`.
diff --git a/docs/api/ai-react.md b/docs/api/ai-react.md
index 891c67ad38..99a5db52c4 100644
--- a/docs/api/ai-react.md
+++ b/docs/api/ai-react.md
@@ -2,7 +2,7 @@
title: "@tanstack/ai-react"
slug: /api/ai-react
order: 3
-description: "API reference for @tanstack/ai-react — React hooks including useChat for streaming chat with full type safety in React apps."
+description: "API reference for @tanstack/ai-react: the useChat hook, generation hooks, and React bindings for the headless TanStack AI client."
keywords:
- tanstack ai
- "@tanstack/ai-react"
@@ -12,15 +12,7 @@ keywords:
- api reference
---
-React hooks for TanStack AI, providing convenient React bindings for the headless client.
-
-For a typed headless chat UI, see [React Chat UI](../ui/react).
-For React Native, the documented support surface is narrow: `useChat` with chat
-connection adapters. React DOM-specific UI packages and TanStack AI devtools UI
-are not part of the React Native support surface.
-
-For a complete native journey, see
-[Quick Start: React Native](../getting-started/quick-start-react-native).
+React hooks for TanStack AI. To build your first chat, start with the [Quick Start](../getting-started/quick-start). For a typed chat layout, see [React Chat UI](../ui/react).
## Installation
@@ -30,186 +22,244 @@ react: @tanstack/ai-react
-## `useRegisterWebMCPTools(tools, options?)`
+The package works in React Native and Expo, but only `useChat` with chat connection adapters is supported there. See [Quick Start: React Native](../getting-started/quick-start-react-native).
-Register executable client tools after the React component mounts. React removes them on cleanup and replaces them when `tools` or `options` change.
+## `useChat(options)`
-For a complete setup and behavior guide, see [WebMCP Tools](../tools/webmcp).
+Holds the messages and the run state of one chat, and talks to your server route.
```tsx
-import {
- useRegisterWebMCPTools,
- type UseRegisterWebMCPToolsOptions,
-} from "@tanstack/ai-react";
-import { searchProducts } from "./tools";
+import { useState } from "react";
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
-const tools = [searchProducts];
-const options: UseRegisterWebMCPToolsOptions = {
- onError(error) {
- console.error(error);
- },
-};
-
-function ProductsPage() {
- useRegisterWebMCPTools(tools, options);
- return null;
+export function Chat() {
+ const [input, setInput] = useState("");
+ const { messages, sendMessage, isLoading } = useChat({
+ connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
+ });
+
+ return (
+
+ );
}
```
-`UseRegisterWebMCPToolsOptions` contains `toolOptions`, `context`, and `onError`. The hook owns the registration signal.
+### Options
-The `context` field is required when a tool declares a required runtime context. Keep `tools` and `options` stable when their values do not change.
+You must pass `threadId`. You must also pass `connection` or `fetcher`, not both.
+
+| Option | What it does |
+| --- | --- |
+| `connection` | Connection adapter, for example `fetchServerSentEvents("/api/chat")`. See [Transports](../transports/overview). |
+| `fetcher` | A function that returns a `Response` or a stream. Use it with server functions. |
+| `tools` | Client tools from `.client()`. They run when the model calls them. See [Client Tools](../tools/client-tools). |
+| `threadId` | Required. The id of this chat. Use a stable id for each conversation, for example the id from your route. |
+| `initialMessages` | The messages to show first. |
+| `forwardedProps` | JSON sent to the server in the AG-UI `forwardedProps` field, for example `{ model: "gpt-6.1-sol" }`. |
+| `persistence` | `true` to load the thread from the server, or a storage adapter. See [Chat Persistence](../persistence/chat-persistence). |
+| `outputSchema` | A Standard Schema. Adds typed `partial` and `final` to the return value. See [Streaming structured output](../structured-outputs/streaming). |
+| `context` | Client-only runtime context for client tools. It is not sent to the server. |
+| `live` | Subscribe on mount and unsubscribe on unmount. |
+| `queue` | What `sendMessage` does while a run is busy. Default: queue the message. See [Queueing](../chat/queueing). |
+| `interrupts` | Definitions from `defineInterrupt()`. See [Interrupts](../interrupts/overview). |
+| `subagents` | Agents from `defineAgent()`, for types only. See [Subagents](../chat/subagents). |
+| `byok`, `byokProvider` | A keyring from `defineByok()`. See [Bring Your Own Key](../advanced/byok). |
+| `devtools` | Display options for TanStack AI Devtools. |
+
+The hook also takes the `ChatClient` callbacks (`onResponse`, `onChunk`, `onFinish`, `onError`, `onInterruptStateChange`, `onCustomEvent`) and the other client options. See [`ChatClient` options](./ai-client#constructor-options).
+
+When `forwardedProps`, `tools`, `context`, or `queue` change, the hook updates the current chat.
-## `usePageWebMCPTools(options?)`
+### Returns
-Read the WebMCP tools on the page as client tools. The array starts empty and updates when the page adds or removes a tool. Pass it to `useChat` as `tools`.
+State values are plain React values. The hook renders again when they change.
+
+| Field | What it is |
+| --- | --- |
+| `messages` | The `UIMessage` array of the chat. |
+| `sendMessage(content, options?)` | Sends a user message. `content` is a string or `{ content, id?, metadata? }`. `options` takes `whenBusy` and `body`. |
+| `isLoading` | `true` while a response streams. |
+| `error` | The last error, or `undefined`. |
+| `status` | `"ready"`, `"submitted"`, `"streaming"`, or `"error"`. |
+| `stop()` | Stops the current response. |
+| `reload()` | Sends the last user message again. |
+| `append(message)` | Adds a `UIMessage` or `ModelMessage` and starts a run. |
+| `addToolResult(result)` | Gives the result of a client tool by hand: `{ toolCallId, tool, output, state?, errorText? }`. |
+| `setMessages(messages)`, `clear()` | Replace or clear the messages. |
+| `queue`, `cancelQueued(id)` | Messages that wait while a run is busy. See [Queueing](../chat/queueing). |
+| `runId` | The id of the run in flight, or `null`. |
+| `interrupts` | Pending interrupts. Each item has `resolveInterrupt(...)`. See [Interrupts](../interrupts/overview). |
+| `resolveInterrupts`, `cancelInterrupts`, `retryInterrupts`, `resumeInterruptsUnsafe` | Act on all pending interrupts at once. |
+| `interruptErrors`, `resuming` | Errors from the last resume, and `true` while a resume runs. |
+| `isHydrating` | `true` while the chat loads from persistence. |
+| `hasOlderMessages`, `loadOlderMessages()` | Page in older messages. See [Chat Persistence](../persistence/chat-persistence). |
+| `isSubscribed`, `connectionStatus`, `sessionGenerating` | State of the live subscription. |
+| `partial`, `final` | Structured output. Only when you pass `outputSchema`. |
+| `subagents` | Live child-agent runs. See [Subagents](../chat/subagents). |
+
+### Example: tool approval
+
+A tool with `needsApproval: true` pauses the run. The pending call shows up in `interrupts`.
```tsx
-import { usePageWebMCPTools } from "@tanstack/ai-react";
+import { toolDefinition } from "@tanstack/ai";
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
+import { z } from "zod";
-export function useSameOriginPageTools() {
- return usePageWebMCPTools({
- filter: (tool) => tool.origin === location.origin,
+const deleteFileTool = toolDefinition({
+ name: "delete_file",
+ description: "Delete a file",
+ needsApproval: true,
+ inputSchema: z.object({ path: z.string() }),
+});
+
+export function ChatWithApproval() {
+ const { interrupts } = useChat({
+ connection: fetchServerSentEvents("/api/chat"),
+ threadId: "support-chat",
+ tools: [deleteFileTool],
});
+
+ return (
+
+ );
}
```
-`filter` skips a tool when it returns `false`. `onError` gets a failed WebMCP read. For a complete guide, see [Page WebMCP Tools in Chat](../tools/webmcp-page-tools).
+See [Tool Approval](../interrupts/tool-approval) for the server side.
## `createChatHook(options)`
-Bind `chatOptions` once at module scope. Call `useChat()` in the screen to create the instance. Per-call overrides may set `threadId`, `initialMessages`, `live`, and `forwardedProps`. They must not change `tools`, `interrupts`, or `outputSchema`.
+Binds chat options once at module scope and returns a `useChat` hook that uses them.
```tsx
import { createChatHook, fetchServerSentEvents } from "@tanstack/ai-react";
-const chatOptions = {
+const { useChat: useSupportChat } = createChatHook({
connection: fetchServerSentEvents("/api/chat"),
-};
-
-const { useChat } = createChatHook(chatOptions);
+});
-function ChatScreen() {
- const chat = useChat({ threadId: "support-1" });
- return null;
+export function SupportScreen() {
+ const chat = useSupportChat({ threadId: "support-1" });
+ return
{chat.messages.length} messages
;
}
```
-`useChat(chatOptions)` from this package still works when you want to pass the full object at the call site. Rename the bound hook if both are in one file: `const { useChat: useSupportChat } = createChatHook(chatOptions)`.
+The bound hook takes these per-call overrides: `threadId`, `initialMessages`, `live`, and `forwardedProps`. Keep `tools`, `interrupts`, and `outputSchema` on the factory options.
-## `useChat(options?)`
+`@tanstack/ai-react/ui` has its own `createChatHook` that also returns typed UI components. See [React Chat UI](../ui/react).
-Main hook for managing chat state in React with full type safety.
+## Generation hooks
-```tsx
-import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
-import {
- createChatClientOptions,
- type InferChatMessages
-} from "@tanstack/ai-client";
-import { toolDefinition } from "@tanstack/ai";
-import { z } from "zod";
-import { useState } from "react";
+Each hook runs one generation request and keeps its result. See [Generation Hooks](../media/generation-hooks).
-const updateUIDef = toolDefinition({
- name: "updateUI",
- description: "Update the UI with a notification",
- inputSchema: z.object({
- message: z.string(),
- }),
- outputSchema: z.object({ success: z.boolean() }),
-});
+| Hook | `generate()` input | `result` |
+| --- | --- | --- |
+| `useGenerateImage` | `ImageGenerateInput` | `ImageGenerationResult` |
+| `useGenerateAudio` | `AudioGenerateInput` | `AudioGenerationResult` |
+| `useGenerateSpeech` | `SpeechGenerateInput` | `TTSResult` |
+| `useTranscription` | `TranscriptionGenerateInput` | `TranscriptionResult` |
+| `useSummarize` | `SummarizeGenerateInput` | `SummarizationResult` |
+| `useGenerateVideo` | `VideoGenerateInput` | `VideoGenerateResult` |
+| `useGeneration` | Your own input type | Your own result type |
-function ChatComponent() {
- const [notification, setNotification] = useState(null);
+```tsx
+import { useGenerateImage, fetchServerSentEvents } from "@tanstack/ai-react";
- // Create client tool implementations
- const updateUI = updateUIDef.client((input) => {
- setNotification(input.message);
- return { success: true };
+export function ImageButton() {
+ const image = useGenerateImage({
+ connection: fetchServerSentEvents("/api/generate/image"),
+ threadId: "image-studio",
});
- // Create typed tools array (no 'as const' needed!)
- const tools = [updateUI];
+ return (
+ image.generate({ prompt: "A mountain at sunset" })}
+ >
+ Generate
+
+ );
+}
+```
- const chatOptions = createChatClientOptions({
- connection: fetchServerSentEvents("/api/chat"),
- tools,
- });
+Options: `threadId` (required), `connection` or `fetcher`, `body`, `persistence`, `byok`, `byokProvider`, `devtools`, `onResult`, `onError`, `onProgress`, `onChunk`.
- // Fully typed messages!
- type ChatMessages = InferChatMessages;
+Returns: `generate(input)`, `result`, `isLoading`, `error`, `status`, `stop()`, `reset()`, `runId`.
- const { messages, sendMessage, isLoading, error, addToolApprovalResponse } =
- useChat(chatOptions);
+`useGenerateVideo` also returns `jobId` and `videoStatus`, and takes `onJobCreated` and `onStatusUpdate`.
- return
{/* Chat UI with typed messages */}
;
-}
-```
+## `useRealtimeChat(options)`
-### Options
+Runs a voice conversation with a realtime model. Pass `getToken` and `adapter`. See [Realtime Chat](../media/realtime-chat).
-Extends `ChatClientOptions` from `@tanstack/ai-client`:
-
-- `connection` - Connection adapter (required)
-- `tools?` - Array of client tool implementations (with `.client()` method)
-- `initialMessages?` - Initial messages array
-- `threadId?` - The only identity for this chat. Required when persistence is on. If omitted, minted after mount.
-- `forwardedProps?` - Arbitrary client-controlled JSON forwarded to the server in the AG-UI `RunAgentInput.forwardedProps` field (e.g., `{ provider: 'openai', model: 'gpt-5.5' }`)
-- `body?` - **Deprecated.** Use `forwardedProps` instead. Still works for backward compatibility; values are merged into `forwardedProps` on the wire
-- `byok?` - Optional BYOK keyring from `defineByok`. On each send the client prepares the resolved provider and stamps `x-byok-*` request headers. Keys never go in the body
-- `byokProvider?` - Optional function that returns the provider slug for this chat. If it returns a slug, only that key is prepared and sent. Otherwise the merged `provider` from `forwardedProps`, `body`, and per-call `sendMessage` `body` is used. Later sources win. If no slug resolves, the send throws instead of attaching every stored key
-- `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
-- `onError?` - Callback when error occurs
-- `onInterruptStateChange?` - Callback when interrupt state changes; context source is `hydrate` for restored state and `live` for streamed or client-initiated updates
-- `streamProcessor?` - Stream processing configuration
-
-**Note:** Client tools are now automatically executed - no `onToolCall` callback needed!
+```tsx
+import { useRealtimeChat } from "@tanstack/ai-react";
+import { openaiRealtime } from "@tanstack/ai-openai";
-### Returns
+export function VoiceChat() {
+ const chat = useRealtimeChat({
+ getToken: () => fetch("/api/realtime-token").then((r) => r.json()),
+ adapter: openaiRealtime(),
+ });
-```typescript
-import type { UIMessage } from "@tanstack/ai-react";
-import type { ModelMessage } from "@tanstack/ai";
-import type {
- MultimodalContent,
- SendMessageOptions,
-} from "@tanstack/ai-client";
-
-interface UseChatReturn {
- messages: ReadonlyArray;
- sendMessage: (
- content: string | MultimodalContent,
- options?: SendMessageOptions,
- ) => Promise;
- append: (message: ModelMessage | UIMessage) => Promise;
- addToolResult: (result: {
- toolCallId: string;
- tool: string;
- output: any;
- state?: "output-available" | "output-error";
- errorText?: string;
- }) => Promise;
- addToolApprovalResponse: (response: {
- id: string;
- approved: boolean;
- }) => Promise;
- reload: () => Promise;
- stop: () => void;
- isLoading: boolean;
- isHydrating: boolean;
- error: Error | undefined;
- setMessages: (messages: ReadonlyArray) => void;
- clear: () => void;
+ return chat.status === "idle" ? (
+ chat.connect()}>Start
+ ) : (
+ chat.disconnect()}>Stop
+ );
}
```
+Returns `status`, `error`, `mode`, `messages`, `connect()`, `disconnect()`, `startListening()`, `stopListening()`, `interrupt()`, `sendText(text)`, `sendImage(data, mimeType)`, `updateSession(config)`, plus audio levels and frequency data for visualizers.
+
+## `useAudioRecorder(options?)`
+
+Records audio from the microphone. See [Audio Recording](../media/audio-recording).
+
+Returns `recording`, `isRecording`, `isSupported`, `start()`, `stop()`, and `cancel()`. Pass `onComplete` to transform the recording. Then `recording` and `stop()` use the transformed type.
+
## `useByok(client)`
-Subscribe to a `ByokClient` snapshot in React.
+Subscribes to a BYOK keyring and returns its snapshot. See [Bring Your Own Key](../advanced/byok).
```tsx
import { useByok } from "@tanstack/ai-react";
@@ -218,279 +268,81 @@ import { byok } from "./byok";
export function KeyStatus() {
const snapshot = useByok(byok);
const openai = snapshot.status.openai;
- const last4 = openai && "masked" in openai ? openai.masked : "No key";
- return
{last4}
;
+ return
{openai && "masked" in openai ? openai.masked : "No key"}
;
}
```
-`snapshot` has `status`, `locked`, and `prompt`. Call `byok.update(provider, value)` from your own UI to save a key. See [Bring Your Own Key](../advanced/byok).
-
-## Connection Adapters
-
-Re-exported from `@tanstack/ai-client` for convenience:
-
-```typescript
-import {
- fetchServerSentEvents,
- fetchHttpStream,
- fetchJson,
- xhrServerSentEvents,
- xhrHttpStream,
- stream,
- type ConnectionAdapter,
- type FetchConnectionOptions,
- type FetchJsonOptions,
- type XhrConnectionOptions,
-} from "@tanstack/ai-react";
-```
+The snapshot has `status`, `locked`, `prompt`, and `storageError`. Save a key from your own UI with `byok.update(provider, value)`.
-For React Native or Expo chat screens, use an absolute server URL and prefer
-`xhrHttpStream()` with a server route that returns `toHttpResponse()`. Use
-`xhrServerSentEvents()` with `toServerSentEventsResponse()` when you want SSE.
-Use `fetchHttpStream()` only when the runtime supports streaming `fetch`,
-`Response.body.getReader()`, and `TextDecoder`; otherwise it throws
-`UnsupportedResponseStreamError`.
+## `useMcpAppBridge(options)`
-If the host cannot stream at all, use `fetchJson()` with a server route that
-returns `toJsonResponse()`. See [JSON](../transports/json).
+Returns a stable MCP App bridge for one thread. It always calls the latest `chat.sendMessage` and `onLink`. Options: `threadId`, `callEndpoint`, `chat`, `fetchImpl`, `onLink`. See [MCP Apps](../mcp/apps).
-XHR adapter options include `headers`, `withCredentials`, `signal`, `body`, and
-`xhrFactory`. Fetch adapter options include `headers`, `credentials`, `signal`,
-`body`, and `fetchClient`. `fetchJson()` also takes `pollIntervalMs`. Both option objects may be provided directly or as a
-function that resolves per request.
+The `MCPAppResource` component, which renders a `ui-resource` part, is on the `@tanstack/ai-react/mcp-apps` subpath.
-For error narrowing, import `UnsupportedResponseStreamError` and
-`StreamTruncatedError` from `@tanstack/ai-client`.
+## `useRegisterWebMCPTools(tools, options?)`
-## Example: Basic Chat
+Exposes client tools to the browser WebMCP API while the component is mounted. See [WebMCP Tools](../tools/webmcp).
```tsx
-import { useState } from "react";
-import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
+import { useRegisterWebMCPTools } from "@tanstack/ai-react";
+import { searchProducts } from "./tools";
-export function Chat() {
- const [input, setInput] = useState("");
+const tools = [searchProducts];
- const { messages, sendMessage, isLoading } = useChat({
- connection: fetchServerSentEvents("/api/chat"),
+export function ProductsPage() {
+ useRegisterWebMCPTools(tools, {
+ onError: (error) => console.error(error),
});
-
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- if (input.trim() && !isLoading) {
- sendMessage(input);
- setInput("");
- }
- };
-
- return (
-
- );
-}
-```
+## `usePageWebMCPTools(options?)`
-## Example: Client Tools with Type Safety
+Reads the WebMCP tools that the page registered, as client tools. The array starts empty and updates when the page adds or removes a tool. Pass it to `useChat` as `tools`. See [Page WebMCP Tools in Chat](../tools/webmcp-page-tools).
```tsx
-import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
-import {
- createChatClientOptions,
- type InferChatMessages
-} from "@tanstack/ai-client";
-import { toolDefinition } from "@tanstack/ai";
-import { z } from "zod";
-import { useState } from "react";
-
-const updateUIDef = toolDefinition({
- name: "updateUI",
- description: "Update the UI with a notification",
- inputSchema: z.object({
- message: z.string(),
- type: z.string(),
- }),
- outputSchema: z.object({ success: z.boolean() }),
-});
-
-const saveToStorageDef = toolDefinition({
- name: "saveToStorage",
- description: "Save a value to storage",
- inputSchema: z.object({
- key: z.string(),
- value: z.string(),
- }),
- outputSchema: z.object({ saved: z.boolean() }),
-});
-
-export function ChatWithClientTools() {
- const [notification, setNotification] = useState<{ message: string; type: string } | null>(null);
-
- // Create client implementations
- const updateUI = updateUIDef.client((input) => {
- // ✅ input is fully typed!
- setNotification({ message: input.message, type: input.type });
- return { success: true };
- });
-
- const saveToStorage = saveToStorageDef.client((input) => {
- localStorage.setItem(input.key, input.value);
- return { saved: true };
- });
-
- // Create typed tools array (no 'as const' needed!)
- const tools = [updateUI, saveToStorage];
+import { usePageWebMCPTools } from "@tanstack/ai-react";
- const { messages, sendMessage } = useChat({
- connection: fetchServerSentEvents("/api/chat"),
- tools, // ✅ Automatic execution, full type safety
+export function useSameOriginPageTools() {
+ return usePageWebMCPTools({
+ filter: (tool) => tool.origin === location.origin,
});
-
- return (
-
- {messages.map((message) =>
- message.parts.map((part) => {
- if (part.type === "tool-call" && part.name === "updateUI") {
- // ✅ part.input and part.output are fully typed!
- return
Tool executed: {part.name}
;
- }
- return null;
- })
- )}
-
- );
}
```
-## `createChatClientOptions(options)`
+Options: `filter` (return `false` to skip a tool) and `onError`.
-Helper to create typed chat options (re-exported from `@tanstack/ai-client`).
+## Re-exports from `@tanstack/ai-client`
-```typescript
-import {
- createChatClientOptions,
- fetchServerSentEvents,
- type InferChatMessages
-} from "@tanstack/ai-client";
-import { tool1, tool2 } from "./tools";
+You can import these from `@tanstack/ai-react` directly. See [`@tanstack/ai-client`](./ai-client) for details.
-// Create typed tools array (no 'as const' needed!)
-const tools = [tool1, tool2];
+| Group | Exports |
+| --- | --- |
+| Connection adapters | `fetchServerSentEvents`, `fetchHttpStream`, `fetchJson`, `xhrServerSentEvents`, `xhrHttpStream`, `webSocket`, `stream`, `rpcStream` |
+| Typed options | `createChatClientOptions`, `clientTools` |
+| Persistence | `localStoragePersistence`, `sessionStoragePersistence`, `indexedDBPersistence` |
+| Clients | `ChatClient`, `GenerationClient`, `VideoGenerationClient`, `RealtimeClient`, `AudioRecorder`, `InterruptManager` |
+| WebMCP and MCP Apps | `registerWebMCPTools`, `getWebMCPTools`, `subscribeWebMCPTools`, `createMcpAppBridge` |
+| Errors | `UnsupportedResponseStreamError`, `StreamTruncatedError`, `DurableStreamIncompleteError`, `StreamReconnectLimitError`, `StorageUnavailableError` |
+| Stream helpers | `StreamProcessor`, chunk strategies, `parsePartialJSON`, message converters |
-const chatOptions = createChatClientOptions({
- connection: fetchServerSentEvents("/api/chat"),
- tools,
-});
+For React Native or Expo, use an absolute server URL and `xhrHttpStream()` with a route that returns `toHttpResponse()`. If the host cannot stream, use `fetchJson()` with `toJsonResponse()`. See [React Native](../transports/react-native).
-type Messages = InferChatMessages;
-```
+BYOK helpers (`defineByok`, `defaultByokStorage`, and more) are on the `@tanstack/ai-react/byok` subpath.
## Types
-Re-exported from `@tanstack/ai-client`:
-
-- `UIMessage` - Message type with tool type parameter
-- `MessagePart` - Message part with tool type parameter
-- `TextPart` - Text content part
-- `ThinkingPart` - Thinking content part
-- `ToolCallPart` - Tool call part (discriminated union)
-- `ToolResultPart` - Tool result part
-- `ChatClientOptions` - Chat client options with typed client runtime context
-- `ConnectionAdapter` - Connection adapter interface
-- `InferChatMessages` - Extract message type from options
-
-Re-exported from `@tanstack/ai`:
-
-- `toolDefinition()` - Create isomorphic tool definition
-- `ToolDefinitionInstance` - Tool definition type
-- `ClientTool` - Client tool type
-- `ServerTool` - Server tool type
-
-## Next Steps
+| Type | What it is |
+| --- | --- |
+| `UseChatOptions`, `UseChatReturn` | The options and return value of `useChat`. |
+| `UIMessage` | A chat message with typed parts. |
+| `InferChatMessages` | The message type for a `createChatClientOptions` result. |
+| `SendMessageOptions`, `WhenBusy`, `QueueOption`, `QueuedMessage` | Send and queue types. |
+| `DeepPartial` | The type of `partial` while structured output streams. |
+| `UseOptions`, `UseReturn` | Options and return types for the generation, realtime, and recorder hooks. The WebMCP hooks export only an options type. |
-- [Getting Started](../getting-started/quick-start) - Learn the basics
-- [Bring Your Own Key](../advanced/byok) - Store keys and pass `byok` into `useChat`
-- [Tools Guide](../tools/tools) - Learn about the isomorphic tool system
-- [Client Tools](../tools/client-tools) - Learn about client-side tools
+Pass a plain tools array to `useChat` or `createChatClientOptions`. Tool names, inputs, and outputs stay typed without `as const`. Import `toolDefinition` from `@tanstack/ai`.
diff --git a/docs/api/ai-remix.md b/docs/api/ai-remix.md
index c93848aca3..13ddac10aa 100644
--- a/docs/api/ai-remix.md
+++ b/docs/api/ai-remix.md
@@ -2,7 +2,7 @@
title: "@tanstack/ai-remix"
id: ai-remix
order: 9
-description: "API reference for @tanstack/ai-remix. Remix 3 helpers including createChat for streaming chat with full type safety."
+description: "API reference for @tanstack/ai-remix: the createChat helper, generation helpers, and Remix 3 bindings for the headless TanStack AI client."
keywords:
- tanstack ai
- "@tanstack/ai-remix"
@@ -12,9 +12,7 @@ keywords:
- api reference
---
-Install `@tanstack/ai-remix`, then call `createChat(handle, options)` in a Remix setup function. The package publishes uncompiled source. Remix compiles JSX through `jsxImportSource` `remix/ui`.
-
-For a typed headless chat UI, see [Remix Chat UI](../ui/remix). Import `createChatHook` from `@tanstack/ai-remix/ui`.
+Remix 3 helpers for TanStack AI. To build your first chat, start with the [Quick Start](../getting-started/quick-start). For a typed chat layout, see [Remix Chat UI](../ui/remix).
## Installation
@@ -24,260 +22,61 @@ remix: @tanstack/ai-remix remix
-`remix` is a required peer.
-
-## `createRegisterWebMCPTools(handle, tools, options?)`
-
-Register executable client tools for a Remix component. Remix removes them when the component `Handle` signal aborts.
-
-For a complete setup and behavior guide, see [WebMCP Tools](../tools/webmcp).
-
-```tsx
-import {
- createRegisterWebMCPTools,
- type CreateRegisterWebMCPToolsOptions,
-} from '@tanstack/ai-remix'
-import { clientEntry, type Handle } from 'remix/ui'
-import { searchProducts } from './tools'
-
-const tools = [searchProducts]
-const options: CreateRegisterWebMCPToolsOptions = {
- onError(error) {
- console.error(error)
- },
-}
-
-export const ProductsPage = clientEntry(
- import.meta.url,
- function ProductsPage(handle: Handle) {
- createRegisterWebMCPTools(handle, tools, options)
- return () => null
- },
-)
-```
+`remix` is a required peer. The package publishes uncompiled source. Remix compiles the JSX through the `remix/ui` `jsxImportSource`.
-`CreateRegisterWebMCPToolsOptions` contains `toolOptions`, `context`, and `onError`. The helper uses `handle.signal` for registration.
+Every helper takes the component `Handle` as its first argument. Call it in the setup function, not in the render function.
-The `context` field is required when a tool declares a required runtime context.
-
-## `createPageWebMCPTools(handle, options?)`
-
-Read the WebMCP tools on the page as client tools. The `tools` field starts empty. When the page adds or removes a tool, the helper updates `tools` and calls `handle.update()`. Read it in a `get tools()` getter on `createChat`.
-
-```ts
-import { createPageWebMCPTools } from '@tanstack/ai-remix'
-import type { Handle } from 'remix/ui'
-
-export function readPageTools(handle: Handle) {
- return createPageWebMCPTools(handle, {
- filter: (tool) => tool.origin === location.origin,
- })
-}
-```
-
-`filter` skips a tool when it returns `false`. `onError` gets a failed WebMCP read. For a complete guide, see [Page WebMCP Tools in Chat](../tools/webmcp-page-tools).
-
-## Server
-
-A Remix controller action can return the same SSE `Response` as any other host.
+The server side is a normal route. A Remix controller action returns the same SSE `Response` as any other host:
```typescript
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
-} from '@tanstack/ai'
-import { openaiText } from '@tanstack/ai-openai'
-import { createController } from 'remix/router'
-import { post, route } from 'remix/routes'
+} from "@tanstack/ai";
+import { openaiText } from "@tanstack/ai-openai";
+import { createController } from "remix/router";
+import { post, route } from "remix/routes";
const routes = route({
chat: {
- stream: post('/chat'),
+ stream: post("/chat"),
},
-})
+});
export default createController(routes.chat, {
actions: {
async stream({ request }) {
- const { messages, threadId, runId } =
- await chatParamsFromRequest(request)
+ const { messages, threadId, runId } = await chatParamsFromRequest(request);
const stream = chat({
- adapter: openaiText('gpt-5.6'),
+ adapter: openaiText("gpt-6.1-sol"),
messages,
threadId,
runId,
- })
+ });
- return toServerSentEventsResponse(stream)
+ return toServerSentEventsResponse(stream);
},
},
-})
+});
```
-The matching client calls `createChat` and streams from that route. See [Quick Start](../getting-started/quick-start).
-
## `createChat(handle, options)`
-Manages chat state in a Remix component. Pass the component `Handle` as the first argument. Put `connection` and `tools` in setup. They are not serializable `clientEntry` props.
-
-```tsx
-import { createChat, fetchServerSentEvents } from '@tanstack/ai-remix'
-import {
- createChatClientOptions,
- type InferChatMessages,
-} from '@tanstack/ai-client'
-import { toolDefinition } from '@tanstack/ai'
-import { clientEntry, on, type Handle } from 'remix/ui'
-import { z } from 'zod'
-
-const updateUIDef = toolDefinition({
- name: 'updateUI',
- description: 'Show a notification in the UI',
- inputSchema: z.object({ message: z.string() }),
-})
-
-export const ChatComponent = clientEntry(
- import.meta.url,
- function ChatComponent(handle: Handle) {
- let notification: string | null = null
- const updateUI = updateUIDef.client((input) => {
- notification = input.message
- return { success: true }
- })
- const tools = [updateUI]
-
- const chatOptions = createChatClientOptions({
- connection: fetchServerSentEvents('/chat'),
- tools,
- })
-
- type ChatMessages = InferChatMessages
-
- const chat = createChat(handle, chatOptions)
-
- return () => (
-