skills/vercel-ai-sdk/SKILL.md
Build, debug, and tune Vercel AI SDK (v6) code — Core primitives (streamText, generateText, generateObject, streamObject, embed, embedMany, tool() agentic loops, wrapLanguageModel middleware), UI hooks (useChat, useCompletion, useObject), and provider-specific features for Anthropic (prompt caching, extended thinking), OpenAI (reasoning effort, structured outputs), and Google (grounding, thinking budget). Covers the v6 UIMessage parts[] wire protocol, DefaultChatTransport, message persistence, abort/retry, edge-runtime gotchas (Cloudflare Workers process.env), and v5 → v6 migration. Use when the user mentions Vercel AI SDK, `ai` package, useChat, streamText, generateObject, structured output with Zod, agentic tools, prompt caching, AI SDK v6, AI SDK migration, or wires Anthropic/OpenAI/Google through the unified provider interface.
npx skillsauth add RonanCodes/ronan-skills vercel-ai-sdkInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Wire the Vercel AI SDK v6 (ai, @ai-sdk/react, @ai-sdk/anthropic, @ai-sdk/openai, @ai-sdk/google) into a TypeScript app. Default streaming UI, structured output via Zod, agentic tool loops, multi-provider routing, and middleware.
Canonical decision: this is the user's default for any LLM-touching code in TanStack Start / Cloudflare Workers projects. Drop to a raw provider SDK only when the AI SDK has not wrapped a feature you need (see "When NOT to use" below).
The user never invokes this skill manually; the harness auto-loads it via fuzzy match on the description's keywords. By the time these instructions are in your context, the user has not actively chosen to add the AI SDK. So:
Before introducing the SDK to a project that doesn't already use it (i.e. before any pnpm add ai @ai-sdk/... or a new lib/models.ts scaffold), prompt the user via AskUserQuestion:
Question: "I'm about to wire the Vercel AI SDK into this project for
<feature>. Sound good, or would you rather take a different approach?" Options:
- Wire it. Vercel AI SDK is the right default (recommended)
- Wait, explain the alternatives first
- Use a raw provider SDK instead
- Skip the AI feature for now
For projects that already use the SDK (check package.json for "ai" + "@ai-sdk/*" deps), skip the prompt and proceed with feature work. Don't re-confirm every change inside a single conversation.
For STRUCTURAL changes inside an existing-SDK project (adding a new provider package, enabling prompt caching for the first time, introducing tool calling where none existed, switching the primary model), a single quick AskUserQuestion is still worth it.
For pure code-tweak changes (refactoring an existing streamText call, tightening a Zod schema, adding a log line), just proceed. No prompt.
Reason: the user prefers being looped in on structural changes since auto-loaded skills can otherwise surprise-install dependencies. See the feedback_skills_prompt_before_structural_change memory for the durable rule.
Reach for this skill when the user wants any of:
useChat)generateObject)streamObject + useObject)tool() + stopWhen)embed, embedMany)wrapLanguageModel)providerOptionsinputSchema vs parameters)Drop to the raw provider SDK (@anthropic-ai/sdk, openai, @google/generative-ai) when:
For everything else (chat UIs, agentic loops, structured output, provider portability), the AI SDK is the default.
Need to talk to an LLM?
├─ User-facing text reply? ───────────────► streamText + result.toUIMessageStreamResponse()
├─ Backfill / non-interactive text? ──────► generateText
├─ Structured JSON output? ───────────────► generateObject (+ Zod)
│ └─ Want to render it as it builds? ───► streamObject + useObject
├─ Chatbot UI in React? ──────────────────► useChat + DefaultChatTransport (server runs streamText)
├─ Single-turn text completion UI? ───────► useCompletion (server runs streamText)
├─ Vector embeddings? ────────────────────► embed / embedMany
├─ Multimodal (image, audio, TTS)? ───────► generateImage / transcribe / speech
└─ Multi-step with tools? ────────────────► generateText/streamText + tools + stopWhen(stepCountIs(N))
Server route (TanStack Start file-route example):
import { streamText, convertToModelMessages, type UIMessage } from 'ai';
import { createAnthropic } from '@ai-sdk/anthropic';
export const Route = createFileRoute('/api/chat')({
server: {
handlers: {
POST: async ({ request }) => {
const { env } = requireWorkerContext();
const anthropic = createAnthropic({ apiKey: env.ANTHROPIC_API_KEY });
const body = (await request.json()) as { messages: UIMessage[] };
const result = streamText({
model: anthropic('claude-sonnet-4-5'),
system: 'You are a helpful assistant.',
messages: convertToModelMessages(body.messages),
temperature: 0.7,
abortSignal: request.signal,
onError: ({ error }) => console.error('stream error', error),
});
return result.toUIMessageStreamResponse();
},
},
},
});
Client:
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { useState } from 'react';
function Chat() {
const [draft, setDraft] = useState('');
const { messages, sendMessage, status, stop, error } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});
return (
<form onSubmit={(e) => {
e.preventDefault();
if (!draft.trim() || status !== 'ready') return;
sendMessage({ text: draft });
setDraft('');
}}>
{messages.map((m) => (
<div key={m.id}>
{m.parts.filter((p) => p.type === 'text').map((p, i) => (
<span key={i}>{(p as { text: string }).text}</span>
))}
</div>
))}
<input value={draft} onChange={(e) => setDraft(e.target.value)} />
{status === 'streaming' && <button type="button" onClick={stop}>Stop</button>}
</form>
);
}
import { generateObject } from 'ai';
import { z } from 'zod';
const Rubric = z.object({
grammar: z.number().int().min(1).max(5),
feedbackEn: z.string(),
errors: z.array(z.object({
incorrect: z.string(),
correction: z.string(),
})).default([]),
});
const { object: rubric } = await generateObject({
model: anthropic('claude-sonnet-4-5'),
schema: Rubric,
schemaName: 'RoleplayRubric',
system: 'You are a Dutch language grader.',
prompt: transcript,
});
For live-filling UI, swap to streamObject on the server and useObject on the client (same schema both sides).
Mark long, repeated content (system prompt, RAG context, doc retrieval) with a cache breakpoint. First call writes cache, subsequent calls within ~5 minutes read at ~10% cost.
const result = streamText({
model: anthropic('claude-sonnet-4-5'),
messages: [
{
role: 'system',
content: [
{
type: 'text',
text: bigSystemPrompt, // >= ~1024 tokens to be worth it
providerOptions: {
anthropic: { cacheControl: { type: 'ephemeral' } },
},
},
],
},
...convertToModelMessages(body.messages),
],
});
// After streaming:
const meta = await result.providerMetadata;
console.log(meta?.anthropic?.cacheReadInputTokens, meta?.anthropic?.cacheCreationInputTokens);
Up to 4 cache breakpoints per request. Place them on the most-repeated content first (system, then static RAG, then few-shot examples).
import { tool, stepCountIs } from 'ai';
import { z } from 'zod';
const lookupCustomer = tool({
description: 'Look up a customer by id; returns name, plan, region.',
inputSchema: z.object({ id: z.string() }),
execute: async ({ id }, { abortSignal }) => {
const row = await db.select().from(customers).where(eq(customers.id, id));
return row[0] ?? { error: 'not_found' };
},
});
const result = await generateText({
model: anthropic('claude-sonnet-4-5'),
tools: { lookupCustomer },
stopWhen: stepCountIs(5),
prompt: 'Summarise customer cust_42.',
onStepFinish: ({ stepNumber, toolCalls }) => log.debug({ stepNumber, toolCalls }),
});
// result.steps[] has every intermediate call + result
Note: v6 uses inputSchema, not v5's parameters. Tool execute receives { toolCallId, messages, abortSignal, experimental_context } as second arg.
streamObject + useObject for a live rubricServer:
import { streamObject } from 'ai';
const result = streamObject({
model: anthropic('claude-sonnet-4-5'),
schema: Rubric,
prompt: transcript,
});
return result.toTextStreamResponse();
Client:
import { useObject } from '@ai-sdk/react';
const { object, submit, isLoading } = useObject({
api: '/api/grade.stream',
schema: Rubric,
});
// object is DeepPartial<z.infer<typeof Rubric>>; renders progressively
useChat with persistence (server-side history)Client transport ships only the new message; server reloads history.
const { messages, sendMessage, status } = useChat({
id: chatId, // stable per conversation
messages: initialMessages, // server-loaded on mount
transport: new DefaultChatTransport({
api: '/api/chat',
prepareSendMessagesRequest: ({ messages, id }) => ({
body: { message: messages[messages.length - 1], id },
}),
}),
});
Server (TanStack Start):
const { id, message } = await request.json();
const history = await loadMessages(id);
const all = [...history, message];
const result = streamText({
model: anthropic('claude-sonnet-4-5'),
messages: convertToModelMessages(all),
});
return result.toUIMessageStreamResponse({
originalMessages: all,
onFinish: async ({ messages }) => {
await saveMessages(id, messages); // persist final assistant turn
},
});
Pair with createIdGenerator({ prefix: 'msg', size: 16 }) for stable ids across client + server.
// Client: pass files alongside text
sendMessage({
text: 'Describe this image in Dutch.',
files: [imageFile], // File | Blob
});
// Server: convertToModelMessages handles file parts automatically
// Anthropic/OpenAI/Google all accept image input through the same shape
providerOptions.anthropic.cacheControl: { type: 'ephemeral' }) — see recipe 3.providerOptions.anthropic.thinking: { type: 'adaptive' } and effort: 'low' | 'medium' | 'high' | 'max'. Returns reasoningText on the result.anthropic.tools: computer_20251124(), bash_20250124(), textEditor_20250728(), codeExecution_20260120(), webSearch_20250305(), webFetch_20250910().file part with mediaType: 'application/pdf'.openai('gpt-5') uses it. Force chat with openai.chat('gpt-5').providerOptions.openai.reasoningEffort: 'low' | 'medium' | 'high' for gpt-5, o3, o4-mini.providerOptions.openai.strictJsonSchema: false when your schema uses unions/records OpenAI's strict mode rejects.providerMetadata?.openai?.cachedPromptTokens. No config needed.openai.image('gpt-image-1') or openai.image('dall-e-3').tools: { search: google.tools.googleSearch({}) }. Result exposes sources and groundingMetadata.providerOptions.google.thinkingBudget: 8192 (Gemini 2.5) or thinkingLevel: 'medium' (Gemini 3). Set includeThoughts: true for reasoning summaries.tools: { code: google.tools.codeExecution({}) } runs Python sandbox.file parts; the SDK fetches automatically except for generativelanguage.googleapis.com and YouTube URLs (those are handled provider-side).providerOptions.google.structuredOutputs: false for schemas with unions/records.process.env — the static import { anthropic } reads process.env.ANTHROPIC_API_KEY, which is undefined in Workers. Always use createAnthropic({ apiKey: env.ANTHROPIC_API_KEY }) (same shape for createOpenAI, createGoogleGenerativeAI) with the runtime-bound env.request.signal as abortSignal so user disconnect cancels the upstream call. onAbort fires; onFinish does not.maxRetries defaults to 2, exponential backoff. Bump down to 0 on user-facing low-latency paths so failures fail fast.useChat exposes error and regenerate(). Show a retry button on status === 'error'. Server stream errors arrive as error parts on fullStream and do not crash the connection (by design).result.usage is per-turn. For multi-step agent loops, use result.totalUsage or sum across result.steps[*].usage. Logging just usage after a loop returns only the final step.fetch: to inject a wrapped fetch for proxying / logging / mocking. Useful when running behind a forward proxy or in tests.data-progress, data-citations, etc. from the server for non-LLM channel data. Client reads part.type.startsWith('data-') on UIMessage.parts[].UIMessage vs ModelMessage. UI uses parts[]; the model uses content blocks. Always convertToModelMessages(uiMessages) at the server boundary before handing to streamText. Skipping this passes through for trivial cases and silently breaks once a tool call or attachment is in the history.m.content — does not exist on v6 UIMessage. Always m.parts.filter(p => p.type === 'text').map(p => p.text).join('').initialMessages identity — pass a memoised array (useMemo) or you reset the chat on every render.useChat — input, handleInputChange, handleSubmit are gone. You own the input state, you call sendMessage({ text }). Replace any of those v5 props on migration.inputSchema, not parameters. Update tool definitions on migration.result.totalUsage after agent loops, not the last step's usage.validateUIMessages({ messages, tools }) so old shapes do not crash the model.| v5 | v6 |
|---|---|
| useChat({ api: '/api/chat' }) | useChat({ transport: new DefaultChatTransport({ api: '/api/chat' }) }) |
| input, handleInputChange, handleSubmit | Own input state; sendMessage({ text }) |
| m.content: string | m.parts[]: Part[] |
| tool({ parameters: z.object() }) | tool({ inputSchema: z.object() }) |
| maxSteps on useChat | stopWhen / sendAutomaticallyWhen |
| experimental_attachments | First-class file parts |
| result.text for full streamed text | Same on generateText; on streamText use await result.text (promise) |
| Hooked fetch from inside the hook | Explicit transport object (DefaultChatTransport, DirectChatTransport, custom) |
llm-wiki-ai-research:
/ro:new-tanstack-app (the stack this sits on), /ro:cf-ship (deployment), claude-api (when dropping to raw Anthropic SDK)testing
--- name: linear-pipeline description: The Fable orchestrator for a single dispatched Linear ticket. Holds almost no context itself; it receives `--issue <ID> --detached`, decides the stage sequence, and fans out a sub-agent per stage, passing forward only each stage's artifact (never re-derived, never inlined into its own context). Step zero, before any planning or stage routing, is a boundary triage against `canon/security-boundary.md` (#199): a match tags Ronan Connolly and stops the run, no
development
--- name: in-your-face description: Capture a chat-only answer into a durable artifact (markdown + HTML, PDF when cheap) and launch it automatically so the user cannot miss it. Use when user says "in your face", "don't let me lose this", "save that answer", "make that durable", or right after answering a substantive side question (a recipe, comparison, how-to, or generated prompt) that would otherwise die with the context. category: workflow argument-hint: [--no-open] [--vault <short>] [hint of
tools
One-shot headless OpenAI Codex CLI calls for background/admin AI tasks — summaries, classification, extraction, admin glue. The default engine for anything that runs AI constantly in the background (daemon-driven, per-event), because it bills the flat ChatGPT subscription instead of Claude usage or per-token API spend, and it keeps working while Claude is rate-limited. NEVER for coding — coding stays Claude. Use when a skill or daemon needs a cheap always-on AI call, when the user says "use codex", "ask codex", "codex as backup", or when building a background summarizer/classifier into a listener or loop. Reads auth from ~/.codex/auth.json (ChatGPT account, no API key).
research
Turn a warranty rejection, repair quote, or RMA email into a cited decision brief — legal read (NL/EU consumer law), is the part user-serviceable, live part and new-unit prices, repair-vs-DIY-vs-new economics, before-you-send-it checklist, deadlines. Use when the user pastes or screenshots a repair quote, warranty rejection, "not covered" email, onderzoekskosten fee, or asks "should I repair or replace this".