@lantos1618/better-ui 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,565 +6,721 @@
6
6
  [![npm downloads](https://img.shields.io/npm/dm/@lantos1618/better-ui.svg)](https://www.npmjs.com/package/@lantos1618/better-ui)
7
7
  [![CI](https://github.com/lantos1618/better-ui/actions/workflows/test.yml/badge.svg)](https://github.com/lantos1618/better-ui/actions/workflows/test.yml)
8
8
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
9
- [![TypeScript](https://img.shields.io/badge/TypeScript-5.0-blue)](https://www.typescriptlang.org/)
10
9
 
11
10
  **[Guide](./GUIDE.md)** · **[API Reference](./docs/)** · **[Examples](./examples/)**
12
11
 
13
- ## The Problem
12
+ ## The idea
14
13
 
15
- Every AI framework lets you define tools. None of them let the tool own its own UI. You end up with tool definitions in one place, rendering logic scattered somewhere else, and no way to expose those same tools to external AI clients.
14
+ One tool definition = schema + server logic + view. Use the same tool in chat, as a React hook, as an MCP server, as an OpenAPI endpoint, or as an AG-UI server.
16
15
 
17
- ## The Solution
16
+ ```bash
17
+ npm install @lantos1618/better-ui zod ai @ai-sdk/openai
18
+ ```
19
+
20
+ ---
18
21
 
19
- Better UI tools are self-contained units: **schema + server logic + view + streaming**, all in one definition. Use them in chat, call them from React, or expose them as an MCP server — same tool, zero glue code.
22
+ ## 1. Define tools
20
23
 
21
- ```typescript
22
- import { tool } from '@lantos1618/better-ui';
24
+ ```tsx
25
+ // lib/tools.tsx
26
+ import { tool, Tool } from '@lantos1618/better-ui';
23
27
  import { z } from 'zod';
24
28
 
25
- const weather = tool({
29
+ export const weatherTool = tool({
26
30
  name: 'weather',
27
- description: 'Get weather for a city',
31
+ description: 'Get current weather for a city',
28
32
  input: z.object({ city: z.string() }),
29
- output: z.object({ temp: z.number(), condition: z.string() }),
33
+ output: z.object({
34
+ temp: z.number(),
35
+ city: z.string(),
36
+ condition: z.string(),
37
+ }),
30
38
  });
31
39
 
32
- weather.server(async ({ city }) => {
33
- const data = await weatherAPI.get(city);
34
- return { temp: data.temp, condition: data.condition };
40
+ weatherTool.server(async ({ city }, ctx) => {
41
+ // ctx.env, ctx.headers, ctx.user, ctx.session available here (stripped on client)
42
+ const res = await fetch(`https://wttr.in/${encodeURIComponent(city)}?format=j1`);
43
+ const data = await res.json();
44
+ return { temp: Number(data.current_condition[0].temp_C), city, condition: data.current_condition[0].weatherDesc[0].value };
35
45
  });
36
46
 
37
- weather.view((data) => (
38
- <div className="weather-card">
39
- <span>{data.temp}°</span>
40
- <span>{data.condition}</span>
41
- </div>
42
- ));
43
- ```
44
-
45
- That's it. The tool validates input/output with Zod, runs server logic securely, and renders its own results. Drop it into chat and it just works. Expose it over MCP and Claude Desktop can call it.
46
-
47
- ## Install
47
+ weatherTool.view((data, state) => {
48
+ if (state?.loading) {
49
+ return (
50
+ <div className="bg-zinc-800 rounded-xl p-4 text-zinc-400 text-sm animate-pulse">
51
+ Fetching weather...
52
+ </div>
53
+ );
54
+ }
55
+ if (state?.error) {
56
+ return <div className="bg-zinc-800 rounded-xl p-4 text-red-400 text-sm">{state.error.message}</div>;
57
+ }
58
+ if (!data) return null;
59
+ return (
60
+ <div className="bg-zinc-800 border border-zinc-700 rounded-xl p-4">
61
+ <p className="text-xs text-zinc-500 uppercase">{data.city}</p>
62
+ <p className="text-3xl font-light">{data.temp}&deg;</p>
63
+ <p className="text-sm text-zinc-400">{data.condition}</p>
64
+ </div>
65
+ );
66
+ });
48
67
 
49
- ```bash
50
- npm install @lantos1618/better-ui zod
68
+ // Export a registry for routes and components to share
69
+ export const tools = { weather: weatherTool } satisfies Record<string, Tool>;
51
70
  ```
52
71
 
53
- ## What You Get
54
-
55
- | Feature | What |
56
- |---------|------|
57
- | **View integration** | Tools render their own results no other framework does this |
58
- | **MCP server** | Expose any tool registry to Claude Desktop, Cursor, VS Code |
59
- | **AG-UI protocol** | Compatible with CopilotKit, LangChain, Google ADK frontends |
60
- | **Multi-provider** | OpenAI, Anthropic, Google Gemini, OpenRouter |
61
- | **Streaming views** | Progressive partial data rendering |
62
- | **Drop-in chat** | `<Chat />` component with automatic tool view rendering |
63
- | **HITL confirmation** | Tools can require human approval before executing |
64
- | **Auth helpers** | JWT, session cookies, BetterAuth integration |
65
- | **Security** | Context stripping, input validation, output sanitization, rate limiting |
72
+ Key points:
73
+ - `.server(input, ctx)` runs only server-side. `ctx.env`, `ctx.headers`, `ctx.user` are available and auto-stripped on client.
74
+ - `.view(data, state)` `state` has `loading`, `streaming`, `error`, and `onAction` (which takes the tool's **input** schema).
75
+ - Calling `.view()` also creates `tool.View` — a memoized React component you can render standalone anywhere.
76
+ - No `.client()`? Client auto-fetches to `/api/tools/execute`. Override with `clientFetch: { endpoint: '/my/path' }` in the tool config.
66
77
 
67
- ## Quick Start
68
-
69
- ### 1. Define a Tool
70
-
71
- ```typescript
72
- import { tool } from '@lantos1618/better-ui';
73
- import { z } from 'zod';
78
+ ---
74
79
 
75
- export const search = tool({
76
- name: 'search',
77
- description: 'Search the web',
78
- input: z.object({ query: z.string().max(1000) }),
79
- output: z.object({
80
- results: z.array(z.object({
81
- title: z.string(),
82
- url: z.string(),
83
- })),
84
- }),
85
- });
80
+ ## 2. Wire up API routes (Next.js App Router)
86
81
 
87
- search.server(async ({ query }) => {
88
- const results = await searchAPI.search(query);
89
- return { results };
90
- });
82
+ ### Chat route
91
83
 
92
- search.view((data) => (
93
- <ul>
94
- {data.results.map((r, i) => (
95
- <li key={i}><a href={r.url}>{r.title}</a></li>
96
- ))}
97
- </ul>
98
- ));
99
- ```
84
+ ```ts
85
+ // app/api/chat/route.ts
86
+ import { openai } from '@ai-sdk/openai';
87
+ import { streamText, convertToModelMessages } from 'ai';
88
+ import { weatherTool } from '@/lib/tools';
100
89
 
101
- ### 2. Use It in Chat
90
+ export async function POST(req: Request) {
91
+ const { messages } = await req.json();
102
92
 
103
- ```tsx
104
- import { Chat } from '@lantos1618/better-ui/components';
93
+ const result = await streamText({
94
+ model: openai('gpt-4o'),
95
+ messages: convertToModelMessages(messages),
96
+ tools: {
97
+ weather: weatherTool.toAITool(),
98
+ },
99
+ });
105
100
 
106
- function App() {
107
- return (
108
- <Chat
109
- endpoint="/api/chat"
110
- tools={{ weather, search }}
111
- className="h-[600px]"
112
- />
113
- );
101
+ return result.toUIMessageStreamResponse();
114
102
  }
115
103
  ```
116
104
 
117
- Tool results render automatically using the tool's `.view()` component.
105
+ `.toAITool()` returns `{ description, inputSchema, execute }` — the format AI SDK v6 expects. The `execute` callback runs `tool.run(input, { isServer: true })` automatically. If the tool has `confirm: true`, `execute` is omitted so the SDK leaves the call at `state: 'input-available'` for HITL approval.
118
106
 
119
- ### 3. Wire Up the API Route
107
+ ### Passing auth context to AI-called tools
120
108
 
121
- ```typescript
122
- // app/api/chat/route.ts (Next.js)
123
- import { streamText, convertToModelMessages } from 'ai';
124
- import { createProvider } from '@lantos1618/better-ui';
109
+ When the LLM calls a tool via `streamText`, the `execute` from `.toAITool()` runs server-side. To pass auth/request context into it, wrap the call:
125
110
 
126
- const provider = createProvider({ provider: 'openai', model: 'gpt-4o' });
111
+ ```ts
112
+ // app/api/chat/route.ts
113
+ import { betterAuth } from '@lantos1618/better-ui/auth';
114
+ import { auth as authInstance } from '@/lib/auth'; // your BetterAuth instance
115
+
116
+ const auth = betterAuth(authInstance);
127
117
 
128
118
  export async function POST(req: Request) {
119
+ const { user, session } = await auth(req.headers);
129
120
  const { messages } = await req.json();
130
- const result = streamText({
131
- model: provider.model(),
121
+
122
+ const result = await streamText({
123
+ model: openai('gpt-4o'),
132
124
  messages: convertToModelMessages(messages),
133
125
  tools: {
134
- weather: weatherTool.toAITool(),
135
- search: searchTool.toAITool(),
126
+ // Wrap toAITool's execute to inject auth context
127
+ weather: {
128
+ ...weatherTool.toAITool(),
129
+ execute: async (input) => weatherTool.run(input, {
130
+ isServer: true, user, session, headers: req.headers,
131
+ }),
132
+ },
136
133
  },
137
134
  });
135
+
138
136
  return result.toUIMessageStreamResponse();
139
137
  }
140
138
  ```
141
139
 
142
- ### 4. Or Expose as an MCP Server
140
+ Now `ctx.user` and `ctx.session` are available inside `.server(input, ctx)`. Works the same with `jwtAuth()` or `sessionAuth()`.
143
141
 
144
- ```typescript
145
- import { createMCPServer } from '@lantos1618/better-ui/mcp';
142
+ ### Tool execution route
146
143
 
147
- const server = createMCPServer({
148
- name: 'my-tools',
149
- version: '1.0.0',
150
- tools: { weather, search },
151
- });
144
+ Interactive views call tools directly (via `onAction`). This endpoint handles that:
152
145
 
153
- server.start(); // stdio transport — works with Claude Desktop
154
- ```
146
+ ```ts
147
+ // app/api/tools/execute/route.ts
148
+ import { tools } from '@/lib/tools';
149
+ import { betterAuth } from '@lantos1618/better-ui/auth';
150
+ import { auth as authInstance } from '@/lib/auth';
155
151
 
156
- Add to Claude Desktop config (`~/.claude/claude_desktop_config.json`):
152
+ const auth = betterAuth(authInstance);
157
153
 
158
- ```json
159
- {
160
- "mcpServers": {
161
- "my-tools": {
162
- "command": "npx",
163
- "args": ["tsx", "path/to/mcp-server.ts"]
164
- }
165
- }
154
+ export async function POST(req: Request) {
155
+ const { user, session } = await auth(req.headers);
156
+ const { tool: name, input } = await req.json();
157
+
158
+ const t = tools[name];
159
+ if (!t) return Response.json({ error: 'Tool not found' }, { status: 404 });
160
+
161
+ // Pass the same auth context so ctx.user works in onAction calls too
162
+ const result = await t.run(input, { isServer: true, user, session, headers: req.headers });
163
+ return Response.json({ result });
166
164
  }
167
165
  ```
168
166
 
169
- Or use the HTTP handler for web-based MCP clients:
167
+ ### HITL confirmation route (only needed if you use `confirm: true`)
170
168
 
171
- ```typescript
172
- // app/api/mcp/route.ts
173
- export const POST = server.httpHandler();
169
+ ```ts
170
+ // app/api/tools/confirm/route.ts
171
+ import { tools } from '@/lib/tools';
172
+
173
+ export async function POST(req: Request) {
174
+ const { tool: name, input } = await req.json();
175
+ const t = tools[name];
176
+ if (!t) return Response.json({ error: 'Not found' }, { status: 404 });
177
+ const result = await t.run(input, { isServer: true });
178
+ return Response.json({ result });
179
+ }
174
180
  ```
175
181
 
176
182
  ---
177
183
 
178
- ## Tool API
179
-
180
- ### Object Config
181
-
182
- ```typescript
183
- const myTool = tool({
184
- name: 'myTool',
185
- description: 'What this tool does',
186
- input: z.object({ query: z.string() }),
187
- output: z.object({ results: z.array(z.string()) }),
188
- tags: ['search'],
189
- cache: { ttl: 60000 },
190
- confirm: true, // require HITL confirmation
191
- hints: { destructive: true }, // behavioral metadata
192
- autoRespond: true, // auto-send state back to AI after user action
193
- groupKey: (input) => input.query, // collapse related calls in thread
194
- });
195
- ```
184
+ ## 3. Chat UI
196
185
 
197
- ### Fluent Builder
186
+ ### Drop-in (simplest)
198
187
 
199
- ```typescript
200
- const search = tool('search')
201
- .description('Search the database')
202
- .input(z.object({ query: z.string() }))
203
- .output(z.object({ results: z.array(z.string()) }))
204
- .server(async ({ query }) => ({ results: await db.search(query) }))
205
- .view((data) => <ResultsList items={data.results} />)
206
- .build();
188
+ ```tsx
189
+ // app/page.tsx
190
+ 'use client';
191
+ import { Chat } from '@lantos1618/better-ui/components';
192
+ import { tools } from '@/lib/tools';
193
+
194
+ export default function Page() {
195
+ return (
196
+ <Chat
197
+ endpoint="/api/chat"
198
+ tools={tools}
199
+ className="h-screen"
200
+ placeholder="Ask something..."
201
+ suggestions={["What's the weather in Tokyo?"]}
202
+ />
203
+ );
204
+ }
207
205
  ```
208
206
 
209
- ### Handlers
207
+ `Chat` = `ChatProvider` + `Thread` + `Composer` in one component. Tool views render inline automatically.
210
208
 
211
- ```typescript
212
- // Server — runs in API routes, never on client
213
- myTool.server(async (input, ctx) => {
214
- // ctx.env, ctx.headers, ctx.cookies, ctx.user, ctx.session
215
- return await db.query(input.query);
216
- });
209
+ ### Composable (full control)
217
210
 
218
- // Client — runs in browser. Auto-fetches to /api/tools/execute if not defined
219
- myTool.client(async (input, ctx) => {
220
- return ctx.fetch('/api/search', { method: 'POST', body: JSON.stringify(input) });
221
- });
211
+ ```tsx
212
+ 'use client';
213
+ import { ChatProvider, Thread, Composer, ChatPanel } from '@lantos1618/better-ui/components';
214
+ import { tools } from '@/lib/tools';
222
215
 
223
- // Stream progressive partial updates
224
- myTool.stream(async (input, { stream }) => {
225
- stream({ status: 'searching...' });
226
- const results = await search(input.query);
227
- stream({ results, status: 'done' });
228
- return { results, status: 'done', count: results.length };
229
- });
216
+ export default function Page() {
217
+ return (
218
+ <ChatProvider endpoint="/api/chat" tools={tools}>
219
+ <div className="flex h-screen">
220
+ <div className="flex-1 flex flex-col">
221
+ <Thread className="flex-1 overflow-y-auto" />
222
+ <Composer placeholder="Ask something..." />
223
+ </div>
224
+ {/* Side panel showing the latest tool result */}
225
+ <ChatPanel className="w-[500px] border-l border-zinc-800" />
226
+ </div>
227
+ </ChatProvider>
228
+ );
229
+ }
230
+ ```
230
231
 
231
- // View render results (the differentiator)
232
- myTool.view((data, { loading, error, streaming, onAction }) => {
233
- if (loading) return <Spinner />;
234
- if (error) return <ErrorCard message={error.message} />;
235
- if (streaming) return <PartialResults data={data} />;
236
- return <Results items={data.results} />;
237
- });
232
+ Inside `ChatProvider`, use `useChatContext()` from a child component to access:
233
+
234
+ ```tsx
235
+ import { useChatContext } from '@lantos1618/better-ui/components';
236
+
237
+ const {
238
+ messages, // UIMessage[]
239
+ sendMessage, // (text: string) => void
240
+ isLoading, // boolean
241
+ status, // 'ready' | 'streaming' | 'submitted'
242
+ tools, // Record<string, Tool>
243
+ toolStateStore, // shared tool state
244
+ confirmTool, // HITL approve
245
+ rejectTool, // HITL reject
246
+ retryTool, // retry failed tool
247
+ // When persistence is configured:
248
+ threads, // Thread[]
249
+ threadId, // string
250
+ createThread, // (title?) => Promise<Thread>
251
+ switchThread, // (id) => Promise<void>
252
+ deleteThread, // (id) => Promise<void>
253
+ } = useChatContext();
238
254
  ```
239
255
 
240
- ### Execution
256
+ ---
241
257
 
242
- ```typescript
243
- // Server-side
244
- const result = await myTool.run(input, { isServer: true });
258
+ ## 4. Interactive views with onAction
245
259
 
246
- // Client-side (auto-fetches if no .client() defined)
247
- const result = await myTool.run(input, { isServer: false });
260
+ Views can trigger tool re-execution. The result updates in-place and optionally syncs back to the AI:
248
261
 
249
- // Streaming
250
- for await (const { partial, done } of myTool.runStream(input)) {
251
- console.log(partial); // progressive updates
252
- if (done) break;
253
- }
262
+ ```tsx
263
+ const counterTool = tool({
264
+ name: 'counter',
265
+ description: 'Manage a named counter',
266
+ input: z.object({
267
+ name: z.string(),
268
+ action: z.enum(['increment', 'decrement', 'reset', 'get']),
269
+ }),
270
+ output: z.object({ name: z.string(), value: z.number() }),
271
+ autoRespond: true, // auto-send updated state back to AI after user clicks
272
+ });
273
+
274
+ // In-memory for demo only — use a real database in production (resets in serverless)
275
+ const counterStore: Record<string, number> = {};
254
276
 
255
- // AI SDK integration
256
- const aiTool = myTool.toAITool(); // { description, inputSchema, execute }
277
+ counterTool.server(async ({ name, action }) => {
278
+ if (!(name in counterStore)) counterStore[name] = 0;
279
+ if (action === 'increment') counterStore[name]++;
280
+ if (action === 'decrement') counterStore[name]--;
281
+ return { name, value: counterStore[name] };
282
+ });
283
+
284
+ counterTool.view((data, state) => {
285
+ if (!data) return null;
286
+ return (
287
+ <div className="flex items-center gap-4 p-4 bg-zinc-800 rounded-xl">
288
+ <span>{data.name}: {data.value}</span>
289
+ <button onClick={() => state?.onAction?.({ name: data.name, action: 'increment' })}>+</button>
290
+ <button onClick={() => state?.onAction?.({ name: data.name, action: 'decrement' })}>-</button>
291
+ </div>
292
+ );
293
+ });
257
294
  ```
258
295
 
296
+ `onAction` calls `/api/tools/execute` with the new input and updates the view. With `autoRespond: true`, the updated state is also sent to the AI as a hidden message so it stays in sync.
297
+
259
298
  ---
260
299
 
261
- ## React Hooks
300
+ ## 5. HITL (human-in-the-loop)
262
301
 
263
- ```typescript
264
- import { useTool, useTools, useToolStream } from '@lantos1618/better-ui/react';
265
- ```
302
+ ```tsx
303
+ const sendEmailTool = tool({
304
+ name: 'sendEmail',
305
+ description: 'Send an email',
306
+ input: z.object({
307
+ to: z.string().email(),
308
+ subject: z.string(),
309
+ body: z.string(),
310
+ }),
311
+ output: z.object({ sent: z.boolean(), messageId: z.string() }),
312
+ confirm: true, // always show Approve/Reject before executing
313
+ });
266
314
 
267
- ### `useTool`
315
+ // Or conditional:
316
+ const deleteTool = tool({
317
+ name: 'delete',
318
+ input: z.object({ id: z.string(), permanent: z.boolean() }),
319
+ confirm: (input) => input.permanent === true, // only confirm permanent deletes
320
+ // ...
321
+ });
268
322
 
269
- ```typescript
270
- const { data, loading, error, execute, reset, executed } = useTool(myTool, initialInput, {
271
- auto: false,
272
- onSuccess: (data) => {},
273
- onError: (error) => {},
323
+ // Or via hints (destructive auto-implies confirmation):
324
+ const dropTool = tool({
325
+ name: 'dropTable',
326
+ hints: { destructive: true },
327
+ // ...
274
328
  });
275
329
  ```
276
330
 
277
- ### `useToolStream`
331
+ The chat UI automatically shows an Approve/Reject card. Approved tools hit `/api/tools/confirm`.
278
332
 
279
- ```typescript
280
- const { data, finalData, streaming, loading, error, execute, reset } = useToolStream(myTool);
281
- ```
333
+ ---
282
334
 
283
- ### `useTools`
335
+ ## 6. Streaming
284
336
 
285
- ```typescript
286
- const tools = useTools({ weather, search });
337
+ Use `.stream()` instead of (or alongside) `.server()` when you need partial updates before the final result:
338
+
339
+ ```tsx
340
+ const analysisTool = tool({
341
+ name: 'analyze',
342
+ input: z.object({ data: z.string() }),
343
+ output: z.object({ status: z.string(), result: z.string() }),
344
+ });
345
+
346
+ analysisTool.stream(async (input, { stream }) => {
347
+ stream({ status: 'Parsing...' });
348
+ const parsed = JSON.parse(input.data); // your parsing logic
349
+ stream({ status: 'Analyzing...' });
350
+ const result = `Processed ${Object.keys(parsed).length} fields`; // your analysis
351
+ return { status: 'Done', result };
352
+ });
287
353
 
288
- await tools.weather.execute({ city: 'London' });
289
- tools.weather.data; // result
290
- tools.search.loading; // loading state
354
+ analysisTool.view((data, state) => {
355
+ if (state?.streaming) return <p>{data?.status}</p>;
356
+ return <p>{data?.result}</p>;
357
+ });
291
358
  ```
292
359
 
293
- ---
360
+ `state.streaming` is true while partials arrive.
294
361
 
295
- ## Chat Components
362
+ ---
296
363
 
297
- ```typescript
298
- import { Chat, ChatProvider, Thread, Composer, Message, ToolResult } from '@lantos1618/better-ui/components';
299
- ```
364
+ ## 7. React hooks (outside chat)
300
365
 
301
- ### Drop-in
366
+ Use tools directly in any React component:
302
367
 
303
368
  ```tsx
304
- <Chat endpoint="/api/chat" tools={{ weather, search }} className="h-[600px]" />
369
+ import { useTool } from '@lantos1618/better-ui/react';
370
+
371
+ function WeatherWidget() {
372
+ const { data, loading, error, execute } = useTool(weatherTool);
373
+
374
+ return (
375
+ <div>
376
+ <button onClick={() => execute({ city: 'Tokyo' })}>Get Weather</button>
377
+ {loading && <p>Loading...</p>}
378
+ {error && <p>Error: {error.message}</p>}
379
+ {data && <weatherTool.View data={data} />}
380
+ </div>
381
+ );
382
+ }
305
383
  ```
306
384
 
307
- ### Composable
385
+ Calling `.view()` on a tool also creates `tool.View` — a memoized React component you can render standalone, outside of chat.
386
+
387
+ ### useToolStream
308
388
 
309
389
  ```tsx
310
- <ChatProvider endpoint="/api/chat" tools={tools}>
311
- <div className="flex flex-col h-screen">
312
- <Thread className="flex-1 overflow-y-auto" />
313
- <Composer placeholder="Type a message..." />
314
- </div>
315
- </ChatProvider>
316
- ```
390
+ import { useToolStream } from '@lantos1618/better-ui/react';
317
391
 
318
- ### All Components
392
+ const { data, finalData, streaming, execute } = useToolStream(analysisTool);
393
+ ```
319
394
 
320
- | Component | Description |
321
- |-----------|-------------|
322
- | `Chat` | All-in-one (ChatProvider + Thread + Composer) |
323
- | `ChatProvider` | Context provider wrapping AI SDK's `useChat` |
324
- | `Thread` | Message list with auto-scroll |
325
- | `Message` | Single message with tool view rendering |
326
- | `Composer` | Input form with send button |
327
- | `ToolResult` | Renders a tool's `.view()` in chat context |
328
- | `Panel` / `ChatPanel` | Sidebar panel for thread management |
329
- | `Markdown` | Markdown renderer with syntax highlighting |
330
- | `ThemeProvider` | Theme CSS variable provider |
395
+ ### useTools (multiple)
331
396
 
332
- ### View Building Blocks
397
+ ```tsx
398
+ import { useTools } from '@lantos1618/better-ui/react';
333
399
 
334
- Pre-made view components for common patterns:
400
+ function Dashboard() {
401
+ const t = useTools({ weather: weatherTool, search: searchTool });
335
402
 
336
- ```typescript
337
- import {
338
- QuestionView, // Multiple choice / free-text questions
339
- FormView, // Dynamic forms
340
- DataTableView, // Sortable data tables
341
- ProgressView, // Step-by-step progress
342
- MediaDisplayView,// Image/video display
343
- CodeBlockView, // Syntax-highlighted code
344
- FileUploadView, // File upload UI
345
- } from '@lantos1618/better-ui/components';
403
+ return (
404
+ <div>
405
+ <button onClick={() => t.weather.execute({ city: 'London' })}>
406
+ {t.weather.loading ? 'Loading...' : t.weather.data?.temp ?? 'Get Weather'}
407
+ </button>
408
+ <button onClick={() => t.search.execute({ query: 'React' })}>
409
+ {t.search.loading ? 'Searching...' : 'Search'}
410
+ </button>
411
+ </div>
412
+ );
413
+ }
346
414
  ```
347
415
 
348
416
  ---
349
417
 
350
- ## MCP Server
418
+ ## 8. MCP server
351
419
 
352
- Turn any tool registry into an [MCP](https://modelcontextprotocol.io) server. Zero dependencies beyond Better UI itself.
420
+ Expose the same tools to Claude Desktop, Cursor, VS Code:
353
421
 
354
- ```typescript
422
+ ```ts
423
+ // mcp-server.ts
355
424
  import { createMCPServer } from '@lantos1618/better-ui/mcp';
425
+ import { weatherTool, searchTool } from './lib/tools';
356
426
 
357
427
  const server = createMCPServer({
358
- name: 'my-app',
428
+ name: 'my-tools',
359
429
  version: '1.0.0',
360
- tools: { weather, search, calculator },
361
- context: { env: process.env }, // passed to every tool execution
430
+ tools: { weather: weatherTool, search: searchTool },
362
431
  });
432
+
433
+ server.start(); // stdio transport
363
434
  ```
364
435
 
365
- ### Transports
436
+ ```jsonc
437
+ // ~/.claude/claude_desktop_config.json
438
+ {
439
+ "mcpServers": {
440
+ "my-tools": { "command": "npx", "args": ["tsx", "mcp-server.ts"] }
441
+ }
442
+ }
443
+ ```
366
444
 
367
- ```typescript
368
- // stdio — for Claude Desktop, Cursor, VS Code extensions
369
- server.start();
445
+ Or as an HTTP endpoint:
370
446
 
371
- // HTTP — for Next.js, Express, Cloudflare Workers, Deno
372
- const handler = server.httpHandler();
373
- // Use as: export const POST = handler;
447
+ ```ts
448
+ // app/api/mcp/route.ts
449
+ export const POST = server.httpHandler();
450
+ // or for SSE streaming:
451
+ export const POST = server.streamableHttpHandler();
374
452
  ```
375
453
 
376
- ### Programmatic Use
454
+ ---
377
455
 
378
- ```typescript
379
- // List tools with JSON schemas
380
- const tools = server.listTools();
456
+ ## 9. AG-UI server
381
457
 
382
- // Call a tool directly
383
- const result = await server.callTool('weather', { city: 'Tokyo' });
384
- // → { content: [{ type: 'text', text: '{"temp":21,"city":"Tokyo","condition":"sunny"}' }] }
458
+ Expose tools via AG-UI protocol (CopilotKit, LangChain, Google ADK):
385
459
 
386
- // Handle raw JSON-RPC messages
387
- const response = await server.handleMessage({
388
- jsonrpc: '2.0',
389
- id: 1,
390
- method: 'tools/call',
391
- params: { name: 'weather', arguments: { city: 'Tokyo' } },
392
- });
460
+ ```ts
461
+ // app/api/agui/route.ts
462
+ import { createAGUIServer } from '@lantos1618/better-ui/agui';
463
+ import { tools } from '@/lib/tools';
464
+
465
+ export const POST = createAGUIServer({
466
+ name: 'my-tools',
467
+ tools,
468
+ }).handler();
393
469
  ```
394
470
 
395
- ### Schema Conversion
471
+ ---
472
+
473
+ ## 10. Built-in view components
396
474
 
397
- The built-in `zodToJsonSchema` converter handles common Zod types without extra dependencies:
475
+ Pre-built views for common patterns. Use in your tool's `.view()`:
398
476
 
399
- ```typescript
400
- import { zodToJsonSchema } from '@lantos1618/better-ui/mcp';
477
+ ```tsx
478
+ import {
479
+ QuestionView, // multiple choice / free-text
480
+ FormView, // dynamic forms from field definitions
481
+ DataTableView, // sortable paginated table
482
+ ProgressView, // step-by-step progress tracker
483
+ CodeBlockView, // syntax highlighted code with copy button
484
+ MediaDisplayView, // image/video grid or carousel
485
+ FileUploadView, // drag-and-drop file upload
486
+ } from '@lantos1618/better-ui/components';
487
+
488
+ // Example: question tool
489
+ questionTool.view((data, state) => (
490
+ <QuestionView
491
+ question={data.question}
492
+ options={data.options} // { label: string, value: string }[]
493
+ allowFreeText={true}
494
+ onSubmit={(answer) => state?.onAction?.({ ...data, answer })}
495
+ />
496
+ ));
497
+
498
+ // Example: form tool
499
+ formTool.view((data, state) => (
500
+ <FormView
501
+ title={data.title}
502
+ fields={data.fields} // { name, label, type, required, options? }[]
503
+ onSubmit={(values) => state?.onAction?.({ ...data, values })}
504
+ />
505
+ ));
401
506
 
402
- zodToJsonSchema(z.object({
403
- name: z.string().min(1).max(100),
404
- age: z.number().int().min(0),
405
- role: z.enum(['admin', 'user']),
406
- }));
407
- // → { type: 'object', properties: { name: { type: 'string', ... }, ... }, required: [...] }
507
+ // Example: data table
508
+ tableTool.view((data) => (
509
+ <DataTableView
510
+ columns={data.columns} // { key, label, sortable? }[]
511
+ rows={data.rows} // Record<string, unknown>[]
512
+ pageSize={10}
513
+ />
514
+ ));
408
515
  ```
409
516
 
410
517
  ---
411
518
 
412
- ## AG-UI Protocol
519
+ ## 11. Tool side effects
413
520
 
414
- Expose your tools via the [AG-UI (Agent-User Interaction Protocol)](https://docs.ag-ui.com) compatible with CopilotKit, LangChain, Google ADK, and any AG-UI frontend.
521
+ React to tool results outside the chat (e.g. open URLs, change themes):
415
522
 
416
- ```typescript
417
- import { createAGUIServer } from '@lantos1618/better-ui/agui';
523
+ ```tsx
524
+ import { useChatContext, useToolEffect } from '@lantos1618/better-ui/components';
418
525
 
419
- const server = createAGUIServer({
420
- name: 'my-tools',
421
- tools: { weather, search },
422
- });
526
+ function SideEffects() {
527
+ const { toolStateStore } = useChatContext();
423
528
 
424
- // Next.js route handler — returns SSE event stream
425
- export const POST = server.handler();
426
- ```
529
+ useToolEffect(toolStateStore, 'navigate', (entry) => {
530
+ const data = entry.output as { url: string };
531
+ if (data?.url) window.open(data.url, '_blank');
532
+ });
427
533
 
428
- The handler emits standard AG-UI events (`RUN_STARTED`, `TOOL_CALL_START`, `TOOL_CALL_ARGS`, `TOOL_CALL_RESULT`, `TOOL_CALL_END`, `RUN_FINISHED`) over Server-Sent Events.
534
+ return null;
535
+ }
536
+ ```
429
537
 
430
538
  ---
431
539
 
432
- ## Providers
540
+ ## 12. Persistence
433
541
 
434
- ```typescript
435
- import { createProvider } from '@lantos1618/better-ui';
542
+ ### In-memory (dev)
436
543
 
437
- createProvider({ provider: 'openai', model: 'gpt-4o' });
438
- createProvider({ provider: 'anthropic', model: 'claude-4-sonnet' });
439
- createProvider({ provider: 'google', model: 'gemini-2.5-pro' });
440
- createProvider({ provider: 'openrouter', model: 'anthropic/claude-4-sonnet', apiKey: '...' });
544
+ ```tsx
545
+ import { createMemoryAdapter } from '@lantos1618/better-ui/persistence';
546
+
547
+ <ChatProvider
548
+ endpoint="/api/chat"
549
+ tools={tools}
550
+ persistence={createMemoryAdapter()}
551
+ >
441
552
  ```
442
553
 
443
- | Provider | Package | Example Models |
444
- |----------|---------|----------------|
445
- | OpenAI | `@ai-sdk/openai` (included) | `gpt-4o`, `gpt-5.2` |
446
- | Anthropic | `@ai-sdk/anthropic` (optional) | `claude-4-sonnet`, `claude-4-opus` |
447
- | Google | `@ai-sdk/google` (optional) | `gemini-2.5-pro` |
448
- | OpenRouter | `@ai-sdk/openai` (included) | any model via `provider/model` |
554
+ ### Database (production)
555
+
556
+ Implement `PersistenceAdapter`:
557
+
558
+ ```ts
559
+ import type { PersistenceAdapter, Thread } from '@lantos1618/better-ui/persistence';
560
+ import type { UIMessage } from 'ai';
561
+
562
+ const persistence: PersistenceAdapter = {
563
+ listThreads(): Promise<Thread[]> { /* ... */ },
564
+ getThread(id: string): Promise<Thread | null> { /* ... */ },
565
+ createThread(title?: string): Promise<Thread> { /* ... */ },
566
+ deleteThread(id: string): Promise<void> { /* ... */ },
567
+ getMessages(threadId: string): Promise<UIMessage[]> { /* ... */ },
568
+ saveMessages(threadId: string, msgs: UIMessage[]): Promise<void> { /* ... */ },
569
+ };
570
+ ```
571
+
572
+ Messages auto-save when AI finishes responding. `useChatContext()` exposes `threads`, `createThread`, `switchThread`, `deleteThread`.
449
573
 
450
574
  ---
451
575
 
452
- ## Auth
576
+ ## 13. Auth
453
577
 
454
- ```typescript
578
+ ```ts
455
579
  import { jwtAuth, sessionAuth, betterAuth } from '@lantos1618/better-ui/auth';
456
580
 
457
- // JWT Bearer tokens
458
- const auth = jwtAuth({ secret: process.env.JWT_SECRET!, issuer: 'my-app' });
581
+ // JWT Bearer tokens (uses jose)
582
+ const auth = jwtAuth({ secret: process.env.JWT_SECRET! });
459
583
 
460
- // Cookie-based sessions
461
- const auth = sessionAuth({ cookieName: 'session', verify: async (token) => db.getSession(token) });
584
+ // Cookie sessions
585
+ const auth = sessionAuth({
586
+ cookieName: 'session',
587
+ verify: async (token) => db.sessions.findUnique({ where: { token } }),
588
+ });
462
589
 
463
- // BetterAuth integration
590
+ // BetterAuth
464
591
  const auth = betterAuth(authInstance);
592
+
593
+ // Usage in a route:
594
+ const user = await auth(req.headers);
595
+ const result = await tool.run(input, { isServer: true, user });
465
596
  ```
466
597
 
467
598
  ---
468
599
 
469
- ## Persistence
600
+ ## 14. Providers
470
601
 
471
- ```typescript
472
- import { createMemoryAdapter } from '@lantos1618/better-ui/persistence';
602
+ ```ts
603
+ import { createProvider } from '@lantos1618/better-ui';
473
604
 
474
- const adapter = createMemoryAdapter(); // in-memory, for dev/testing
605
+ const p = createProvider({ provider: 'openai', model: 'gpt-4o' });
606
+ // or: 'anthropic' + 'claude-sonnet-4-5-20250929'
607
+ // or: 'google' + 'gemini-2.5-pro'
608
+ // or: 'openrouter' + 'anthropic/claude-sonnet-4-5-20250929' (needs apiKey)
475
609
 
476
- await adapter.createThread('New Chat');
477
- await adapter.saveMessages(threadId, messages);
478
- await adapter.getMessages(threadId);
610
+ // Use in streamText:
611
+ streamText({ model: p.model(), tools: { ... } });
479
612
  ```
480
613
 
481
- Implement the `PersistenceAdapter` interface for Drizzle, Prisma, or any database.
614
+ ---
615
+
616
+ ## 15. Fluent builder (alternative syntax)
617
+
618
+ ```tsx
619
+ const search = tool('search')
620
+ .description('Search the database')
621
+ .input(z.object({ query: z.string() }))
622
+ .output(z.object({ results: z.array(z.string()) }))
623
+ .cache({ ttl: 60_000 })
624
+ .hints({ readOnly: true })
625
+ .server(async ({ query }) => ({ results: await db.search(query) }))
626
+ .view((data) => <ul>{data.results.map((r, i) => <li key={i}>{r}</li>)}</ul>)
627
+ .build();
628
+ ```
482
629
 
483
630
  ---
484
631
 
485
- ## HITL (Human-in-the-Loop)
632
+ ## 16. OpenAPI / Swagger
486
633
 
487
- Tools can require confirmation before executing:
634
+ Auto-generate an OpenAPI 3.1 spec and callable REST endpoints from your tools:
488
635
 
489
- ```typescript
490
- const sendEmail = tool({
491
- name: 'sendEmail',
492
- description: 'Send an email',
493
- input: z.object({ to: z.string().email(), subject: z.string(), body: z.string() }),
494
- confirm: true, // always require confirmation
495
- // or: confirm: (input) => input.to.endsWith('@company.com') // conditional
496
- // or: hints: { destructive: true } // auto-implies confirmation
636
+ ```ts
637
+ import { toolRouter } from '@lantos1618/better-ui/openapi';
638
+ import { tools } from './tools';
639
+
640
+ // Next.js catch-all: app/api/tools/[...path]/route.ts
641
+ const router = toolRouter({ tools });
642
+ export const GET = router;
643
+ export const POST = router;
644
+ ```
645
+
646
+ That gives you:
647
+
648
+ | Endpoint | What |
649
+ |---|---|
650
+ | `POST /api/tools/weather` | Execute tool, returns `{ result }` |
651
+ | `GET /api/tools` | OpenAPI 3.1 JSON spec |
652
+ | `GET /api/tools/docs` | Swagger UI |
653
+
654
+ With auth/rate-limiting:
655
+
656
+ ```ts
657
+ const router = toolRouter({
658
+ tools,
659
+ onBeforeExecute: async (toolName, input, req) => {
660
+ const user = await auth(req.headers);
661
+ if (!user) throw new Error('Unauthorized');
662
+ },
497
663
  });
498
664
  ```
499
665
 
500
- When `confirm` is set, `toAITool()` omits the `execute` function, leaving the tool call at `state: 'input-available'` for client-side confirmation before execution.
666
+ Or just generate the spec without the router:
501
667
 
502
- ---
668
+ ```ts
669
+ import { generateOpenAPISpec, openAPIHandler } from '@lantos1618/better-ui/openapi';
670
+
671
+ // Get the spec object
672
+ const spec = generateOpenAPISpec({ title: 'My API', version: '1.0.0', tools });
503
673
 
504
- ## Security
674
+ // Or serve it as a route
675
+ export const GET = openAPIHandler({ title: 'My API', version: '1.0.0', tools });
676
+ ```
505
677
 
506
- Better UI is designed with security boundaries between server and client:
678
+ ---
507
679
 
508
- - **Context stripping** — `env`, `headers`, `cookies`, `user`, `session` are automatically removed when running on the client
509
- - **Input validation** — Zod schemas validate and strip unknown keys before execution
510
- - **Output validation** Output schemas prevent accidental data leakage (extra fields are stripped)
511
- - **Server isolation** — Server handlers never run on the client; auto-fetch kicks in instead
512
- - **Serialization safety** `toJSON()` excludes handlers, schemas, and internal config
513
- - **Rate limiting** Pluggable rate limiter with in-memory and Redis backends
514
- - **Audit logging** Structured JSON logging for every tool execution
515
- - **Prototype pollution protection** Safe object merging in state context handling
516
- - **MCP hardening** `hasOwnProperty` checks prevent prototype chain traversal on tool lookup
680
+ ## Cheat sheet
681
+
682
+ | I want to... | Code |
683
+ |------------------------------|-------------------------------------------------|
684
+ | Define a tool | `tool({ name, input, output })` |
685
+ | Add server logic | `.server(async (input, ctx) => result)` |
686
+ | Add a view | `.view((data, state) => <JSX />)` |
687
+ | Drop into chat | `<Chat endpoint="..." tools={tools} />` |
688
+ | Composable chat | `<ChatProvider>` + `<Thread>` + `<Composer>` |
689
+ | Convert for AI SDK | `tool.toAITool()` |
690
+ | Run directly | `await tool.run(input, { isServer: true })` |
691
+ | Use as React hook | `useTool(tool)` / `useToolStream(tool)` |
692
+ | Render view standalone | `<tool.View data={data} />` |
693
+ | Require approval | `confirm: true` |
694
+ | Stream partial results | `.stream(async (input, { stream }) => ...)` |
695
+ | Sync UI actions back to AI | `autoRespond: true` |
696
+ | React to tool results | `useToolEffect(store, 'toolName', callback)` |
697
+ | Persist conversations | `persistence={adapter}` on `ChatProvider` |
698
+ | Expose via MCP | `createMCPServer({ tools }).start()` |
699
+ | Expose via AG-UI | `createAGUIServer({ tools }).handler()` |
700
+ | Add auth | `jwtAuth()` / `sessionAuth()` / `betterAuth()` |
701
+ | OpenAPI spec | `generateOpenAPISpec({ tools })` |
702
+ | Callable REST + Swagger UI | `toolRouter({ tools })` |
517
703
 
518
704
  ---
519
705
 
520
- ## Project Structure
706
+ ## Project structure
521
707
 
522
708
  ```
523
709
  src/
524
710
  tool.tsx Core tool() API — schema, handlers, view, streaming
525
711
  index.ts Main exports (server-safe, no React)
526
- react/
527
- useTool.ts useTool, useTools hooks
528
- useToolStream.ts useToolStream hook
529
- components/
530
- Chat.tsx All-in-one chat
531
- ChatProvider.tsx Chat context provider
532
- Thread.tsx Message list
533
- Message.tsx Single message
534
- Composer.tsx Input form
535
- ToolResult.tsx Tool view renderer
536
- Panel.tsx Sidebar panel
537
- Markdown.tsx Markdown renderer
538
- Question.tsx Question view block
539
- Form.tsx Form view block
540
- DataTable.tsx Data table view block
541
- Progress.tsx Progress view block
542
- MediaDisplay.tsx Media view block
543
- CodeBlock.tsx Code block view block
544
- FileUpload.tsx File upload view block
545
- Toast.tsx Toast notifications
546
- ThemeProvider.tsx Theme CSS variables
547
- providers/
548
- openai.ts OpenAI adapter
549
- anthropic.ts Anthropic adapter
550
- google.ts Google Gemini adapter
551
- openrouter.ts OpenRouter adapter
552
- auth/
553
- jwt.ts JWT auth helper
554
- session.ts Session cookie auth
555
- better-auth.ts BetterAuth integration
556
- persistence/
557
- types.ts PersistenceAdapter interface
558
- memory.ts In-memory adapter
559
- mcp/
560
- server.ts MCP server (stdio + HTTP + SSE)
561
- schema.ts Zod → JSON Schema converter
562
- agui/
563
- server.ts AG-UI protocol server (SSE)
712
+ react/ useTool, useTools, useToolStream hooks
713
+ components/ Chat, Thread, Composer, ToolResult, Panel, Markdown, Form, etc.
714
+ providers/ OpenAI, Anthropic, Google, OpenRouter adapters
715
+ auth/ JWT, session cookie, BetterAuth helpers
716
+ persistence/ PersistenceAdapter interface + in-memory adapter
717
+ mcp/ MCP server (stdio + HTTP + SSE)
718
+ agui/ AG-UI protocol server (SSE)
719
+ openapi/ OpenAPI spec generator + tool router
564
720
  examples/
565
- nextjs-demo/ Full Next.js demo app
566
- vite-demo/ Vite + Express demo app
567
- mcp-server/ Standalone MCP server example
721
+ nextjs-demo/ Full Next.js chat app
722
+ vite-demo/ Vite + React demo
723
+ mcp-server/ Standalone MCP server
568
724
  ```
569
725
 
570
726
  ## Development
@@ -572,27 +728,10 @@ examples/
572
728
  ```bash
573
729
  npm install
574
730
  npm run build # Build library
575
- npm test # Run 228 tests across 11 suites
731
+ npm test # Run 250 tests across 12 suites
576
732
  npm run type-check # TypeScript check
577
733
  ```
578
734
 
579
- ## Deploy the Demo
580
-
581
- The `examples/nextjs-demo/` is a full-featured chat app ready to deploy:
582
-
583
- ```bash
584
- cd examples/nextjs-demo
585
- npm install
586
- # Set OPENAI_API_KEY in .env.local
587
- npm run dev
588
- ```
589
-
590
- To deploy on Vercel, set the **Root Directory** to `examples/nextjs-demo` in your project settings.
591
-
592
- ## Contributing
593
-
594
- See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines.
595
-
596
735
  ## License
597
736
 
598
737
  MIT