Docs

React UI

TanStack quickstart

Build a TanStack Router React frontend, a separate Hono API app, and a streaming React UI chat.

This quickstart builds the smallest useful React UI setup as two dedicated apps:

  • frontend: a TanStack Router React app that renders @anvia/react-ui.
  • api: a Hono server that owns provider credentials, tools, agents, and streaming routes.

The same shape is used by the runnable repository example at examples/fullstack-agent.

Install

Create the two app folders:

mkdir anvia-react-ui
cd anvia-react-ui
pnpm dlx @tanstack/cli@latest create --router-only
mkdir -p api/src

When the TanStack CLI asks for the app location, create it as frontend.

Install frontend packages:

cd frontend
pnpm add @anvia/react @anvia/react-ui

Create api/package.json:

{
  "type": "module",
  "scripts": {
    "dev": "tsx src/server.ts"
  }
}

Install API packages:

cd ../api
pnpm add @anvia/core @anvia/openai @anvia/server
pnpm add @hono/node-server hono zod dotenv
pnpm add -D tsx typescript @types/node

Set the provider key on the server side:

OPENAI_API_KEY=...

API server

Create api/src/server.ts. The important part is the request body: React sends { messages, stream: true }, so the route reads body.messages.

import { AgentBuilder, createTool, type UIStreamRequest } from "@anvia/core";
import { OpenAIClient } from "@anvia/openai";
import { createEventStream } from "@anvia/server";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { z } from "zod";
import "dotenv/config";

const client = new OpenAIClient({
  apiKey: process.env.OPENAI_API_KEY,
});

const getOrder = createTool({
  name: "get_order",
  description: "Read an order summary from local application state.",
  input: z.object({
    id: z.string().describe("The order id to read."),
  }),
  output: z.object({
    id: z.string(),
    status: z.enum(["processing", "blocked", "shipped"]),
    customer: z.string(),
    notes: z.string(),
  }),
  execute: ({ id }) => ({
    id,
    status: "blocked" as const,
    customer: "Delta Kit Labs",
    notes: "Payment review is complete, but warehouse allocation is still pending.",
  }),
});

const model = client.completionModel("gpt-5");
const agent = new AgentBuilder("support-operations", model)
  .name("Support Operations")
  .instructions("Answer operational questions clearly. Use tools when they help.")
  .tool(getOrder)
  .build();

const app = new Hono();
const frontendOrigin = process.env.FRONTEND_ORIGIN ?? "http://localhost:5173";

app.use("/api/*", cors({ origin: frontendOrigin }));
app.get("/api/health", (c) => c.json({ ok: true }));

app.post("/api/chat", async (c) => {
  const body = (await c.req.json()) as UIStreamRequest;

  return createEventStream(agent.prompt(body.messages).stream(), {
    format: "jsonl",
  });
});

serve({
  fetch: app.fetch,
  hostname: "127.0.0.1",
  port: Number(process.env.API_PORT ?? 8787),
});

Frontend API URL

The frontend calls the API app directly.

Create frontend/.env.local:

VITE_ANVIA_API_URL=http://localhost:8787

Chat UI

Create or replace frontend/src/App.tsx:

import { useChat } from "@anvia/react";
import { ChatProvider, Composer, Message, Thread } from "@anvia/react-ui";
import "@anvia/react-ui/styles.css";

const suggestions = [
  { id: "order", prompt: "Where is order A-100?", label: "Find order A-100" },
  { id: "next", prompt: "What should support do next?", label: "Suggest next step" },
];

const API_BASE_URL = import.meta.env.VITE_ANVIA_API_URL ?? "http://localhost:8787";

export function App() {
  const chat = useChat({
    endpoint: `${API_BASE_URL}/api/chat`,
    format: "jsonl",
    suggestions,
  });

  return (
    <ChatProvider controller={chat}>
      <main className="chat-shell">
        <Thread.Root className="thread">
          <Thread.Viewport className="thread-viewport">
            <Thread.Empty className="empty-state">
              <h1>Ask the support agent</h1>
              <Thread.Suggestions className="suggestions" />
            </Thread.Empty>

            <Thread.Messages className="messages">
              {(message) => (
                <Message.Root className="message">
                  <Message.Content className="message-content">
                    <Message.Parts>
                      {(part) =>
                        part.type === "text" ? (
                          <Message.Part>
                            <Message.Markdown />
                          </Message.Part>
                        ) : part.type === "tool" ? (
                          <Message.Part>
                            <Message.Tool className="tool-card" />
                          </Message.Part>
                        ) : (
                          <Message.Part />
                        )
                      }
                    </Message.Parts>
                  </Message.Content>
                </Message.Root>
              )}
            </Thread.Messages>

            <Thread.Error className="thread-error" />
            <Thread.ScrollToBottom className="scroll-button">Latest</Thread.ScrollToBottom>
          </Thread.Viewport>

          <Composer.Root className="composer">
            <Composer.Input placeholder="Ask about an order..." maxRows={6} />
            <Composer.Stop>Stop</Composer.Stop>
            <Composer.Submit>Send</Composer.Submit>
          </Composer.Root>
        </Thread.Root>
      </main>
    </ChatProvider>
  );
}

Add enough CSS for a usable layout:

.chat-shell {
  min-height: 100vh;
  background: #0f1014;
  color: #f4f4f5;
}

.thread {
  min-height: 100vh;
  display: grid;
  grid-template-rows: minmax(0, 1fr) auto;
}

.thread-viewport {
  min-height: 0;
  overflow-y: auto;
  padding: 24px 16px;
}

.messages,
.empty-state,
.thread-error {
  width: min(760px, 100%);
  margin: 0 auto;
}

.messages {
  display: grid;
  gap: 16px;
}

.message {
  display: grid;
  gap: 8px;
}

.message[data-role="user"] {
  justify-items: end;
}

.message[data-role="user"] .message-content {
  max-width: min(620px, 88%);
  border-radius: 18px;
  background: #27272a;
  padding: 10px 14px;
}

.tool-card {
  display: grid;
  gap: 8px;
  border: 1px solid #3f3f46;
  border-radius: 10px;
  background: #18181b;
  padding: 12px;
}

.composer {
  width: min(760px, calc(100% - 32px));
  margin: 0 auto 16px;
  display: flex;
  align-items: end;
  gap: 8px;
  border: 1px solid #3f3f46;
  border-radius: 18px;
  background: #18181b;
  padding: 8px;
}

.composer [data-anvia-composer-input] {
  min-width: 0;
  flex: 1;
  border: 0;
  outline: 0;
  background: transparent;
  color: inherit;
}

Run it

Run the API app in one terminal:

cd api
OPENAI_API_KEY=... pnpm dev

Run the frontend app in another terminal:

cd frontend
pnpm dev

Ask “Where is order A-100?” and confirm that:

  • the browser posts to http://localhost:8787/api/chat
  • the API server allows the frontend origin with CORS
  • the route reads body.messages
  • the response streams JSONL
  • the UI shows assistant text and tool-call output

For a complete repo-native version with a combined dev script, see examples/fullstack-agent.