@swarmify/harness-ui
v0.1.0
Published
Universal React generative UI system for AI agents. Schema-validated components that work in CSR, SSR, RSC, Electron, and React Native.
Maintainers
Readme
harness-ui
Universal React generative UI system for AI agents.
Installation
bun add harness-uiPeer dependencies: react >= 18.0.0
Tailwind CSS (required)
Components use Tailwind CSS classes. Add harness-ui to your content paths:
// tailwind.config.js
module.exports = {
content: [
'./src/**/*.{js,ts,jsx,tsx}',
'./node_modules/harness-ui/src/**/*.{js,ts,jsx,tsx}',
],
}What makes harness-ui different
- 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
Quick comparison
| 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) | ✓ |
Architecture
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
└──────┬──────┘
│
▼
┌─────────────┐
│ <EmailCard │ Rendered component
│ {...} /> │
└─────────────┘vs Vercel streamUI (server-side):
┌─────────────┐
│ LLM │ Tool call: get_weather
└──────┬──────┘
│
▼
┌─────────────┐
│ Server │ render: function*() { yield <Card/> }
│ (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)
Registry Pattern
Components are registered client-side:
import { componentRegistry } from 'harness-ui';
const Component = componentRegistry['email_card'];
// Returns: EmailCard componentThe LLM outputs component names as strings:
{
"component": "email_card",
"props": {
"messageId": "msg_123",
"from": { "name": "Alice", "email": "[email protected]" },
"subject": "Project update",
"snippet": "Latest progress report..."
}
}Your client validates and renders:
import { componentRegistry, emailCardSchema } from 'harness-ui';
const result = emailCardSchema.safeParse(llmOutput.props);
if (result.success) {
const Component = componentRegistry[llmOutput.component];
return <Component {...result.data} onAction={handleAction} />;
}This works in CSR, SSR, and RSC - the registry is just a static import.
Schema System
Each component exports two schemas:
Props Schema
What the LLM sends to render the component:
// 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'),
});Actions Schema
What the component can emit when the user interacts:
// 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:
<EmailCard
{...props}
onAgentAction={(action, params) => {
// action = 'reply' | 'archive' | 'delete'
// params = { body: '...' } for reply, {} for others
sendToAgent({ action, params });
}}
/>MCP Server
harness-ui includes an MCP server that exposes components as tools. LLMs can discover available components and their schemas automatically.
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:
{
"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:
{
"error": "validation_failed",
"issues": [
{ "path": ["from", "email"], "message": "Required" }
],
"hint": "The 'from' field requires both 'name' and 'email' properties"
}Design Language
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.
Display Modes
- All generative components accept an optional
modeprop ('default' | 'compact') viaGenerativeComponentProps. 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.- Use
compactwhen space is constrained: week-row calendars, sidebars, narrow columns, multi-card grids. - Containers can propagate
mode(e.g.,event_listsetsmode="compact"for all items) but child items can override. - If
modeis absent or unknown, components fall back todefaultfor backward compatibility.
Philosophy: Craft Over Features
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?"
Core Principles
| 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. |
Color Palette
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)State Indication
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-semiboldfor unread/active, normal for read/inactive - Text opacity:
text-zinc-100for active,text-zinc-400for inactive - Background opacity:
bg-white/[0.05]for selected,bg-white/[0.03]for normal
Card Styling
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:
// DON'T DO THIS
<div className="rounded-xl border border-white/[0.08] bg-white/[0.03] p-4">
<h3>{title}</h3>
<p>{content}</p>
</div>Correct - component only has internal padding:
// DO THIS
<div className="p-4">
<h3>{title}</h3>
<p>{content}</p>
</div>The container/wrapper (controlled by the host app) applies the visual styling:
// Host app wraps component with desired styling
<div className="rounded-xl border border-white/[0.08] bg-white/[0.03]">
<MyComponent {...props} />
</div>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.) - Internal spacing between elements (
space-y-3,gap-2) - Text colors and typography
- Internal dividers (
border-t border-white/[0.08]between sections)
What components must NOT include:
- Outer border (
border,border-white/[0.08]) - Outer background (
bg-white/[0.03],bg-neutral-900) - Outer rounding (
rounded-xl,rounded-lg) - Box shadows (
shadow-xl)
Buttons
Primary (rare, for main actions):
className="rounded px-3 py-1.5 text-sm text-zinc-100
bg-white/[0.10] hover:bg-white/[0.15]"Secondary (most common):
className="rounded px-3 py-1.5 text-sm text-zinc-400
hover:bg-white/[0.05] hover:text-zinc-300"Icon buttons:
className="rounded p-1.5 text-zinc-500
hover:bg-white/[0.05] hover:text-zinc-300"Avatars
When brand icons unavailable, use monochrome initials:
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.
Typography Hierarchy
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-500Badges
Muted, not attention-grabbing:
className="rounded bg-zinc-700/50 px-1.5 py-0.5 text-xs text-zinc-400"Expanded Content
When cards expand to show more content, use a subtle divider:
className="mt-4 pt-4 border-t border-white/[0.08]"Text Utilities
Always decode HTML entities before display:
import { decodeHtmlEntities } from '../lib/utils';
// Use on any user-generated or API-sourced text
{decodeHtmlEntities(snippet)}Animations
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
// Fade-in on mount
className="animate-in fade-in duration-200"
// Staggered list items
style={{ animationDelay: `${index * 50}ms` }}Copy & Text
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."
Anti-Patterns
| 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] |
| Blue dot for unread | Visual noise | Bold text |
| text-yellow-400 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 |
Services Configuration
Components that require external services (transcription, icons) must be configured via HostProvider:
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' }),
},
};
<HostProvider api={hostAPI}>
<App />
</HostProvider>If services are not configured, components will show appropriate error states rather than silently failing.
Icon System
Components that display external brands (email senders, integrations) can resolve icons via a centralized service. Configure the URL via HostProvider.api.services.icons.url.
API Contract
Your icon service should accept POST requests:
POST {services.icons.url}
{
"queries": [
{ "type": "email", "value": "Google Calendar <[email protected]>" },
{ "type": "domain", "value": "github.com" },
{ "type": "brand", "value": "Notion" }
]
}
Response:
{
"icons": {
"email:Google Calendar <[email protected]>": "https://cdn.example.com/google-calendar.png",
"domain:github.com": "https://cdn.example.com/github.png",
"brand:Notion": "https://cdn.example.com/notion.png"
}
}Query Types
| Type | Resolution Logic | Example |
|------|------------------|---------|
| email | Extract brand from sender name/domain | "Google Calendar <[email protected]>" |
| 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.
Components
Interactive Components
| Component | Purpose |
|-----------|---------|
| AskPermission | 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 |
Data Display
| 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 |
Media
| 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 |
Progress and Status
| 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 |
Content
| 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 |
Layout and Animation
| 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).
Architecture
| 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 |
Usage
// 1. Wrap app with provider
import { StreamingProvider } from 'harness-ui';
<StreamingProvider>
<App />
</StreamingProvider>
// 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';
<ConnectedStreamingText streamId="response-1" />Controller Methods
| 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 |
Key Files
| 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:
ui_components:
- email_card
- metric_cardThe agent can then call render_email_card({ ... }) to display rich UI.
