agents
Create Agent
A two-step wizard card for naming an agent, picking its icon, and selecting which event sources activate it.
New agent
Name your agent
Give it a name, instructions, and an icon.
- Require approvalHuman must approve before any write action
- Read-only by defaultCannot push code or send messages directly
- Limit to 1 action per triggerPrevents runaway loops on rapid events
"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import {
Antenna,
AtSign,
Bot,
Brain,
Check,
ChevronRight,
Code2,
Compass,
Cpu,
Database,
Eye,
FlaskConical,
GitBranch,
Globe,
Layers,
Lightbulb,
Plus,
Rocket,
Search,
Server,
Shield,
ShieldAlert,
ShieldOff,
Sparkles,
Star,
Terminal,
Webhook,
Wrench,
X,
Zap,
} from "lucide-react";
import { cn } from "@/lib/utils";
// ── Trigger sources ──────────────────────────────────────────────────────────
interface TriggerField {
id: string;
label: string;
type: "text" | "select";
placeholder?: string;
options?: { value: string; label: string }[];
}
export const TRIGGER_SOURCES = [
{
id: "github" as const,
label: "GitHub",
icon: GitBranch,
description: "Issues, pull requests, and labels",
fields: [
{
id: "repo",
label: "Repository",
type: "text" as const,
placeholder: "e.g. carlosmarch/ai-patterns",
},
{
id: "event",
label: "Event",
type: "select" as const,
options: [
{ value: "issue-labeled", label: "Issue labeled" },
{ value: "pr-opened", label: "PR opened" },
{ value: "pr-merged", label: "PR merged" },
],
},
] satisfies TriggerField[],
},
{
id: "slack" as const,
label: "Slack",
icon: AtSign,
description: "Mentions, messages, and reactions",
fields: [
{
id: "channel",
label: "Channel",
type: "text" as const,
placeholder: "e.g. #design-requests",
},
{
id: "event",
label: "Trigger on",
type: "select" as const,
options: [
{ value: "mention", label: "Agent mentioned" },
{ value: "keyword", label: "Keyword match" },
],
},
] satisfies TriggerField[],
},
{
id: "figma" as const,
label: "Figma",
icon: Layers,
description: "Frame status changes and comments",
fields: [
{
id: "scope",
label: "Files",
type: "select" as const,
options: [
{ value: "any", label: "Any Figma file" },
{ value: "specific", label: "Specific file URL" },
],
},
{
id: "event",
label: "Event",
type: "select" as const,
options: [
{ value: "ready-for-dev", label: "Frame ready for dev" },
{ value: "comment", label: "New comment" },
],
},
] satisfies TriggerField[],
},
] as const;
export type TriggerSourceId = (typeof TRIGGER_SOURCES)[number]["id"];
// ── Agent icons ───────────────────────────────────────────────────────────────
export const AGENT_ICONS = [
{ id: "bot" as const, icon: Bot, label: "Bot" },
{ id: "sparkles" as const, icon: Sparkles, label: "Sparkles" },
{ id: "zap" as const, icon: Zap, label: "Zap" },
{ id: "brain" as const, icon: Brain, label: "Brain" },
{ id: "rocket" as const, icon: Rocket, label: "Rocket" },
] as const;
export const EXTRA_AGENT_ICONS = [
{ id: "cpu" as const, icon: Cpu, label: "CPU" },
{ id: "globe" as const, icon: Globe, label: "Globe" },
{ id: "terminal" as const, icon: Terminal, label: "Terminal" },
{ id: "code2" as const, icon: Code2, label: "Code" },
{ id: "wrench" as const, icon: Wrench, label: "Wrench" },
{ id: "search" as const, icon: Search, label: "Search" },
{ id: "eye" as const, icon: Eye, label: "Eye" },
{ id: "star" as const, icon: Star, label: "Star" },
{ id: "database" as const, icon: Database, label: "Database" },
{ id: "server" as const, icon: Server, label: "Server" },
{ id: "lightbulb" as const, icon: Lightbulb, label: "Lightbulb" },
{ id: "compass" as const, icon: Compass, label: "Compass" },
{ id: "flask" as const, icon: FlaskConical, label: "Flask" },
{ id: "antenna" as const, icon: Antenna, label: "Antenna" },
] as const;
export const ALL_AGENT_ICONS = [...AGENT_ICONS, ...EXTRA_AGENT_ICONS];
export type AgentIconId = (typeof ALL_AGENT_ICONS)[number]["id"];
// ── Guardrails ────────────────────────────────────────────────────────────────
export interface Guardrail {
id: string;
label: string;
description: string;
icon: React.ComponentType<{ className?: string }>;
enabled: boolean;
}
export const DEFAULT_GUARDRAILS: Guardrail[] = [
{
id: "approval-required",
label: "Require approval",
description: "Human must approve before any write action",
icon: ShieldAlert,
enabled: true,
},
{
id: "read-only",
label: "Read-only by default",
description: "Cannot push code or send messages directly",
icon: ShieldOff,
enabled: true,
},
{
id: "rate-limit",
label: "Limit to 1 action per trigger",
description: "Prevents runaway loops on rapid events",
icon: Shield,
enabled: false,
},
];
// ── Payload ───────────────────────────────────────────────────────────────────
export interface SelectedTrigger {
sourceId: TriggerSourceId;
config: Record<string, string>;
}
export interface CustomTrigger {
id: string;
name: string;
event: string;
}
export interface CreateAgentPayload {
name: string;
instructions: string;
iconId: AgentIconId;
guardrails: { id: string; enabled: boolean }[];
triggers: SelectedTrigger[];
customTriggers: CustomTrigger[];
}
// ── Props ─────────────────────────────────────────────────────────────────────
export interface CreateAgentProps {
onCreateAgent?: (payload: CreateAgentPayload) => void;
className?: string;
}
// ── Component ─────────────────────────────────────────────────────────────────
export function CreateAgent({ onCreateAgent, className }: CreateAgentProps) {
const [step, setStep] = React.useState<0 | 1>(0);
const [direction, setDirection] = React.useState<1 | -1>(1);
const [name, setName] = React.useState("");
const [instructions, setInstructions] = React.useState("");
const [iconId, setIconId] = React.useState<AgentIconId>("bot");
const [selectedSources, setSelectedSources] = React.useState<TriggerSourceId[]>([]);
const [triggerConfigs, setTriggerConfigs] = React.useState<
Partial<Record<TriggerSourceId, Record<string, string>>>
>({});
const [customTriggers, setCustomTriggers] = React.useState<CustomTrigger[]>([]);
const [showMoreIcons, setShowMoreIcons] = React.useState(false);
const [guardrailStates, setGuardrailStates] = React.useState<Record<string, boolean>>(
Object.fromEntries(DEFAULT_GUARDRAILS.map((g) => [g.id, g.enabled]))
);
const selectedIcon = ALL_AGENT_ICONS.find((i) => i.id === iconId)!;
const SelectedIconComp = selectedIcon.icon;
function goToStep1() {
setDirection(1);
setStep(1);
}
function goToStep0() {
setDirection(-1);
setStep(0);
}
function toggleSource(id: TriggerSourceId) {
setSelectedSources((prev) =>
prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]
);
}
function setTriggerField(sourceId: TriggerSourceId, fieldId: string, value: string) {
setTriggerConfigs((prev) => ({
...prev,
[sourceId]: { ...prev[sourceId], [fieldId]: value },
}));
}
function addCustomTrigger() {
setCustomTriggers((prev) => [
...prev,
{ id: `custom-${Date.now()}`, name: "", event: "" },
]);
}
function updateCustomTrigger(id: string, field: keyof Omit<CustomTrigger, "id">, value: string) {
setCustomTriggers((prev) =>
prev.map((t) => (t.id === id ? { ...t, [field]: value } : t))
);
}
function removeCustomTrigger(id: string) {
setCustomTriggers((prev) => prev.filter((t) => t.id !== id));
}
function toggleGuardrail(id: string) {
setGuardrailStates((prev) => ({ ...prev, [id]: !prev[id] }));
}
function buildPayload(): CreateAgentPayload {
return {
name: name.trim() || "Unnamed Agent",
instructions: instructions.trim(),
iconId,
guardrails: DEFAULT_GUARDRAILS.map((g) => ({ id: g.id, enabled: guardrailStates[g.id] ?? g.enabled })),
triggers: selectedSources.map((sourceId) => ({
sourceId,
config: triggerConfigs[sourceId] ?? {},
})),
customTriggers,
};
}
const variants = {
enter: (dir: number) => ({ x: dir * 16, opacity: 0 }),
center: { x: 0, opacity: 1 },
exit: (dir: number) => ({ x: dir * -16, opacity: 0 }),
};
return (
<div suppressHydrationWarning className={cn("w-full max-w-xs overflow-hidden rounded-2xl border bg-card", className)}>
{/* Header preview */}
<div className="flex items-center justify-between gap-3 border-b px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<SelectedIconComp className="size-3.5" />
</span>
<p className="truncate text-sm font-semibold text-foreground">
{name.trim() || "New agent"}
</p>
</div>
<div className="flex shrink-0 items-center gap-1" aria-hidden>
{[0, 1].map((i) => (
<span
key={i}
className={cn(
"block h-1.5 rounded-full transition-all duration-200",
step === i ? "w-4 bg-foreground" : "w-1.5 bg-muted-foreground/30"
)}
/>
))}
</div>
</div>
{/* Steps */}
<div className="relative overflow-hidden">
<AnimatePresence initial={false} custom={direction} mode="wait">
{step === 0 ? (
<motion.div
key="step-identity"
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.15, ease: "easeInOut" }}
className="px-4 pb-4 pt-3"
>
<p className="text-sm font-semibold">Name your agent</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Give it a name, instructions, and an icon.
</p>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. AI-Patterns"
aria-label="Agent name"
className="mt-3 w-full rounded-lg border bg-background px-3 py-2 text-sm outline-none ring-ring placeholder:text-muted-foreground/50 focus-visible:ring-2"
/>
<textarea
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
placeholder="e.g. When a GitHub issue is labeled pattern-request in carlosmarch/ai-patterns, triage it and suggest a matching component."
aria-label="Agent instructions"
rows={3}
className="mt-2 w-full resize-none rounded-lg border bg-background px-3 py-2 text-sm outline-none ring-ring placeholder:text-muted-foreground/50 focus-visible:ring-2"
/>
<ul className="mt-3 divide-y rounded-xl border">
{DEFAULT_GUARDRAILS.map(({ id, label, description, icon: Icon }) => {
const enabled = guardrailStates[id] ?? false;
return (
<li key={id} className="flex items-center gap-3 px-3 py-2.5">
<span className={cn("flex size-6 shrink-0 items-center justify-center rounded text-muted-foreground")}>
<Icon className="size-3.5" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-xs font-medium">{label}</span>
<span className="block text-[11px] text-muted-foreground">{description}</span>
</span>
<button
type="button"
role="switch"
aria-checked={enabled}
aria-label={label}
onClick={() => toggleGuardrail(id)}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
enabled ? "bg-foreground" : "bg-muted"
)}
>
<motion.span
aria-hidden
className="pointer-events-none inline-block h-4 w-4 rounded-full bg-background shadow-sm"
animate={{ x: enabled ? 16 : 0 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
/>
</button>
</li>
);
})}
</ul>
<div className="mt-3 space-y-1.5">
<div className="flex items-center gap-1.5">
{AGENT_ICONS.map(({ id, icon: Icon, label }) => (
<button
key={id}
type="button"
aria-label={label}
aria-pressed={iconId === id}
onClick={() => setIconId(id)}
className={cn(
"flex size-8 items-center justify-center rounded-lg transition-colors",
iconId === id
? "bg-foreground text-background"
: "bg-muted text-muted-foreground hover:text-foreground"
)}
>
<Icon className="size-4" />
</button>
))}
<button
type="button"
aria-label={showMoreIcons ? "Show fewer icons" : "Show more icons"}
aria-expanded={showMoreIcons}
onClick={() => setShowMoreIcons((v) => !v)}
className={cn(
"flex size-8 items-center justify-center rounded-lg transition-colors",
showMoreIcons
? "bg-muted text-foreground"
: "bg-muted text-muted-foreground hover:text-foreground"
)}
>
<Plus className={cn("size-4 transition-transform duration-150", showMoreIcons && "rotate-45")} />
</button>
</div>
<AnimatePresence initial={false}>
{showMoreIcons && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="flex flex-wrap gap-1.5 pt-0.5">
{EXTRA_AGENT_ICONS.map(({ id, icon: Icon, label }) => (
<button
key={id}
type="button"
aria-label={label}
aria-pressed={iconId === id}
onClick={() => { setIconId(id); setShowMoreIcons(false); }}
className={cn(
"flex size-8 items-center justify-center rounded-lg transition-colors",
iconId === id
? "bg-foreground text-background"
: "bg-muted text-muted-foreground hover:text-foreground"
)}
>
<Icon className="size-4" />
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<button
type="button"
onClick={goToStep1}
className="mt-4 flex w-full items-center justify-center gap-1.5 rounded-lg bg-foreground px-4 py-2 text-sm font-medium text-background transition-opacity hover:opacity-90"
>
Choose triggers
<ChevronRight className="size-3.5" />
</button>
<button
type="button"
onClick={() => onCreateAgent?.(buildPayload())}
className="mt-2 w-full text-center text-xs text-muted-foreground transition-colors hover:text-foreground"
>
Skip triggers
</button>
</motion.div>
) : (
<motion.div
key="step-triggers"
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.15, ease: "easeInOut" }}
className="px-4 pb-4 pt-3"
>
<p className="text-sm font-semibold">Choose triggers</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Select the sources that activate this agent.
</p>
<ul className="mt-3 flex flex-col gap-2">
{TRIGGER_SOURCES.map(({ id, label, icon: Icon, description, fields }) => {
const isSelected = selectedSources.includes(id);
const config = triggerConfigs[id] ?? {};
return (
<li key={id} className={cn("rounded-xl border transition-colors", isSelected ? "border-foreground/20 bg-muted/40" : "")}>
{/* Toggle row */}
<button
type="button"
aria-pressed={isSelected}
onClick={() => toggleSource(id)}
className="flex w-full items-center gap-3 px-3 py-2.5 text-left"
>
<span className="flex size-7 shrink-0 items-center justify-center rounded-md border bg-background text-muted-foreground">
<Icon className="size-3.5" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-xs font-medium">{label}</span>
<span className="block text-[11px] text-muted-foreground">{description}</span>
</span>
<span
className={cn(
"flex size-4 shrink-0 items-center justify-center rounded-full border transition-colors",
isSelected
? "border-foreground bg-foreground text-background"
: "border-muted-foreground/30"
)}
>
{isSelected && <Check className="size-2.5" strokeWidth={3} />}
</span>
</button>
{/* Expanded config */}
<AnimatePresence initial={false}>
{isSelected && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="flex flex-col gap-2 border-t px-3 pb-3 pt-2.5">
{fields.map((field) => (
<div key={field.id} className="flex flex-col gap-1">
<label className="text-[11px] font-medium text-muted-foreground">
{field.label}
</label>
{field.type === "text" ? (
<input
type="text"
value={config[field.id] ?? ""}
onChange={(e) => setTriggerField(id, field.id, e.target.value)}
placeholder={field.placeholder}
className="w-full rounded-md border bg-background px-2.5 py-1.5 text-xs outline-none ring-ring placeholder:text-muted-foreground/40 focus-visible:ring-2"
/>
) : (
<select
value={config[field.id] ?? field.options?.[0]?.value ?? ""}
onChange={(e) => setTriggerField(id, field.id, e.target.value)}
className="w-full rounded-md border bg-background px-2.5 py-1.5 text-xs outline-none ring-ring focus-visible:ring-2"
>
{field.options?.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
)}
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</li>
);
})}
</ul>
{/* Custom triggers */}
<AnimatePresence initial={false}>
{customTriggers.map((ct) => (
<motion.div
key={ct.id}
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="mt-2 rounded-xl border border-foreground/20 bg-muted/40">
<div className="flex items-center gap-2 px-3 py-2.5">
<span className="flex size-7 shrink-0 items-center justify-center rounded-md border bg-background text-muted-foreground">
<Webhook className="size-3.5" />
</span>
<span className="flex-1 text-xs font-medium text-muted-foreground">
Custom trigger
</span>
<button
type="button"
aria-label="Remove custom trigger"
onClick={() => removeCustomTrigger(ct.id)}
className="flex size-5 items-center justify-center rounded text-muted-foreground/50 transition-colors hover:text-foreground"
>
<X className="size-3.5" />
</button>
</div>
<div className="flex flex-col gap-2 border-t px-3 pb-3 pt-2.5">
<div className="flex flex-col gap-1">
<label className="text-[11px] font-medium text-muted-foreground">
Name
</label>
<input
type="text"
value={ct.name}
onChange={(e) => updateCustomTrigger(ct.id, "name", e.target.value)}
placeholder="e.g. Webhook received"
className="w-full rounded-md border bg-background px-2.5 py-1.5 text-xs outline-none ring-ring placeholder:text-muted-foreground/40 focus-visible:ring-2"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[11px] font-medium text-muted-foreground">
Event source
</label>
<input
type="text"
value={ct.event}
onChange={(e) => updateCustomTrigger(ct.id, "event", e.target.value)}
placeholder="e.g. POST /webhooks/my-agent"
className="w-full rounded-md border bg-background px-2.5 py-1.5 text-xs outline-none ring-ring placeholder:text-muted-foreground/40 focus-visible:ring-2"
/>
</div>
</div>
</div>
</motion.div>
))}
</AnimatePresence>
<button
type="button"
onClick={addCustomTrigger}
className="mt-2 flex w-full items-center gap-1.5 rounded-lg border border-dashed px-3 py-2 text-xs text-muted-foreground transition-colors hover:border-foreground/30 hover:text-foreground"
>
<Plus className="size-3.5" />
Add custom trigger
</button>
<div className="mt-3 flex gap-2">
<button
type="button"
onClick={goToStep0}
className="flex-1 rounded-lg border px-4 py-2 text-sm font-medium transition-colors hover:bg-muted"
>
Back
</button>
<button
type="button"
onClick={() => onCreateAgent?.(buildPayload())}
className="flex-1 rounded-lg bg-foreground px-4 py-2 text-sm font-medium text-background transition-opacity hover:opacity-90"
>
Create agent
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}
A UX spec for this pattern — written for agents implementing or reusing it, not the code.
# Create Agent
## Summary
A 2-step wizard card for creating a named, icon-identified agent and selecting which external event sources activate it. Step 1 captures identity (name and icon); Step 2 captures trigger sources. On completion it emits a typed payload that maps directly onto the Agent Triggers panel.
## When to use
- First-time agent creation where no agent exists yet.
- Onboarding flows that walk users through setting up their first automation.
- An agent catalog screen with an "Add agent" entry point that opens an inline or sheet-level wizard.
## When not to use
- Editing an existing agent's trigger sources — use the Agent Triggers pattern directly, which has per-trigger toggles and a "Finish setup" inline action.
- When only one trigger source is possible. Presenting a multi-select of one option adds unnecessary friction; skip to a single confirmation step instead.
- When agent creation is complex enough to require a full-page form (multiple steps beyond identity and triggers, required integrations, permissions review, etc.).
## Anatomy
1. **Header preview row** — always visible across both steps. Shows the currently selected icon in a rounded avatar, the live agent name (or "New agent" placeholder), and two step-progress dots.
2. **Step 1 — Identity** — a title/subtitle label pair, a text input for the agent name, a row of five icon-picker buttons (Bot, Sparkles, Zap, Brain, Rocket), and a full-width "Choose triggers →" CTA.
3. **Step 2 — Triggers** — a title/subtitle label pair, a `<ul>` of trigger source cards (each with a bordered icon square, a label and description, and a trailing checkmark circle), and a two-button footer with "Back" (outline) and "Create agent" (filled).
## Behavior
- The header preview row updates in real time: the name reflects every keystroke; the icon swaps immediately on selection. This gives users continuous feedback on how the finished agent will appear elsewhere in the product.
- Icon selection is immediate with no confirm step — clicking a picker button applies it at once.
- Trigger source cards are multi-select toggle buttons. The user may select zero, one, or all sources; there is no minimum enforcement in the component (enforce constraints in the consuming view if needed).
- Navigating from Step 1 to Step 2 slides content in from the right; navigating back slides from the left. AnimatePresence with `mode="wait"` ensures the outgoing step fully exits before the incoming step enters.
- "Create agent" is the terminal action. It fires `onCreateAgent` with a payload of `{ name, iconId, triggerSources }` and hands control entirely to the parent. The component itself has no post-creation state.
- The "Back" button does not reset Step 1 state — name and icon choices are preserved across forward/back navigation.
## Content guidelines
- Agent names should be short proper nouns or noun phrases that describe the agent's role, not its mechanism: "Pattern Bot", "Deploy Guard", "Design Review" — not "github-trigger-agent-v2".
- The placeholder text "e.g. Pattern Bot" demonstrates the expected format; replace it with a placeholder appropriate to the product domain.
- Trigger source labels name the platform (GitHub, Slack, Figma). Descriptions name the event type that fires the agent, not the agent's response: "Issues, pull requests, and labels" not "Monitors your repo and responds to issues".
- The "Create agent" button label should always say exactly that — avoid "Save", "Done", or "Finish", which imply the agent already exists.
## Accessibility
- The name input has a visible `<p>` label ("Name your agent") immediately above it and an `aria-label` attribute for programmatic association.
- Each icon-picker button has an `aria-label` naming the icon (e.g. "Sparkles") and `aria-pressed` reflecting selection state.
- Each trigger source card is a `<button>` with `aria-pressed` reflecting whether it is selected.
- The step-progress dots in the header are `aria-hidden` — they are a decorative visual indicator, not a navigation control.
- Step transitions use motion that respects `prefers-reduced-motion` via Motion's default behavior; the `duration: 0.15` transitions are brief enough that they cause minimal disruption even without the media-query guard.
## Related patterns
- **Agent Triggers** — the panel displayed after a successful `onCreateAgent` callback. The payload from Create Agent maps directly to Agent Triggers' `title`, `agent`, and `triggers` props.
- **Setup Checklist** — use for multi-step onboarding of a whole product or feature area when several independent tasks must be completed, not a single wizard with two sequential screens.
- **Invite Members** — shares the "create something new" modal shape: header preview, multi-step form, terminal action. Reference for visual consistency when both patterns appear in the same product.