wafer-ui
v1.0.2
Published
Agentic UI toolkit for React — single-package install.
Readme
wafer-ui
Agentic UI toolkit for React — single-package install. Add a fully wired AI agent to your React app in minutes, with any LLM backend. Local Ollama today, Claude tomorrow, LangGraph next week — the same few lines of React code work with all of them.
What's inside
| Package (bundled in) | What it does |
| --- | --- |
| @wafer/core | Agent client and state machine. Tracks messages, tool calls, runs, and approvals. |
| @wafer/react | React provider and hooks — useThread, useComposer, useRunState, useApprovals. |
| @wafer/ui | Pre-built components — AgentThread, Composer, RunTimeline, ApprovalPanel, ToolCallCard. |
| @wafer/adapters | Ready-made transports for Ollama and Groq. Add any other backend in ~50 lines. |
The core idea is the AgentTransport interface — a two-method contract that separates your UI from your LLM backend. Swap the transport and the rest of the app is untouched.
Installation
npm install wafer-ui
# or
pnpm add wafer-ui
# or
yarn add wafer-uiPrerequisites
- React 18 or 19 — hooks and
useSyncExternalStoreare required. - Tailwind CSS v4 — required peer dependency.
Wafer ships pre-built JS, so Tailwind won't see the component class names unless you point it at the dist files. Add this to your global CSS:
/* In your global CSS file (e.g. src/styles.css) */
@source "../../node_modules/wafer-ui/dist/**/*.js";Without this, Tailwind's production build purges the component styles and the UI renders unstyled.
Quick start
Both options below use createOllamaTransport to talk to a local Ollama instance. Swap in any other transport and nothing else changes.
Option A — use hooks, wire your own UI
import { createAgentClient, AgentProvider, useThread, useComposer } from "wafer-ui";
import { createOllamaTransport } from "wafer-ui/adapters/ollama";
// Create the client once — outside the component
const client = createAgentClient({
transport: createOllamaTransport({ model: "llama3.2" })
});
function Chat() {
const { messages } = useThread();
const { input, setInput, submit, isRunning } = useComposer();
return (
<div>
{messages.map(msg => (
<p key={msg.id}>
<strong>{msg.role}:</strong> {msg.content}
</p>
))}
<input
value={input}
onChange={e => setInput(e.target.value)}
placeholder="Ask anything…"
/>
<button onClick={() => submit()} disabled={isRunning}>
{isRunning ? "Thinking…" : "Send"}
</button>
</div>
);
}
export default function App() {
return (
<AgentProvider client={client}>
<Chat />
</AgentProvider>
);
}Option B — drop in the pre-built components
import { createAgentClient, AgentProvider, AgentThread, Composer } from "wafer-ui";
import { createOllamaTransport } from "wafer-ui/adapters/ollama";
const client = createAgentClient({
transport: createOllamaTransport({ model: "llama3.2" })
});
export default function App() {
return (
<AgentProvider client={client}>
<AgentThread title="My Agent" />
<Composer placeholder="Ask anything…" />
</AgentProvider>
);
}Local LLM via Ollama (free, no API key)
Install from ollama.com. Ollama runs a local server at
http://localhost:11434.Pull a model:
ollama pull llama3.2 # general purpose — good starting point ollama pull qwen2.5:7b # great tool-calling model ollama pull llama3.1:8b # smarter, still runs on most laptops ollama pull mistral # fast and capableCreate the transport:
import { createOllamaTransport } from "wafer-ui/adapters/ollama"; const transport = createOllamaTransport({ model: "llama3.2", // required baseUrl: "http://localhost:11434", // default — Ollama's port systemPrompt: "You are a helpful assistant.", maxToolRounds: 6, // max agent loop iterations think: "low", // thinking mode: true | "low" | "medium" | "high" requestOptions: { // passed directly to Ollama temperature: 0.7, num_predict: 1024 } });Add tools (optional). Define each tool with a JSON Schema and an
executefunction that runs when the model calls it:import { createOllamaTransport } from "wafer-ui/adapters/ollama"; const transport = createOllamaTransport({ model: "qwen2.5:7b", systemPrompt: "You are a helpful assistant. Always call a tool when relevant.", tools: [ { function: { name: "get_weather", description: "Get the current weather for a city.", parameters: { type: "object", required: ["city"], properties: { city: { type: "string", description: "The city name, e.g. 'London'" } } } }, // execute() receives the parsed arguments and must return a value execute: async (args) => { const response = await fetch(`/api/weather?city=${args.city}`); const data = await response.json(); return { temperature: data.temp, condition: data.sky }; } } ] });
All createOllamaTransport options
| Option | Default | Description |
| --- | --- | --- |
| model | required | Ollama model name, e.g. llama3.2 |
| baseUrl | http://localhost:11434 | Ollama server URL |
| systemPrompt | — | System message prepended to every request |
| tools | [] | Tool definitions with execute() callbacks |
| maxToolRounds | 6 | Max agent-loop iterations before stopping |
| think | — | Enable thinking: true \| "low" \| "medium" \| "high" |
| requestOptions | — | Raw Ollama options (temperature, num_predict, …) |
| forceToolCallRetryCount | 0 | Retries if model skips a required tool call |
Groq (free tier, cloud, no GPU)
Sign up at console.groq.com — no credit card required.
Never put an API key in the browser — create a server-side proxy:
// api/chat.ts (Vercel Edge Function) export const config = { runtime: "edge" }; export default async function handler(req: Request): Promise<Response> { const body = await req.text(); const res = await fetch("https://api.groq.com/openai/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json", // GROQ_API_KEY lives in your host's env vars — never in the browser Authorization: `Bearer ${process.env.GROQ_API_KEY}` }, body }); return new Response(res.body, { status: res.status, headers: { "Content-Type": res.headers.get("Content-Type") ?? "application/json" } }); }Create the transport:
import { createGroqTransport } from "wafer-ui/adapters/groq"; const transport = createGroqTransport({ model: "llama-3.3-70b-versatile", // default — very capable, great tool calling endpoint: "/api/chat", // your proxy URL systemPrompt: "You are a helpful assistant.", maxToolRounds: 6 });
Free models on Groq
| Model | Notes |
| --- | --- |
| llama-3.3-70b-versatile | Very capable, best for tool calling |
| llama-3.1-8b-instant | Ultra-fast for simple tasks |
| gemma2-9b-it | Google's Gemma — fast and capable |
| moonshotai/kimi-k2-instruct | Strong reasoning, complex tasks |
Any other backend (Claude, OpenAI, LangGraph, Mastra, custom)
AgentTransport is a plain two-method interface, so any backend can plug in:
import type { AgentTransport } from "wafer-ui";
const myTransport: AgentTransport = {
async sendUserMessage(input, emit) {
// input.threadId — stable ID for this conversation
// input.runId — unique ID for this specific user message + response
// input.text — the user's raw message text
// input.history — full message history: Array<{ role, content }>
// Call emit() to push events into Wafer's state machine.
// Everything you emit becomes visible via hooks and components.
// ... your LLM call here ...
},
// Optional — only needed if your agent asks for human approval
async submitApproval(input, emit) {
emit({
type: "approval.resolved",
threadId: input.threadId, runId: input.runId,
approvalId: input.approvalId, decision: input.decision,
createdAt: new Date().toISOString()
});
}
};Minimum viable transport — emit three events per response:
// ── Message events ────────────────────────────────────────────────────────
emit({ type: "message.created", threadId, messageId, role: "assistant", runId, content: "", createdAt });
emit({ type: "message.delta", threadId, messageId, runId, delta: "Hello", createdAt });
// ── Tool events ───────────────────────────────────────────────────────────
emit({ type: "tool.called", threadId, runId, toolCallId, toolName: "search", input: { query: "…" }, createdAt });
emit({ type: "tool.completed", threadId, runId, toolCallId, output: { results: [] }, createdAt });
emit({ type: "tool.failed", threadId, runId, toolCallId, error: "Timeout", createdAt });
// ── Approval events ───────────────────────────────────────────────────────
emit({ type: "approval.requested", threadId, runId, approvalId, actionLabel: "Send email", createdAt });
// ── Run lifecycle ─────────────────────────────────────────────────────────
emit({ type: "run.completed", threadId, runId, createdAt });
emit({ type: "run.failed", threadId, runId, error: "Something broke", createdAt });message.created— before you start streamingmessage.delta— once per chunk of textrun.completed— when the response is done
Tool, approval, and run-failure events are optional — add them as your agent grows more sophisticated.
License
MIT
