Using CHAI in your own codebase

Develop with Chai UI

This page is about practical use — not why CHAI is shaped this way, but how to actually install it, render a component, and extend the parts meant to be extended.

Status

Live on npm as @chai-ui/react, @chai-ui/vue, @chai-ui/core and @chai-ui/tokens. The packages share one version, so install them together. The code boxes show React or Vue: pick one in any box and the whole site follows.

Install & quick start

Three packages: @chai-ui/tokens (design tokens, as CSS custom properties), @chai-ui/core (the headless data model, with no framework dependency), and a binding for your framework, @chai-ui/react or @chai-ui/vue (components, hooks or composables, and the default primitive set).

npm install @chai-ui/core @chai-ui/react @chai-ui/tokens

Import the tokens CSS before the binding's stylesheet: the component styles reference --chai-* custom properties that the tokens package defines.

import "@chai-ui/tokens/css";
import "@chai-ui/react/style.css";
import { useState } from "react";
import { Composer, type ComposerAttachment, type ComposerSubmitPayload } from "@chai-ui/react";
function Demo() {
const [value, setValue] = useState("");
const [attachments, setAttachments] = useState<ComposerAttachment[]>([]);
return (
<Composer
value={value}
onChange={setValue}
attachments={attachments}
onAttachmentsChange={setAttachments}
onSubmit={(payload: ComposerSubmitPayload) => {
// payload = { value, attachments, useCase, modelId, aspectRatio }
// Send it to whichever model/provider you've set up — Composer
// never calls one itself.
}}
/>
);
}

That's the minimal case — no model picker, no aspect ratio, no use-case chip. Every other control (model/aspect-ratio pickers, an end-user-facing auto-select switch, prompt optimization) is additive and opt-in via props; see the package README for the full example, or this site's own Composer bar catalog page for a live one with every control wired.

Docking the bar

Every menu in the bar (the + menu, Model, Aspect ratio, Voice, Genre and Frame rate) opens the same way, set by menuPlacement. By default it's "auto": each menu is measured when it opens and goes below the bar if it fits, like a start screen with the bar near the top. When it doesn't fit below, it goes above, and if it fits neither way, toward the side with more room. A menu is never cut off by the edge of the screen.

If your app docks the bar, say where, so the menus always open into the page:

// Docked to the bottom of the screen, like a chat view: menus open upward.
<Composer menuPlacement="above" value={value} onChange={setValue} onSubmit={onSubmit} />
// Docked to the top: menus open downward.
<Composer menuPlacement="below" value={value} onChange={setValue} onSubmit={onSubmit} />

An app that moves the bar, from the middle of a start screen to the bottom once a conversation begins, can switch it: "auto" on the start screen, "above" in the conversation.

Wiring a real engine: useComposer

useComposer (a hook in React, a composable in Vue) turns Composer's submit into a real run: it calls your engine once per picked model, tracks each result as it settles, and hands back a run you can render straight into a ResultCard. Wrap your app in a ChaiProvider with a reasoning model and you get prompt enhancement for free:

import { useState } from "react";
import {
ChaiProvider,
Composer,
ResultCard,
useComposer,
createFalEngine,
type ComposerAttachment,
} from "@chai-ui/react";
const engine = createFalEngine(); // media generation
// The reasoning models write text, e.g. "optimize prompt". The defaults
// run on OpenRouter; see "Reasoning models" below to swap them.
function Root() {
return (
<ChaiProvider>
<App />
</ChaiProvider>
);
}
function App() {
const [value, setValue] = useState("");
const [attachments, setAttachments] = useState<ComposerAttachment[]>([]);
const { run, runs, submit, cancel, enhance, enhancing } = useComposer({ engine });
// Every model's result from every use case, paged in one card.
const results = runs.flatMap((r) => r.results);
const busy = results.some((r) => r.status === "queued" || r.status === "running");
return (
<>
<Composer
value={value}
onChange={setValue}
attachments={attachments}
onAttachmentsChange={setAttachments}
onSubmit={submit}
submitting={busy}
onAbort={cancel}
onEnhance={enhance && (async (v) => setValue(await enhance(v)))}
enhancing={enhancing}
/>
{run && (
<ResultCard
results={results}
prompt={run.request.prompt}
onAction={(action, result) => {
// like / dislike / retry / download / share / your own "more" actions
}}
/>
)}
</>
);
}

With no engine, useComposer falls back to mockEngine — a placeholder image, no network call, no API key — so a first render works before any provider is set up.

Stopping a request

Pass cancel as Composer's onAbort and the submit button becomes a stop button while results are running. Stopping marks them "Stopped" right away; a streamed text answer keeps what already arrived. Whether the provider stops too depends on the provider:

  • Fal.ai: a request still in the queue is removed and never runs. One already running is asked to stop, but may finish.
  • Vercel AI Gateway: an aborted request closes its connection. Whether the model keeps running after that is up to the model's own provider.
  • OpenRouter: a streaming request stops, along with its billing, for providers that support it, including Anthropic and OpenAI but not Google. A non-streaming request keeps running.

Reasoning models

ChaiProvider's reasoning models write text: prompt optimization, auto-select model routing, and media analysis. These are suggested defaults, and you can swap any of them:

  • Text, images and routing: Claude Opus 5 (anthropic/claude-opus-5).
  • Video and audio: Gemini 3.8 Flash (google/gemini-3.8-flash). Claude can't read either kind.

If cost is a concern, Qwen3.8-Omni-Flash also reads video and audio, at a fraction of Gemini's price. Override only the kinds you want; the rest keep their defaults:

<ChaiProvider
reasoningModel="anthropic/claude-opus-5" // text, images, routing
reasoningModelByKind={{
video: "qwen/qwen3.8-omni-flash",
audio: "qwen/qwen3.8-omni-flash",
}}
>
<App />
</ChaiProvider>

Bringing your own engine

Every provider sits behind one interface — one call is one model run: a prompt plus any attached media in, one piece of output back:

TypeScript
// @chai-ui/core
interface GenerationEngine {
generate(args: {
modelId?: string; // the model the person picked, if any
prompt: string;
attachments: DroppedMedia[];
}): Promise<{ src: string; kind: MediaKind; usage?: Usage }>;
}
TypeScript
import type { GenerationEngine } from "@chai-ui/core";
const myEngine: GenerationEngine = {
async generate({ modelId, prompt, attachments }) {
const res = await fetch("/api/generate", {
method: "POST",
body: JSON.stringify({ model: modelId, prompt, attachments }),
});
const { url } = await res.json();
return { src: url, kind: "image" };
},
};
useComposer({ engine: myEngine });

Editing an image: EditCard

The attach menu's "Edit image" puts the Composer in edit mode. Pass the attached image to useComposer as editImage and it returns edit: everything an EditCard needs, plus the regions to share with the Composer. People mark regions on the image, give each its own instruction, and every edit comes back as a new version of the image.

import { Composer, EditCard, EDIT_IMAGE_USE_CASE, SUGGESTED_EDIT_MODELS, useComposer } from "@chai-ui/react";
const editImage = useCase?.edit ? (attachments.find((a) => a.kind === "image") ?? null) : null;
const { run, runs, submit, edit } = useComposer({ engine, editImage });
{edit && <EditCard {...edit} maxRegions={SUGGESTED_EDIT_MODELS[0].maxRegions} />}
<Composer
// ...your other Composer props
useCase={useCase}
onAttachMenuSelect={(action) => {
if (action === "edit-image") setUseCase(EDIT_IMAGE_USE_CASE);
}}
regions={edit?.regions}
onRegionsChange={edit?.onRegionsChange}
onSubmit={submit}
/>
  • Submitting: an edit needs the image plus a whole-image prompt, a region, or both. Regions carry their own instructions, so the prompt can stay empty.
  • Models: in edit mode the Composer offers the suggested edit models: Flux 3 Image first, which reads each region as a box, then Nano Banana Pro and GPT Image 2. Any edit model works; one without a regionFormat gets its regions described in words, which is less precise.
  • Versions: each edit's result is a new version in the card, and the next edit applies to the version showing. Images only for now.

See it running on the Edit card catalog page.

Re-theming: brand colors

Every color in CHAI comes from CSS variables in @chai-ui/tokens. To rebrand, override the semantic roles on :root after importing the tokens. The Material components underneath (the switch, the search field) read the same variables, so they follow automatically:

CSS
:root {
--chai-color-semantic-accent: #6b3fd4; /* buttons, switches, focus */
--chai-color-semantic-accent-soft: #e4d9ff; /* ready-to-submit fill */
--chai-color-semantic-accent-text: #4e2aa8; /* accent text on light surfaces */
}

To restyle one component, set its own variable instead, e.g. --chai-composer-bg on .chai-composer. Override variables, not the internal .chai-*__* class names, which can change without notice. The full list is on the Tokens page.

Model providers

Recommended for generation: Fal.ai

Use Fal.ai, or another provider with a wide range of image, video and audio generation models. Pass it to useComposer as its engine.

CHAI suggests four model marketplaces rather than one. As of 2026-09-24 the default is Fal.ai: OpenRouter has no text/image-to-video models at all, and its audio-generation catalog is thin too — 2 speech models and 2 music models, nothing for sound-effect generation or video-to-audio sync. Fal.ai covers both modality gaps broadly (dozens of TTS/voice-cloning, music, sound-effect, and video-to-audio models).

A default is a starting point, not a restriction — every adapter listed here is a permanent, fully-supported choice, and switching is meant to be free. OpenRouter stays a first-class option: its one-key setup is genuinely the lowest-friction of the three for CHAI's target users (designers, not infra engineers), and a builder whose use case doesn't need video or much beyond basic TTS/music has no reason to prefer Fal.ai's broader-but-more-setup catalog over it. Pick OpenRouter with the gaps above in mind — no video generation, thin audio — not as a downgrade, but as an informed trade for simpler setup. AWS Bedrock is the last alternative; it has the narrowest model selection across modalities, but it's included because AWS is a cloud provider design orgs already trust and often already have an account and procurement relationship with — that trust can outweigh catalog breadth for some teams, even though the setup itself (IAM credentials, region selection, per-model access requests) is the least designer-friendly of the three.

Vercel AI Gateway is a fourth option: one OpenAI-compatible endpoint in front of models from many labs, for text and image generation. Its catalog changes often, so there's no built-in model: pass the gateway model id you want. It has no video-generation endpoint, so video goes through Fal.ai.

createFalEngine() generates media. Text work (prompt optimization, routing, media analysis) goes through ChaiProvider's reasoning engine, OpenRouter by default (see "Reasoning models" above).

Works with your React or Vue stack

Chai comes for React (18.3 or 19) and Vue (3.5 or later), with the same components and behavior in both. @chai-ui/react works in any React app, including Next.js, React Router (formerly Remix) and Vite + React. @chai-ui/vue works in any Vue app, including Nuxt and Vite + Vue, with no Chai-specific build setup. Other UI frameworks aren't supported yet.

Add the server route

Chai's components send requests to /api/chai on your own server. That route adds your API key and passes the request on to Fal.ai, OpenRouter or Vercel AI Gateway, so the key never reaches the browser. Pick your setup below and add the code shown. It's usually one line.

Next.js: add one file.

TypeScript
// app/api/chai/[...path]/route.ts
import { createChaiHandler } from "@chai-ui/core/server";
export const { GET, POST, PUT } = createChaiHandler();

React Router (or Remix): add one route file.

TypeScript
// app/routes/api.chai.$.ts
import { createChaiHandler } from "@chai-ui/core/server";
const chai = createChaiHandler();
export const loader = ({ request }) => chai.handle(request);
export const action = loader;

Nuxt: add one server route.

TypeScript
// server/api/chai/[...path].ts
import { createChaiHandler } from "@chai-ui/core/server";
const chai = createChaiHandler();
export default defineEventHandler((event) => chai.handle(toWebRequest(event)));

Vite + React or Vue, with a separate API server: add the route to that server.

TypeScript
import { createChaiHandler, toNodeHandler } from "@chai-ui/core/server";
const chai = createChaiHandler();
// Express
app.use("/api/chai", toNodeHandler(chai));
// Hono
app.all("/api/chai/*", (c) => chai.handle(c.req.raw));
// Bun or Deno: send requests for /api/chai/* to chai.handle
Bun.serve({ fetch: (req) => chai.handle(req) });
// Cloudflare Workers: read the keys from env
export default {
fetch: (req, env) =>
createChaiHandler({ falKey: env.FAL_KEY, openRouterKey: env.OPENROUTER_API_KEY, aiGatewayKey: env.AI_GATEWAY_API_KEY }).handle(req),
};

Then set FAL_KEY, OPENROUTER_API_KEY or AI_GATEWAY_API_KEY in your server's environment, for whichever providers you use. Never give them a VITE_ or NEXT_PUBLIC_ prefix: those get bundled into the browser. That's the whole setup. If a key is missing, the request fails with a message saying which one.

Before you go live

Without limits, anyone who can reach your site can run models on your account. Two optional settings close that off:

TypeScript
createChaiHandler({
// Only your signed-in users. Return false to reject with 403.
authorize: async (request) => Boolean(await getSession(request)),
// Only the models your app offers. Anything else is rejected with 403.
allowedModels: ["fal-ai/flux/schnell", "anthropic/claude-opus-5", "google/gemini-3.8-flash"],
});

getSession stands for your own auth check. Rate limits are best set on your hosting platform.

  1. Get a key at fal.ai/dashboard/keys.
  2. Set FAL_KEY on your server and add the createChaiHandler route above.
  3. Pass createFalEngine() to useComposer as its engine:
import { createFalEngine, useComposer } from "@chai-ui/react";
const engine = createFalEngine({
outputKind: "image", // "image" | "video" | "audio": what this engine produces
modelByKind: { video: "minimax/h3-max/text-to-video" }, // used when no model was picked
});
const { run, submit } = useComposer({ engine });

For several media kinds at once, give useComposer one engine per kind with engineByKind. Fal runs jobs on a queue: createFalEngine() submits the job and checks on it every pollIntervalMs (default 1.5s) until it's done, then returns the media URL. It gives up after timeoutMs (default 2 minutes). It reports no token usage, because Fal bills per generation.

Setup — Vercel AI Gateway

  1. Create an AI Gateway key in your Vercel dashboard. On a deployed Vercel app, the VERCEL_OIDC_TOKEN Vercel sets is used instead.
  2. Set AI_GATEWAY_API_KEY on your server. The createChaiHandler route serves it too.
  3. Pass createVercelEngine() with the gateway model id you choose:
import { createVercelEngine, useComposer } from "@chai-ui/react";
const engine = createVercelEngine({
outputKind: "image", // "text" | "image"
model: "openai/gpt-image-2", // no default: the gateway's catalog changes often
});
const { run, submit } = useComposer({ engine });

Text runs stream and report token usage, like OpenRouter's. Image runs return the picture from the gateway's images endpoint. Attachments aren't sent to an image model, so pass a prompt only.

Setup — OpenRouter (reasoning, and a fully-supported alternative)

  1. Get a key at openrouter.ai/keys. One account and one key is the entire setup.
  2. Set OPENROUTER_API_KEY on your server. The same createChaiHandler route serves both providers.
  3. That's it for reasoning: ChaiProvider uses OpenRouter by default. To change its options, pass your own engine:
import { ChaiProvider, createOpenRouterEngine } from "@chai-ui/react";
const reasoningEngine = createOpenRouterEngine({
maxTokens: 1200, // optional; default 800
});
<ChaiProvider reasoningEngine={reasoningEngine}>
<App />
</ChaiProvider>

createOpenRouterEngine() always sends a max_tokens cap, because some providers reject an uncapped request once it exceeds your account's credits. When a request fails, it surfaces the provider's own error message (e.g. "Provided image is not valid") instead of OpenRouter's generic "Provider returned error".

Suggested models

You don't have to write model lists. With no models, the Composer offers Chai's suggested Fal models for the use case, and the Media analyzer offers suggested OpenRouter models for each media kind. The Composer never picks a model for the person: submit waits until they pick one or turn on auto-select. With no Model menu (no onModelChange), the list's first model runs. Each model carries its verified aspect ratios and the way Fal expects its inputs. Pass your own models to replace a list.

The lists change only with package releases, and providers change faster. While you set up your engine, check them against Fal and OpenRouter as they are today. It needs no key, runs no model and costs nothing:

import { checkSuggestedModels } from "@chai-ui/react";
const { ok, problems } = await checkSuggestedModels();
// e.g. { provider: "fal", modelId: "…", problem: "No longer takes these aspect_ratio values: 21:9." }

Testing without spending money

Real model calls cost money. Leave out engine and useComposer uses mockEngine: placeholder results, no network, no key. To preview failures, use createMockEngine({ failureRate: 1 }). To test against a real engine's request and response handling without calling it, pass a fake fetchImpl:

TypeScript
const engine = createFalEngine({
fetchImpl: fakeFetch, // your function returning canned Fal responses
});

Quality checks: evaluations

Each result can carry pass or fail tags, shown on the Result card's details side. What counts as good depends on your app, so the checks are yours. They come from two places, and you can use either or both.

From your backend

Run your checks in your server route and add a top-level evaluations field to the JSON it returns. The Fal and OpenRouter engines read it, and useComposer puts it on the result. createChaiHandler passes provider responses through unchanged, so this is a route of your own.

TypeScript
// In your route, after the provider responds:
const json = await providerResponse.json();
return Response.json({ ...json, evaluations: [{ id: "safe", label: "Safe", passed: true }] });
// Streamed text: send it as its own chunk before [DONE].
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ evaluations })}\n\n`));

From the opt-in judge

Name a model, and after each text or image result arrives it checks the result against your criteria, one tag per check, after any from your backend. It runs on the reasoning engine (a ChaiProvider, or reasoningEngine). It's off by default and has no default model. Video results aren't judged. Without judgeChecks it runs one general check, "Matches prompt". A check with a kind only applies to text or image results.

const { submit } = useComposer({
engine,
judgeModel: "google/gemini-3.5-flash", // any vision model for image results
judgeChecks: [
{ id: "on-brand", label: "On brand", criterion: "Photographic and polished, never cartoonish.", kind: "image" },
...DEFAULT_EVALUATION_CHECKS, // "Matches prompt"
],
});

Your criteria, in your words. Some examples by kind of app:

  • Marketing images: photographic and polished, never cartoonish; no garbled text in the image.
  • Product photos: the product looks unchanged; a clean, plain background.
  • Support answers: answers the question asked; invents no policy, price or date.

The judge's prompt

Chai writes the framing; your checks fill it in. This is the exact prompt the judge model receives for the first example, built by the package's own buildJudgePrompt. Pass judgePrompt to replace it, calling buildJudgePrompt to add to it rather than rewrite it. A reply without readable verdicts adds no tags and logs one console warning.

Judge prompt
You judge a result an AI app generated, against the app's own criteria. Be strict: pass a check only if the result clearly meets it.
The person's prompt:
"""
a portrait photo of a store manager in a green cap
"""
The result is the attached image.
Criteria, by id:
- on-brand: Photographic and polished, never cartoonish.
- matches-prompt: The result does what the prompt asked for, with nothing important missing or wrong.
Reply with only a JSON object mapping each id to true (passes) or false (fails), e.g. {"first-id": true, "second-id": false}.