The Cloudflare adapter calls Workers AI, and it can put AI Gateway in front of another provider.
npm i @tanstack/ai-cloudflarepnpm add @tanstack/ai-cloudflareyarn add @tanstack/ai-cloudflarebun add @tanstack/ai-cloudflareInside a Worker, use the env.AI binding. You need no token and no account id. Add the binding to wrangler.jsonc:
{
"ai": { "binding": "AI" },
}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:
CLOUDFLARE_ACCOUNT_ID=your-account-id
CLOUDFLARE_API_TOKEN=your-api-tokenGet the account id from wrangler whoami. Create the token in the Cloudflare dashboard, under Workers AI, then Use REST API.
From a Worker, pass env.AI as the binding:
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from "@tanstack/ai";
import { createCloudflareText } from "@tanstack/ai-cloudflare";
import type { Ai } from "@cloudflare/workers-types";
interface Env {
AI: Ai;
}
export default {
async fetch(request: Request, env: Env) {
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);
},
};Run wrangler dev to start the route.
From any other server, cloudflareText reads CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN:
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:
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!,
});The client is the same for every provider. Point useChat at your route:
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 (
<form
onSubmit={(event) => {
event.preventDefault();
sendMessage(input);
setInput("");
}}
>
{messages.map((message) => (
<p key={message.id}>
{message.parts
.map((part) => (part.type === "text" ? part.content : ""))
.join("")}
</p>
))}
<input value={input} onChange={(event) => setInput(event.target.value)} />
</form>
);
}For Vue, Solid, Svelte, Angular, and the other frameworks, see Quick Start.
You now have a streaming chat on Workers AI. Tools and Structured Outputs work the same as with every adapter.
Every chat turn sends the whole conversation again. Workers AI can serve the repeated start of the prompt from its cache, but only when the turns of one conversation reach the same model instance. Send an x-session-affinity header with one value per conversation to keep them together. See Workers AI prompt caching.
Set the header with defaultHeaders:
import { chat, toServerSentEventsResponse } from "@tanstack/ai";
import { createCloudflareText } from "@tanstack/ai-cloudflare";
import type { Ai } from "@cloudflare/workers-types";
interface Env {
AI: Ai;
}
export default {
async fetch(request: Request, env: Env) {
const { messages, conversationId } = await request.json();
const stream = chat({
adapter: createCloudflareText("@cf/zai-org/glm-5.3-flash", {
binding: env.AI,
defaultHeaders: { "x-session-affinity": `ses_${conversationId}` },
}),
messages,
threadId: conversationId,
});
return toServerSentEventsResponse(stream);
},
};The binding sends defaultHeaders as the extraHeaders run option, next to gateway. The REST config takes the same defaultHeaders field.
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:
import { createCloudflareText } from "@tanstack/ai-cloudflare";
import type { Ai } from "@cloudflare/workers-types";
interface Env {
AI: Ai;
}
export function makeAdapter(env: Env) {
return createCloudflareText("@cf/zai-org/glm-5.3-flash", {
binding: env.AI,
gateway: { id: "default", cacheTtl: 3600, skipCache: false },
});
}With a gateway, you can also run third-party models by their catalog id. Cloudflare bills them:
import { createCloudflareText } from "@tanstack/ai-cloudflare";
const adapter = createCloudflareText("openai/gpt-5.5", {
accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
apiKey: process.env.CLOUDFLARE_API_TOKEN!,
gateway: { id: "default" },
});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:
import { createOpenaiChat } from "@tanstack/ai-openai";
import { cloudflareGateway } from "@tanstack/ai-cloudflare";
const gateway = cloudflareGateway("openai", {
accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
gatewayId: "prod",
cacheTtl: 300,
});
const adapter = createOpenaiChat("gpt-6.1-sol", process.env.OPENAI_API_KEY!, {
baseURL: gateway.baseURL,
defaultHeaders: gateway.headers,
});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.
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:
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";
interface Env {
AI: Ai;
}
export default {
async fetch(request: Request, env: Env) {
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", {
fetch: cloudflareBindingFetch({
binding: env.AI,
vendor: "anthropic",
gateway: { id: "default" },
}),
});
const stream = chat({ adapter, messages, threadId, runId, reasoning: "high" });
return toServerSentEventsResponse(stream);
},
};Two different things have this name. Both work.
A user brings two values: the API token, and the account that it belongs to.
On the client:
import { defineByok, defaultByokStorage } from "@tanstack/ai-client/byok";
import {
cloudflareAccountByok,
cloudflareByok,
} from "@tanstack/ai-cloudflare/byok";
export const byok = defineByok({
storage: defaultByokStorage(),
providers: [cloudflareByok, cloudflareAccountByok],
});On the relay, read both values in one call:
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from "@tanstack/ai";
import { createCloudflareText } from "@tanstack/ai-cloudflare";
import {
cloudflareAccountByok,
cloudflareByok,
} from "@tanstack/ai-cloudflare/byok";
import { byokMissing, getByokKeys } from "@tanstack/ai/byok/server";
export async function POST(request: Request) {
const { apiKey, accountId } = getByokKeys(request, {
apiKey: cloudflareByok,
accountId: cloudflareAccountByok,
});
if (!apiKey) return byokMissing(cloudflareByok);
if (!accountId) return byokMissing(cloudflareAccountByok);
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);
}See Bring Your Own Key for the client store and the save dialog.
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.
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:
import { decide, choice, score, boolean } from "@tanstack/ai";
import { createCloudflareDecider } from "@tanstack/ai-cloudflare";
import type { Ai } from "@cloudflare/workers-types";
interface Env {
AI: Ai;
}
const ticket = {
subject: "Charged twice for the same invoice",
body: "Please refund the extra payment.",
};
export async function evaluateTicket(env: Env) {
const result = await decide({
adapter: createCloudflareDecider("typesafe/jev", {
binding: env.AI,
gateway: { id: "default" },
}),
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 result;
}From any other server, cloudflareDecider reads CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN:
import { decide, boolean } from "@tanstack/ai";
import { cloudflareDecider } from "@tanstack/ai-cloudflare";
const result = await decide({
adapter: cloudflareDecider("typesafe/jev"),
state: "Please refund the extra payment.",
questions: {
refund: boolean({
instructions: "Is the customer asking for a refund?",
}),
},
});
console.log(result.refund.value);See the Evaluate guide for the question helpers, the result shape, abort, and middleware.
Every activity takes the same config as chat:
The samples below use the factories that read the environment.
import { summarize } from "@tanstack/ai";
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);import { embed } from "@tanstack/ai";
import { cloudflareEmbedding } from "@tanstack/ai-cloudflare";
const result = await embed({
adapter: cloudflareEmbedding("@cf/baai/bge-m3"),
input: ["a story about a llama", "a story about a cloud"],
});
console.log(result.embeddings[0]?.vector);import { generateImage } from "@tanstack/ai";
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 },
});
console.log(result.images[0]?.b64Json);import { generateSpeech } from "@tanstack/ai";
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",
});
console.log(result.audio); // base64 MP3import { generateTranscription } from "@tanstack/ai";
import { cloudflareTranscription } from "@tanstack/ai-cloudflare";
import { audioBuffer } from "./audio";
const result = await generateTranscription({
adapter: cloudflareTranscription("@cf/openai/whisper-large-v3-turbo"),
audio: audioBuffer,
threadId: "thread-1",
});
console.log(result.text, result.segments, result.words);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.
These ids work:
Ids in the bundled catalog get editor autocomplete.
Sampling options go in modelOptions. The thinking level goes in reasoning:
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",
});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.
Need a header on each request, or a log of each model call? The chat adapter supports wrapFetch 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.
Two kinds of adapter send the full tools and system prompts on every call:
This is also true on a model with a mid-conversation channel. 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:
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,
});
}