Agent Builder New
A no-code agent builder with identity, instructions, model, tools, knowledge and creativity, beside a live test chat.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/ai-agent-builder.json02
Or copy the source below. Dependencies: lucide-react.
Source
components/niceui/ai/AgentBuilder.tsx
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react';
import { ArrowUp, Bot, Calculator, CalendarDays, Check, FileText, Globe, Headphones, Leaf, Library, Loader2, Plus, Rocket, Sparkles, X } from 'lucide-react';
import { cn } from '@/lib/cn';
const AVATARS = [
{ icon: Sparkles, className: 'bg-brand-500/15 text-brand-300 ring-brand-400/40' },
{ icon: Bot, className: 'bg-violet-500/15 text-violet-300 ring-violet-400/40' },
{ icon: Rocket, className: 'bg-amber-500/15 text-amber-300 ring-amber-400/40' },
{ icon: Leaf, className: 'bg-emerald-500/15 text-emerald-300 ring-emerald-400/40' },
{ icon: Headphones, className: 'bg-rose-500/15 text-rose-300 ring-rose-400/40' },
];
const MODELS = ['Nova Pro', 'Nova Mini', 'Atlas Reasoner'];
const TOOLS = [
{ id: 'web', label: 'Web search', hint: 'Fresh answers from the web', icon: Globe },
{ id: 'calendar', label: 'Calendar', hint: 'Find and book times', icon: CalendarDays },
{ id: 'code', label: 'Code interpreter', hint: 'Run quick calculations', icon: Calculator },
{ id: 'knowledge', label: 'Knowledge', hint: 'Answer from your files', icon: Library },
] as const;
type ToolId = (typeof TOOLS)[number]['id'];
const SPARE_FILES = ['benefits-2026.pdf', 'office-map.png', 'security-policy.md'];
export type AgentConfig = {
name: string;
avatar: number;
instructions: string;
model: string;
tools: Record<ToolId, boolean>;
files: string[];
creativity: number;
};
type Msg = { id: number; role: 'user' | 'agent'; text: string; used?: string[] };
const tone = (c: number) => (c < 34 ? 'Precise' : c < 67 ? 'Balanced' : 'Playful');
function list(items: string[]) {
if (items.length <= 1) return items[0] ?? 'answer questions';
return `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`;
}
/** The first message the agent sends, derived from its settings. */
export function greeting(c: AgentConfig) {
const can = list(
[c.tools.knowledge && c.files.length ? `answer from ${c.files.length} file${c.files.length > 1 ? 's' : ''}` : '', c.tools.calendar ? 'book time on calendars' : '', c.tools.web ? 'search the web' : '', c.tools.code ? 'run quick calculations' : ''].filter(Boolean),
);
const name = c.name.trim() || 'Your agent';
if (c.creativity < 34) return `${name}. I can ${can}. What do you need?`;
if (c.creativity < 67) return `Hi, I'm ${name}. I can ${can}. How can I help today?`;
return `Hey there, ${name} here and ready to go. I can ${can}, so throw anything at me.`;
}
function Toggle({ on, onClick, label }: { on: boolean; onClick: () => void; label: string }) {
return (
<button role="switch" aria-checked={on} aria-label={label} onClick={onClick} className={cn('focus-ring relative h-5 w-9 shrink-0 rounded-full transition-colors duration-300', on ? 'bg-brand-500' : 'bg-white/10')}>
<span className={cn('absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-all duration-300 ease-spring', on ? 'left-[18px]' : 'left-0.5')} />
</button>
);
}
function Field({ label, aside, children }: { label: string; aside?: ReactNode; children: ReactNode }) {
return (
<div>
<div className="mb-1.5 flex items-center justify-between gap-2">
<span className="text-[11px] font-medium uppercase tracking-wider text-ink-500">{label}</span>
{aside}
</div>
{children}
</div>
);
}
export type AgentBuilderProps = { initial: AgentConfig; className?: string };
/** A no-code agent builder: settings on the left, a live test chat on the right that reflects every change. */
export function AgentBuilder({ initial, className }: AgentBuilderProps) {
const [c, setC] = useState(initial);
const [publish, setPublish] = useState<'idle' | 'busy' | 'done'>('done');
const [msgs, setMsgs] = useState<Msg[]>([
{ id: 1, role: 'user', text: 'When is my laptop pickup?' },
{ id: 2, role: 'agent', text: 'Tuesday at 10:00 at the IT desk on the 3rd floor. I added it to your calendar.', used: ['it-setup.md', 'Calendar'] },
]);
const [draft, setDraft] = useState('');
const [typing, setTyping] = useState(false);
const timers = useRef<number[]>([]);
const list$ = useRef<HTMLDivElement>(null);
useEffect(() => () => timers.current.forEach(clearTimeout), []);
useEffect(() => {
const el = list$.current;
if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
}, [msgs, typing]);
const set = (patch: Partial<AgentConfig>) => {
setC((x) => ({ ...x, ...patch }));
setPublish('idle');
};
const Avatar = AVATARS[c.avatar];
const tokens = Math.ceil(c.instructions.length / 4);
const send = (e: FormEvent) => {
e.preventDefault();
const text = draft.trim();
if (!text || typing) return;
setDraft('');
setMsgs((m) => [...m, { id: Date.now(), role: 'user', text }]);
setTyping(true);
const used = [c.tools.knowledge && c.files[0], c.tools.web && /news|latest|today|weather/i.test(text) ? 'Web search' : ''].filter(Boolean) as string[];
const reply =
c.creativity < 34
? `Short answer: check ${c.files[0] ?? 'the handbook'}, section 2. I can book a follow-up if you need it.`
: c.creativity < 67
? `Good question. According to ${c.files[0] ?? 'our docs'}, you can sort that out in your first week. Want me to set a reminder?`
: `Love this one! ${c.files[0] ?? 'The docs'} has the details, and I can turn it into a tiny checklist for you.`;
timers.current.push(
window.setTimeout(() => {
setTyping(false);
setMsgs((m) => [...m, { id: Date.now(), role: 'agent', text: reply, used }]);
}, 900),
);
};
const doPublish = () => {
setPublish('busy');
timers.current.push(window.setTimeout(() => setPublish('done'), 1200));
};
return (
<div className={cn('grid w-full grid-cols-[minmax(0,1fr)] overflow-hidden rounded-2xl bg-ink-900 shadow-card md:grid-cols-[minmax(0,1.05fr)_minmax(0,1fr)]', className)}>
<section className="space-y-4 border-white/5 p-4 md:max-h-[560px] md:overflow-y-auto md:border-r [scrollbar-width:thin]">
<Field label="Identity">
<div className="flex items-center gap-2">
<span className={cn('grid h-10 w-10 shrink-0 place-items-center rounded-xl ring-1 transition-all', Avatar.className)}>
<Avatar.icon className="h-5 w-5" />
</span>
<input value={c.name} onChange={(e) => set({ name: e.target.value })} aria-label="Agent name" className="h-10 min-w-0 flex-1 rounded-xl bg-ink-950/60 px-3 text-sm text-ink-50 outline-none ring-1 ring-white/10 focus:ring-brand-400/50" />
</div>
<div className="mt-2 flex gap-1.5" role="radiogroup" aria-label="Avatar">
{AVATARS.map((a, i) => (
<button key={i} role="radio" aria-checked={c.avatar === i} aria-label={`Avatar ${i + 1}`} onClick={() => set({ avatar: i })} className={cn('focus-ring grid h-7 w-7 place-items-center rounded-lg ring-1 transition-all', a.className, c.avatar === i ? 'scale-110' : 'opacity-50 ring-transparent hover:opacity-90')}>
<a.icon className="h-3.5 w-3.5" />
</button>
))}
</div>
</Field>
<Field label="Instructions" aside={<span className="font-mono text-[10.5px] tabular-nums text-ink-500">~{tokens} tokens</span>}>
<textarea value={c.instructions} onChange={(e) => set({ instructions: e.target.value })} rows={3} aria-label="Instructions" className="w-full resize-none rounded-xl bg-ink-950/60 px-3 py-2 text-[13px] leading-relaxed text-ink-200 outline-none ring-1 ring-white/10 focus:ring-brand-400/50" />
</Field>
<Field label="Model">
<div className="flex flex-wrap gap-1 rounded-xl bg-ink-950/60 p-1 ring-1 ring-white/10">
{MODELS.map((m) => (
<button key={m} onClick={() => set({ model: m })} aria-pressed={c.model === m} className={cn('focus-ring flex-1 whitespace-nowrap rounded-lg px-2 py-1.5 text-xs transition-all', c.model === m ? 'bg-white/10 text-ink-50 shadow-hairline' : 'text-ink-400 hover:text-ink-200')}>
{m}
</button>
))}
</div>
</Field>
<Field label="Tools">
<div className="divide-y divide-white/5 rounded-xl ring-1 ring-white/10">
{TOOLS.map((t) => (
<div key={t.id} className="flex items-center gap-2.5 px-3 py-2">
<t.icon className="h-4 w-4 shrink-0 text-ink-400" />
<span className="min-w-0 flex-1">
<span className="block text-[13px] text-ink-100">{t.label}</span>
<span className="block truncate text-[11px] text-ink-500">{t.hint}</span>
</span>
<Toggle label={t.label} on={c.tools[t.id]} onClick={() => set({ tools: { ...c.tools, [t.id]: !c.tools[t.id] } })} />
</div>
))}
</div>
</Field>
<Field label="Knowledge">
<div className="flex flex-wrap gap-1.5">
{c.files.map((f) => (
<span key={f} className="flex items-center gap-1 rounded-lg bg-white/[0.05] py-1 pl-2 pr-1 text-xs text-ink-200 animate-scale-in">
<FileText className="h-3 w-3 text-ink-400" />
{f}
<button onClick={() => set({ files: c.files.filter((x) => x !== f) })} aria-label={`Remove ${f}`} className="focus-ring grid h-4 w-4 place-items-center rounded text-ink-500 hover:bg-white/10 hover:text-ink-100">
<X className="h-3 w-3" />
</button>
</span>
))}
{SPARE_FILES.some((f) => !c.files.includes(f)) && (
<button onClick={() => set({ files: [...c.files, SPARE_FILES.find((f) => !c.files.includes(f))!] })} className="focus-ring flex items-center gap-1 rounded-lg border border-dashed border-white/15 px-2 py-1 text-xs text-ink-400 hover:text-ink-100">
<Plus className="h-3 w-3" /> Add file
</button>
)}
</div>
</Field>
<Field label="Creativity" aside={<span className="text-[11px] text-brand-300">{tone(c.creativity)}</span>}>
<input type="range" min={0} max={100} value={c.creativity} onChange={(e) => set({ creativity: Number(e.target.value) })} aria-label="Creativity" className="w-full accent-brand-400" />
<div className="flex justify-between text-[10.5px] text-ink-500">
<span>Precise</span>
<span>Playful</span>
</div>
</Field>
</section>
<section className="flex min-h-[420px] min-w-0 flex-col border-t border-white/5 md:max-h-[560px] md:border-t-0">
<header className="flex items-center gap-2.5 border-b border-white/5 px-4 py-3">
<span className={cn('grid h-8 w-8 shrink-0 place-items-center rounded-lg ring-1', Avatar.className)}>
<Avatar.icon className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-ink-50">{c.name || 'Untitled agent'}</span>
<span className="block truncate font-mono text-[10.5px] text-ink-500">
{c.model} · {tone(c.creativity).toLowerCase()}
</span>
</span>
<span className="rounded-full bg-amber-500/10 px-2 py-0.5 text-[10.5px] font-medium text-amber-300">Test</span>
</header>
<div ref={list$} className="min-h-0 flex-1 space-y-3 overflow-y-auto px-4 py-4 [scrollbar-width:thin]" aria-live="polite">
<p key={greeting(c)} className="max-w-[90%] rounded-2xl rounded-bl-md bg-white/[0.04] px-3.5 py-2 text-sm leading-relaxed text-ink-200 ring-1 ring-white/5 animate-fade-in">
{greeting(c)}
</p>
{msgs.map((m) =>
m.role === 'user' ? (
<p key={m.id} className="ml-auto max-w-[85%] rounded-2xl rounded-br-md bg-brand-500/15 px-3.5 py-2 text-sm text-ink-50 animate-fade-up">
{m.text}
</p>
) : (
<div key={m.id} className="max-w-[90%] animate-fade-up">
<p className="rounded-2xl rounded-bl-md bg-white/[0.04] px-3.5 py-2 text-sm leading-relaxed text-ink-200 ring-1 ring-white/5">{m.text}</p>
{!!m.used?.length && (
<div className="mt-1.5 flex flex-wrap gap-1">
{m.used.map((u) => (
<span key={u} className="flex items-center gap-1 rounded-md bg-white/[0.03] px-1.5 py-0.5 font-mono text-[10px] text-ink-500">
<Check className="h-2.5 w-2.5 text-emerald-400" /> {u}
</span>
))}
</div>
)}
</div>
),
)}
{typing && <p className="shimmer-text text-xs font-medium">{c.name || 'Agent'} is typing…</p>}
</div>
<form onSubmit={send} className="mx-3 mb-3 flex items-center gap-2 rounded-xl bg-ink-950/60 py-1 pl-3 pr-1 ring-1 ring-white/10 focus-within:ring-brand-400/50">
<input value={draft} onChange={(e) => setDraft(e.target.value)} placeholder={`Message ${c.name || 'your agent'}…`} aria-label="Test message" className="h-8 min-w-0 flex-1 bg-transparent text-sm text-ink-100 outline-none placeholder:text-ink-600" />
<button type="submit" disabled={!draft.trim() || typing} aria-label="Send" className="focus-ring grid h-8 w-8 place-items-center rounded-lg bg-brand-500 text-white transition-all disabled:bg-white/10 disabled:text-ink-500 active:scale-95">
<ArrowUp className="h-4 w-4" />
</button>
</form>
<footer className="flex items-center gap-2 border-t border-white/5 px-4 py-3">
<span className="min-w-0 flex-1 truncate text-xs text-ink-500">
{publish === 'done' ? (
<span className="flex items-center gap-1.5 text-emerald-300">
<Check className="h-3.5 w-3.5" /> Live at agents.acme.dev/{(c.name || 'agent').toLowerCase().replace(/[^a-z0-9]+/g, '-')}
</span>
) : (
'Unpublished changes'
)}
</span>
<button onClick={doPublish} disabled={publish !== 'idle'} className="focus-ring flex h-8 shrink-0 items-center gap-1.5 rounded-lg bg-white px-3 text-xs font-medium text-ink-950 transition-all hover:bg-ink-100 active:scale-[0.97] disabled:bg-white/10 disabled:text-ink-400">
{publish === 'busy' && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{publish === 'done' ? 'Published' : publish === 'busy' ? 'Publishing' : 'Publish'}
</button>
</footer>
</section>
</div>
);
}
/* ——— demo ——— */
export function AgentBuilderDemo() {
return (
<AgentBuilder
initial={{
name: 'Onboarding Buddy',
avatar: 0,
instructions: 'You help new hires through their first two weeks. Be warm and brief, cite the handbook, and offer to book meetings.',
model: 'Nova Pro',
tools: { web: false, calendar: true, code: false, knowledge: true },
files: ['it-setup.md', 'handbook.pdf'],
creativity: 45,
}}
/>
);
}