Universal React generative UI system for AI agents. Schema-validated components that work in CSR, SSR, RSC, Electron, and React Native.
npm install @swarmify/harness-uiUniversal React generative UI system for AI agents.
``bash`
bun add harness-ui
Peer dependencies: react >= 18.0.0
Components use Tailwind CSS classes. Add harness-ui to your content paths:
`js`
// tailwind.config.js
module.exports = {
content: [
'./src/*/.{js,ts,jsx,tsx}',
'./node_modules/harness-ui/src/*/.{js,ts,jsx,tsx}',
],
}
- Universal React: Works in CSR, SSR, RSC, Electron, React Native
- Schema-first: Zod schemas validate LLM output before render
- Registry pattern: LLM outputs component names, client renders from registry
- MCP discovery: Components auto-exposed to LLM as tools
- Bandwidth efficient: JSON payloads, not streamed components
| Feature | harness-ui | Vercel streamUI | AI Elements |
|---------|-----------|-----------------|-------------|
| Generative UI | ✓ Built-in | ✓ Experimental | ✗ Manual |
| CSR support | ✓ | ✗ (RSC only) | ✓ |
| SSR support | ✓ | ✓ | ✓ |
| Cross-platform | ✓ | ✗ (Next.js only) | ✗ (Next.js) |
| Schema validation | ✓ Zod | ✗ | ✗ |
| MCP discovery | ✓ | ✗ | ✗ |
| Production ready | ✓ | ✗ (experimental) | ✓ |
harness-ui uses client-side rendering with a registry pattern:
``
┌─────────────┐
│ LLM │ Outputs: { component: "email_card", props: {...} }
└──────┬──────┘
│ JSON (~50 bytes)
▼
┌─────────────┐
│ Client │ 1. Registry lookup
│ (Browser/ │ 2. Zod validation
│ Electron) │ 3. Component render
└──────┬──────┘
│
▼
┌─────────────┐
│
└─────────────┘
vs Vercel streamUI (server-side):
``
┌─────────────┐
│ LLM │ Tool call: get_weather
└──────┬──────┘
│
▼
┌─────────────┐
│ Server │ render: function*() { yield
│ (Next.js) │ Executes during tool call
└──────┬──────┘
│ Streamed RSC (~500+ bytes)
▼
┌─────────────┐
│ Client │ Receives serialized component
└─────────────┘
Why client-side?
- Works anywhere React works (not locked to Next.js RSC)
- Works offline (components are local code)
- Debuggable (JSON is inspectable)
- Bandwidth efficient (especially for data-heavy UIs like spreadsheets)
Components are registered client-side:
`tsx
import { componentRegistry } from 'harness-ui';
const Component = componentRegistry['email_card'];
// Returns: EmailCard component
`
The LLM outputs component names as strings:
`json`
{
"component": "email_card",
"props": {
"messageId": "msg_123",
"from": { "name": "Alice", "email": "alice@example.com" },
"subject": "Project update",
"snippet": "Latest progress report..."
}
}
Your client validates and renders:
`tsx
import { componentRegistry, emailCardSchema } from 'harness-ui';
const result = emailCardSchema.safeParse(llmOutput.props);
if (result.success) {
const Component = componentRegistry[llmOutput.component];
return
}
`
This works in CSR, SSR, and RSC - the registry is just a static import.
Each component exports two schemas:
`typescript`
// email-card.tsx
export const emailCardSchema = z.object({
messageId: z.string().describe('Unique message identifier'),
from: z.object({
name: z.string(),
email: z.string(),
}).describe('Sender information'),
subject: z.string().describe('Email subject line'),
snippet: z.string().optional().describe('Preview text'),
});
`typescript`
// email-card.tsx
export const emailCardActions = {
reply: {
description: 'User wants to reply to this email',
params: z.object({
body: z.string().describe('Reply content'),
}),
},
archive: {
description: 'User archived the email',
params: z.object({}),
},
delete: {
description: 'User deleted the email',
params: z.object({}),
},
};
Components call onAgentAction(actionName, params) when users interact:
`tsx`
onAgentAction={(action, params) => {
// action = 'reply' | 'archive' | 'delete'
// params = { body: '...' } for reply, {} for others
sendToAgent({ action, params });
}}
/>
harness-ui includes an MCP server that exposes components as tools. LLMs can discover available components and their schemas automatically.
`typescript
import { createHarnessUIServer } from 'harness-ui/mcp';
const server = createHarnessUIServer();
// Exposes: render_email_card, render_ask_permission, etc.
`
The server auto-generates tool definitions from component schemas:
`json`
{
"tools": [
{
"name": "render_email_card",
"description": "Display an email with sender, subject, and actions. Actions: reply, archive, delete",
"inputSchema": {
"type": "object",
"properties": {
"messageId": { "type": "string", "description": "Unique message identifier" },
"from": { "type": "object", "properties": { "name": { "type": "string" }, "email": { "type": "string" } } },
"subject": { "type": "string", "description": "Email subject line" }
},
"required": ["messageId", "from", "subject"]
}
}
]
}
When validation fails, the server returns actionable errors:
`json`
{
"error": "validation_failed",
"issues": [
{ "path": ["from", "email"], "message": "Required" }
],
"hint": "The 'from' field requires both 'name' and 'email' properties"
}
harness-ui components follow a premium, macOS-native aesthetic. Every component should feel like it belongs in a high-end desktop application, not a web dashboard.
- All generative components accept an optional mode prop ('default' | 'compact') via GenerativeComponentProps.default
- (omit or set): spacious layout with full details.compact
- : tighter padding/typography; secondary details may be hidden (e.g., descriptions, attendee lists, extra buttons) while core info stays visible.compact
- Use when space is constrained: week-row calendars, sidebars, narrow columns, multi-card grids.mode
- Containers can propagate (e.g., event_list sets mode="compact" for all items) but child items can override.mode
- If is absent or unknown, components fall back to default for backward compatibility.
Functionality doesn't create awe - craft does.
The Mac feeling comes from animations that feel alive, typography that breathes, moments of unexpected delight, and everything feeling intentional.
| Moment | Windows Feel | Mac Feel |
|--------|--------------|----------|
| Agent starts | "Processing..." spinner | Subtle pulse, smooth fade-in |
| Email list | Plain text dump | Rich cards with avatars |
| Empty state | "No results" | Illustration + "You're all caught up" |
| Error | Red text, stack trace | Friendly message + retry |
| Completing task | Nothing | Subtle celebration |
Every UI decision should ask: "Does this feel like Mac or Windows?"
| Principle | Description |
|-----------|-------------|
| Monochrome palette | Use zinc grays and white-alpha overlays. Avoid saturated colors. |
| Typography-driven state | Indicate state through font weight and opacity, not colored indicators. |
| Subtle interactions | Hover states should be gentle, not dramatic. |
| Generous spacing | Components should breathe. Never feel cramped. |
| No visual noise | Every element must earn its place. Remove decorative elements. |
| No toasts or colored indicators | No green checkmarks, no red crosses. Use text changes, subtle animations. |
| Hide the plumbing | Technical IDs (tool_call_id) never shown to users. |
| Icons must be semantic | Gmail logo for email, not generic Mail icon. Skip decorative icons. |
Backgrounds (use white-alpha for consistency across themes):
``
Card default: bg-white/[0.03]
Card elevated: bg-white/[0.05]
Card hover: bg-white/[0.08]
Button hover: bg-white/[0.05]
Button active: bg-white/[0.10]
Borders:
``
Default: border-white/[0.08]
Hover: border-white/[0.12]
Dividers: border-white/[0.08]
Text (zinc scale for predictable contrast):
``
Primary: text-zinc-100 (headings, important)
Secondary: text-zinc-300 (body text)
Tertiary: text-zinc-400 (descriptions, read state)
Muted: text-zinc-500 (timestamps, metadata)
Disabled: text-zinc-600 (inactive elements)
Do NOT use:
- Colored dots (blue, green, red)
- Colored left/top borders as accents
- Saturated background colors for categories
- Colored badges for priority/status
Instead use:
- Font weight: font-semibold for unread/active, normal for read/inactivetext-zinc-100
- Text opacity: for active, text-zinc-400 for inactivebg-white/[0.05]
- Background opacity: for selected, bg-white/[0.03] for normal
IMPORTANT: Components must NOT include borders, backgrounds, or rounded corners.
harness-ui components are rendered inside containers that control visual styling. Different hosts (desktop apps, floating windows, embedded views) apply their own border/background/rounding preferences. If components include their own card styling, it creates a double-border effect.
Wrong - component has its own card styling: {content}
`tsx`
// DON'T DO THIS
{title}
Correct - component only has internal padding: {content}
`tsx`
// DO THIS
{title}
The container/wrapper (controlled by the host app) applies the visual styling:
`tsx`
// Host app wraps component with desired styling
Why this matters:
- Not every consumer wants rounded borders (some prefer sharp/square UI)
- Different contexts need different rounding (cards vs floating windows vs inline)
- Prevents double-border visual artifacts
- Gives host apps full control over visual consistency
What components CAN include:
- Internal padding (p-4, px-3 py-2, etc.)space-y-3
- Internal spacing between elements (, gap-2)border-t border-white/[0.08]
- Text colors and typography
- Internal dividers ( between sections)
What components must NOT include:
- Outer border (border, border-white/[0.08])bg-white/[0.03]
- Outer background (, bg-neutral-900)rounded-xl
- Outer rounding (, rounded-lg)shadow-xl
- Box shadows ()
Primary (rare, for main actions):
`tsx`
className="rounded px-3 py-1.5 text-sm text-zinc-100
bg-white/[0.10] hover:bg-white/[0.15]"
Secondary (most common):
`tsx`
className="rounded px-3 py-1.5 text-sm text-zinc-400
hover:bg-white/[0.05] hover:text-zinc-300"
Icon buttons:
`tsx`
className="rounded p-1.5 text-zinc-500
hover:bg-white/[0.05] hover:text-zinc-300"
When brand icons unavailable, use monochrome initials:
`tsx`
className="flex h-10 w-10 items-center justify-center rounded-full
bg-zinc-700/50 text-sm font-medium text-zinc-300"
Never use colored backgrounds for avatars based on category/type.
``
Title: text-sm font-semibold text-zinc-100
Subtitle: text-sm text-zinc-400
Body: text-sm text-zinc-300
Caption: text-xs text-zinc-500
Muted, not attention-grabbing:
`tsx`
className="rounded bg-zinc-700/50 px-1.5 py-0.5 text-xs text-zinc-400"
When cards expand to show more content, use a subtle divider:
`tsx`
className="mt-4 pt-4 border-t border-white/[0.08]"
Always decode HTML entities before display:
`tsx`
import { decodeHtmlEntities } from '../lib/utils';
// Use on any user-generated or API-sourced text
{decodeHtmlEntities(snippet)}
Animations should be subtle and purposeful:
- Fade-ins: Use for appearing elements, 150-200ms duration
- Staggered cascade: When showing multiple cards, stagger by 50ms each
- Hover transitions: Border/background changes only, 150ms
- No springs or bounces: No shaky, playful effects
- Shimmer: Use on interactive text to signal clickability without underlines
`tsx
// Fade-in on mount
className="animate-in fade-in duration-200"
// Staggered list items
style={{ animationDelay: ${index * 50}ms }}`
Tense reflects state:
- In progress: "Reading emails..." / "Analyzing data..."
- Completed: "Read 5 emails" / "Analyzed 3 reports"
Empty states: Never just "No results". Provide context:
- "No emails yet" with a subtle illustration
- "You're all caught up" for cleared inbox
- "Start a conversation" for new sessions
Error messages: Friendly, not technical:
- Bad: "Error: ECONNREFUSED 127.0.0.1:3000"
- Good: "Couldn't connect. Check your internet and try again."
| Avoid | Why | Use Instead |
|-------|-----|-------------|
| bg-blue-500/20 | Saturated, Gmail-like | bg-zinc-700/50 |border-l-2 border-l-purple-500
| | Slack/Discord pattern | Uniform borders |bg-[rgb(12,12,16)]
| | Hardcoded, doesn't adapt | bg-white/[0.03] |text-yellow-400
| Blue dot for unread | Visual noise | Bold text |
| for starred | Too prominent | text-zinc-300 |p-3
| on cards | Too cramped | p-4 |rounded-lg
| | Not premium enough | rounded-xl |
| "No results" | Feels broken | Contextual empty state |
| "Error: CODE" | Technical, scary | Friendly explanation |
| Spring/bounce animations | Playful, not premium | Subtle fades |
Components that require external services (transcription, icons) must be configured via HostProvider:
`tsx
import { HostProvider, type HostAPI } from 'harness-ui';
const hostAPI: HostAPI = {
services: {
transcription: { url: 'wss://your-api.com/api/v1/transcribe' },
icons: { url: 'https://your-api.com/api/v1/icons/resolve' },
},
auth: {
getToken: async () => ({ ok: true, token: 'your-token' }),
},
};
`
If services are not configured, components will show appropriate error states rather than silently failing.
Components that display external brands (email senders, integrations) can resolve icons via a centralized service. Configure the URL via HostProvider.api.services.icons.url.
Your icon service should accept POST requests:
`
POST {services.icons.url}
{
"queries": [
{ "type": "email", "value": "Google Calendar
{ "type": "domain", "value": "github.com" },
{ "type": "brand", "value": "Notion" }
]
}
Response:
{
"icons": {
"email:Google Calendar
"domain:github.com": "https://cdn.example.com/github.png",
"brand:Notion": "https://cdn.example.com/notion.png"
}
}
`
| Type | Resolution Logic | Example |
|------|------------------|---------|
| email | Extract brand from sender name/domain | "Google Calendar |domain
| | Map domain to brand icon | "github.com" |brand
| | Direct brand name lookup | "Notion" |
If no icon service is configured, components fall back to showing initials.
| iOS-style permission request (allow once/session/always, deny) |
| AskUserQuestion | Collect user input with text fields, selects, checkboxes |
| EditablePreview | Editable content with approve/reject actions |
| InputForm | Multi-field form with validation |
| FileInput | File upload with drag-and-drop |
| PhotoSelector | Image selection grid |
| PromptCard | Multi-step prompts with inputs |
| RichTextEditor | Tiptap-based rich text editing |
| ReviewArtifacts | Review and approve/reject generated content |$3
| Component | Purpose |
|-----------|---------|
| EmailCard | Email with sender avatar, subject, snippet, actions |
| EmailList | Scrollable list of emails |
| EventCard | Calendar event with time, location, attendees |
| EventList | List of calendar events |
| ContactCard | Contact info with avatar, phone, email |
| TransactionCard | Financial transaction with amount, status |
| MetricCard | KPI with value, trend indicator, sparkline |
| TableCard | Data table with sortable columns |
| ChartCard | Charts (bar, line, pie) via lightweight renderer |
| LinkCard | URL preview with favicon, title, description |$3
| Component | Purpose |
|-----------|---------|
| MediaGallery | Image/video grid with lightbox |
| VideoPlayer | Video with playback controls |
| HeadshotGallery | Portrait photo grid for selection |
| PlacesMap | Leaflet map with location markers |$3
| Component | Purpose |
|-----------|---------|
| ProgressCard | Progress bar with percentage |
| TaskProgress | Multi-step task with current step indicator |
| GenerationProgress | AI generation status with stages |
| AgentStatus | Agent execution state display |$3
| Component | Purpose |
|-----------|---------|
| Brief | Summary card with key points |
| LearningCard | Lesson/exercise with progress |
| MealCard | Meal logging with nutrition info |
| MeetingSummary | Meeting notes with action items |
| MeetingNotepad | Live transcription notepad |
| TwitterThread | Twitter thread preview |
| PrioritizedTodoList | Todo list with priority levels |$3
| Component | Purpose |
|-----------|---------|
| FlexStack | Flexible layout container |
| StreamingText | Typewriter text animation |
| StreamingContainer | Container for streaming content |
| FadeIn, Stagger, Pulse, Shimmer | Animation primitives |Streaming Infrastructure
Framework-agnostic streaming text support. Works with any data source (SSE, fetch streams, WebSocket, IPC).
$3
| Export | Purpose |
|--------|---------|
|
StreamingProvider | React context provider - wrap your app |
| useStreamingController() | Push chunks from data sources |
| useStreamingText(streamId) | Subscribe to stream updates |
| ConnectedStreamingText | Auto-subscribing StreamingText component |$3
`tsx
// 1. Wrap app with provider
import { StreamingProvider } from 'harness-ui';
// 2. Push chunks from any source
import { useStreamingController } from 'harness-ui';
const { start, pushChunk, complete } = useStreamingController();
// SSE example (standard for LLM APIs)
const eventSource = new EventSource('/api/chat');
start('response-1');
eventSource.onmessage = (e) => pushChunk('response-1', e.data);
eventSource.onerror = () => complete('response-1');
// 3. Render streaming content
import { ConnectedStreamingText } from 'harness-ui';
`$3
| Method | Purpose |
|--------|---------|
|
start(streamId, initialText?) | Start new stream, clears existing |
| pushChunk(streamId, chunk) | Append text chunk |
| complete(streamId) | Mark stream as complete |
| reset(streamId) | Clear stream entirely |$3
| File | Purpose |
|------|---------|
|
src/lib/streaming.tsx | Provider, hooks, store |
| src/components/connected-streaming-text.tsx | Auto-subscribing component |
| src/components/streaming-text.tsx | Presentational component |Usage in Agents
Agents declare
ui_components in YAML to get render_* tools:`yaml
ui_components:
- email_card
- metric_card
`The agent can then call
render_email_card({ ... })` to display rich UI.