Integration

Generative UI with OpenUI and karaUI

OpenUI lets a model reply with interface instead of text. It writes a compact markup called OpenUI Lang, and a React renderer swaps each node for a component from a list you control. This guide fills that list with karaUI pieces, so a chat answer can contain a working button, a status badge or a counting metric, and nothing you did not approve.

Install

Add the OpenUI packages, a model client, and whichever karaUI components the model should be able to use. The shadcn CLI copies their source into your project (the AI Agents page lists other ways to install).

Shellterminal
# Renderer, prompt builder and the Zod schemas
npm install @openuidev/react-lang @openuidev/lang-core zod

# A model client; OpenAI is only the example here
npm install openai

# The karaUI pieces the model may use (the CLI resolves @karaui
# through shadcn's registry directory, no config file edits)
npx shadcn@latest add @karaui/button @karaui/animated-badge @karaui/animated-number

Starting with an empty repo? npx @openuidev/cli@latest create gives you a streaming chat app to begin with; replace its built-in library with the one built on this page.

Register components

defineComponent ties one OpenUI Lang node name to one React component. Its Zod props schema checks each node while it streams in; the description text ends up in the system prompt and tells the model when to use the component; and useTriggerAction is how a rendered control reports a click. For containers, type the children as a union of the other components' .ref schemas; the renderer then enforces what can go inside, and the prompt spells it out for the model.

TSXlib/karaui-library.tsx
import { defineComponent, useTriggerAction } from "@openuidev/react-lang";
import { z } from "zod/v4";
import { Button } from "@/components/motion/button";
import { AnimatedBadge } from "@/components/motion/animated-badge";
import { AnimatedNumber } from "@/components/motion/animated-number";

// The model chooses the label, a variant and whether to ripple on press.
// Clicking calls `useTriggerAction` with `action`, which goes back to the
// model as the next user turn, so the button actually does something.
const KaraButton = defineComponent({
  name: "Button",
  description:
    "Primary action. When clicked, `action` is sent to the model as a new message.",
  props: z.object({
    label: z.string(),
    action: z.string(),
    variant: z.enum(["primary", "secondary", "ghost", "outline"]).default("primary"),
    ripple: z.boolean().default(false),
  }),
  component: ({ props }) => {
    const triggerAction = useTriggerAction();
    return (
      <Button
        variant={props.variant}
        ripple={props.ripple}
        onClick={() => triggerAction(props.action)}
      >
        {props.label}
      </Button>
    );
  },
});

// AnimatedBadge: a colored status chip; pulse marks something still running.
const KaraBadge = defineComponent({
  name: "Badge",
  description:
    "Short status label. Choose a status; turn on pulse while work is ongoing.",
  props: z.object({
    label: z.string(),
    status: z
      .enum(["neutral", "info", "success", "warning", "danger", "loading"])
      .default("neutral"),
    pulse: z.boolean().default(false),
  }),
  component: ({ props }) => (
    <AnimatedBadge status={props.status} pulse={props.pulse}>
      {props.label}
    </AnimatedBadge>
  ),
});

// AnimatedNumber inside a small card, for one headline figure.
const KaraStat = defineComponent({
  name: "Stat",
  description: "One labelled number that counts up when it appears.",
  props: z.object({ label: z.string(), value: z.number() }),
  component: ({ props }) => (
    <div className="rounded-xl border border-border bg-card p-4">
      <p className="text-sm text-muted-foreground">{props.label}</p>
      <AnimatedNumber value={props.value} className="text-2xl font-semibold" />
    </div>
  ),
});

// Root container. Its children are typed as a union of the `.ref` of each
// component above (so it has to come after them). That lets the runtime reject
// anything else and tells the model what it may put inside; `z.any()` would
// give up both.
const StackChild = z.union([KaraButton.ref, KaraBadge.ref, KaraStat.ref]);

const Stack = defineComponent({
  name: "Stack",
  description: "Column layout; children render in order with a gap.",
  props: z.object({ children: z.array(StackChild) }),
  component: ({ props, renderNode }) => (
    <div className="flex flex-col gap-3">{renderNode(props.children)}</div>
  ),
});

Assemble the library

createLibrary bundles the definitions together, sets which one is the root and lets you split the prompt into componentGroups with short usage notes the model reads alongside each group.

TSXlib/karaui-library.tsx
import { createLibrary } from "@openuidev/react-lang";

export const karauiLibrary = createLibrary({
  root: "Stack",
  components: [Stack, KaraButton, KaraBadge, KaraStat],
  componentGroups: [
    {
      name: "Layout",
      components: ["Stack"],
      notes: ["Wrap the whole answer in one Stack."],
    },
    {
      name: "karaUI motion",
      components: ["Button", "Badge", "Stat"],
      notes: [
        "Turn on Badge pulse only for work that is still running.",
        "Offer at most one Button per answer.",
      ],
    },
  ],
});

Generate the prompt

The browser only displays OpenUI Lang; the model still needs to learn how to write it. Build a spec file with npx @openuidev/cli@latest generate --spec ./lib/karaui-library.tsx --out ./lib/generated/karaui-library.spec.json and pass it to generateSystemPrompt in an API route. The route below sends that prompt with the chat history and pipes the text deltas straight to the client.

TSXapp/api/generate/route.ts
import OpenAI from "openai";
import { generateSystemPrompt } from "@openuidev/lang-core";
import karauiLibrarySpec from "@/lib/generated/karaui-library.spec.json";

const openai = new OpenAI();

// The spec JSON (built by the CLI) lists what the model is allowed to output.
export async function POST(req: Request) {
  const { messages } = await req.json();

  const stream = await openai.responses.create({
    model: "gpt-5.6-sol",
    stream: true,
    store: false,
    // OpenUI Lang syntax plus each component's props and description, which
    // keeps the model inside your library.
    instructions: generateSystemPrompt({ library: karauiLibrarySpec }),
    input: messages,
  });

  const response = new ReadableStream<Uint8Array>({
    async start(controller) {
      const encoder = new TextEncoder();

      try {
        for await (const event of stream) {
          if (event.type === "response.output_text.delta") {
            controller.enqueue(encoder.encode(event.delta));
          }
        }
        controller.close();
      } catch (error) {
        controller.error(error);
      }
    },
  });

  return new Response(response, {
    headers: { "Content-Type": "text/plain; charset=utf-8" },
  });
}

Re-run the generate command whenever you edit the library. Because the route only imports the JSON file from @/lib/generated, your client components stay out of the server bundle.

Render the stream

Pass the library to <Renderer> in a client component. It reads the partial OpenUI Lang as it arrives and mounts the matching karaUI components one by one. Hook up onAction so a click on a generated button becomes the next message to the model.

TSXgenerative-response.tsx
"use client";

import { Renderer } from "@openuidev/react-lang";
import { karauiLibrary } from "@/lib/karaui-library";

// `response` holds the OpenUI Lang text received so far from your route.
export function GenerativeResponse({
  response,
  isStreaming,
  onSend,
}: {
  response: string | null;
  isStreaming: boolean;
  onSend: (message: string) => void;
}) {
  return (
    <Renderer
      response={response}
      library={karauiLibrary}
      isStreaming={isStreaming}
      // Fired by a generated Button; forward its message as the next turn.
      onAction={(event) => onSend(event.humanFriendlyMessage)}
    />
  );
}

Why this works well with karaUI

The components are plain source files in your repo, not a package with its own runtime, so the definitions above are all the glue you need. Their colors come from shadcn variables, which means whatever the model generates already matches your app's theme and the design style you picked.

Resources

  • OpenUI

    Project home, with the OpenUI Lang spec.

    Open
  • Defining components

    API docs for defineComponent and createLibrary.

    Open
  • shadcn chat example

    OpenUI's own example of wrapping shadcn components; this guide is modelled on it.

    Open

Any generative UI tool that installs from a shadcn registry can use karaUI in the same way.