Building blocks
AI
#server/ai runs model calls from your server code: text, images, speech,
transcription, embeddings and video. The kit ships one provider, Workers AI
through the AI binding, and calls no model itself. You work here when you
build an AI feature or point a role at a different model.
How it works
ai.rolesinstarter.config.ts(Licensees only) lists each thing your app asks a model for: its modality, and the model each provider uses for it.- You call the entry point for that modality with a role, such as
runText(event, 'copy', …). A role of another modality fails type checking. server/ai/index.ts(Licensees only) tries the providers inai.providersorder. It skips a provider that names no model for the role, has no method for the modality, or has no binding or key. A failure the provider marks retryable is sent once more, then the next provider gets the same request.- The entry point returns the result. It throws a 501 when no provider can serve the role and a 502 when every provider refused.
The shipped roles
| Role | Modality | Workers AI model | Call | Returns |
|---|---|---|---|---|
copy | text | @cf/meta/llama-3.1-8b-instruct-fast | runText | string |
illustration | image | @cf/black-forest-labs/flux-1-schnell | runImage | { data, mediaType } |
narration | speech | @cf/deepgram/aura-1 | runSpeech | { data, mediaType: 'audio/mpeg' } |
transcript | transcription | @cf/openai/whisper | runTranscription | string |
search | embedding | @cf/baai/bge-large-en-v1.5 | runEmbedding | number[][], one per input |
motion | video | bytedance/seedance-2.0 | runVideo | { url, mediaType: 'video/mp4' } |
Rename them, add more roles of one modality, or point them at other models. A
role is an entry such as
copy: { modality: 'text', models: { workersai: '@cf/meta/llama-3.1-8b-instruct-fast' } }.
Role ids must match /^[a-z][a-z0-9]*$/, because each one becomes part of an
environment variable. The build also refuses a role that names a model for a
provider that can't serve its modality, and a role no bundled provider serves.
Call a model
Rate-limit anything that spends money per call (Rate limiting):
import { runText } from '#server/ai'
export default defineEventHandler(async (event) => {
const session = await requireVerifiedSession(event)
const { title } = await readValidatedBody(
event,
z.object({ title: z.string().min(1).max(200) }).parse,
)
await requireRateLimit(event, {
scope: 'ai-copy',
key: String(session.user.id),
policy: { max: 30, windowMs: 60 * 60_000, lockoutMs: 15 * 60_000 },
})
const blurb = await runText(event, 'copy', {
system: 'Write two plain sentences for a product listing.',
messages: [{ role: 'user', content: title }],
maxTokens: 300,
})
return { blurb }
})
A text request takes system, messages and maxTokens. There is no
temperature and no streaming. maxTokens caps the whole answer, including
thinking on models that think. Workers AI text models can't read an image part
in a message, so the adapter refuses such a request.
The other entry points take their request shapes from
server/ai/types.ts(Licensees only):
const { data, mediaType } = await runImage(event, 'illustration', {
prompt: 'A lighthouse at dusk, flat colours',
})
const vectors = await runEmbedding(event, 'search', { input: [title, blurb] })
runImage reads the media type from the bytes (JPEG, PNG or WebP), so store it
as the Content-Type when you put the image in R2 (Storage).
runEmbedding always takes a list and returns vectors in input order. Where
they go is up to you: there is no vector store.
Check before you offer a feature
isAiConfigured(event, role) says whether any bundled provider can serve the
role on this deploy, with its binding or key in place. Return it from a route
and hide the button rather than show one that answers 501.
Handle a failure yourself
attemptAi(event, role, request) runs the same pass without throwing. It
returns the success object or { ok: false, reason, retryable }. The reason is
the provider's own message and can quote your prompt back, so log it, but never
show it or store it on a row a user sees. The entry points log it as
[ai] every provider refused "<role>": … before their 502.
Generate video in a job
runVideo waits minutes for the result, so call it from a background job
(Background jobs), not from a request. The route
records a row and enqueues; the job settles the row.
server/jobs/render-clip.ts:
import { attemptAi } from '#server/ai'
import { defineJob } from '#server/utils/jobs'
export interface Payload {
clipId: number
prompt: string
}
export default defineJob<Payload>({
async run({ clipId, prompt }, event) {
const result = await attemptAi(event, 'motion', {
prompt,
durationSeconds: 5,
aspectRatio: '16:9',
})
if (!result.ok && result.retryable) throw new Error('motion: retry later')
await useDrizzle(event)
.update(tables.clips)
.set(
result.ok ? { status: 'ready', url: result.url } : { status: 'failed' },
)
.where(eq(tables.clips.id, clipId))
},
})
clips is your own table. A job retries on a throw and every retry is billed
again, so throw only when a repeat can help. The URL belongs to the provider and
expires; copy the file into R2 if it has to last. For image-to-video, pass
imageUrl: a URL the provider can fetch, or a data: URI of the frame.
Change a role's model
Edit the model id in ai.roles, or override it per deploy without a rebuild:
NUXT_AI_WORKERSAI_MODELS_COPY=anthropic/claude-sonnet-5
The variable is NUXT_AI_<PROVIDER>_MODELS_<ROLE> and wins over the config.
@cf/… ids are Cloudflare's own models, billed as Workers AI usage.
author/model ids are third-party models Cloudflare resells through AI
Gateway and bills to your Cloudflare account, so load credits under AI Gateway >
Credits first. They also need ai.gateway set; the shipped 'default' works.
The model catalogue is at https://developers.cloudflare.com/ai/models/.
Add a provider
Create server/ai/adapters/custom/<id>.ts with a default-exported AiAdapter
whose id is the file name, and implement a method per modality it serves. A
method must not throw: return { ok: false, reason, retryable }, with
retryable only for failures a repeat can fix, such as a 429 or a 5xx.
server/ai/adapters/custom/openai.ts:
import type { AiAdapter } from '#server/ai/types'
interface ChatCompletion {
choices?: { message?: { content?: string | null } }[]
}
const openai: AiAdapter = {
id: 'openai',
transport: 'fetch',
async text(request, config) {
if (request.messages.some((m) => typeof m.content !== 'string')) {
return { ok: false, reason: 'text messages only', retryable: false }
}
try {
const response = await $fetch.raw<ChatCompletion>(
'https://api.openai.com/v1/chat/completions',
{
method: 'POST',
headers: { authorization: `Bearer ${config.key}` },
body: {
model: config.model,
messages: [
...(request.system
? [{ role: 'system', content: request.system }]
: []),
...request.messages,
],
},
ignoreResponseError: true,
},
)
if (!response.ok) {
return {
ok: false,
reason: `openai answered ${response.status}`,
retryable: response.status === 429 || response.status >= 500,
}
}
const text = response._data?.choices?.[0]?.message?.content
return text
? { ok: true, text, provider: 'openai' }
: { ok: false, reason: 'openai returned no text', retryable: false }
} catch (error) {
return { ok: false, reason: `openai: ${String(error)}`, retryable: true }
}
},
}
export default openai
Then list it, declare what it serves, and give each role it should serve a model:
ai: {
enabled: true,
providers: ['workersai', 'openai'],
gateway: 'default',
adapters: {
openai: { modalities: ['text'], credentials: ['apiKey'] },
},
roles: {
copy: {
modality: 'text',
models: {
workersai: '@cf/meta/llama-3.1-8b-instruct-fast',
openai: 'gpt-5-mini',
},
},
},
},
The build refuses a provider you added until modalities lists what it
serves. The key arrives as config.key from NUXT_AI_OPENAI_API_KEY, and the
Worker logs that variable at boot while it is unset. Without a key the provider
is skipped. Restart pnpm dev after adding the file. A file named
workersai.ts in custom/ replaces the shipped adapter.
Turn AI off
Set ai.enabled: false. No adapter is bundled, and every entry point answers
with a 501. You can then drop the ai block from wrangler.jsonc and run
pnpm cf-typegen.
Configuration
| Key or variable | Where | Default | What it does |
|---|---|---|---|
ai.enabled | starter.config.ts | true | Off bundles no adapter; every call answers 501. |
ai.providers | starter.config.ts | ['workersai'] | Providers to bundle, in fallback order. |
ai.gateway | starter.config.ts | 'default' | AI Gateway id every Workers AI call goes through. |
ai.roles | starter.config.ts | six roles | The catalogue of model calls. |
ai.adapters | starter.config.ts | none | Modalities and credential keys of an adapter you added. |
"ai": { "binding": "AI" } | wrangler.jsonc | present | The binding workersai calls. The build fails without it. |
NUXT_AI_<PROVIDER>_MODELS_<ROLE> | environment | unset | Overrides one role's model for one provider. |
NUXT_AI_<PROVIDER>_API_KEY | environment | unset | Key of a provider you added. Workers AI needs none. |
NUXT_DEV_REMOTE_AI | .env | unset | true lets pnpm dev call Workers AI on your Cloudflare account. |
Good to know
pnpm devruns without the AI binding, so every call answers 501 until you runpnpm exec wrangler loginand setNUXT_DEV_REMOTE_AI=truein.env. Those calls are real and billed.- Workers AI checks each model's input against that model's schema and refuses
unknown fields with error 5006. The adapter in
server/ai/adapters/workersai.ts(Licensees only) maps requests for Flux, the diffusion image models, Aura, MeloTTS and Seedance-style video. A model from another family may need its own mapping there. - A provider you add receives only the credential named
apiKey, asconfig.key. Other keys you list are read into runtime config but never reach the adapter. runTranscriptiontreats silence as a failure and throws a 502. UseattemptAiwhere empty audio is expected.test/unit/ai-registry.test.ts(Licensees only) expects the shipped catalogue to cover all six modalities. If you remove the last role of a modality, update that test with it.
A link with a lock opens the kit’s private repository, which every licensee can read.
Feature flags
Declare typed flags in starter.config.ts, read them with flag() or useFlag(), roll them out by rule or percentage, and add a provider.
Security headers
The Content-Security-Policy and hardening headers on every response: allow another origin, keep tokens out of Referer, accept cross-site posts.