Skip to content
833 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.1/**
2 * The agent builder (docs.g1t.sh/guides/agents/, "Describe it"): one box
3 * for what the agent should do, the drafted agent as a card to edit, a test
4 * chat beside it, and, on an agent's profile, a box to change it in words
5 * with the change shown before it is saved.
6 */
7import { ArrowUp, Check, Dices, Lock, MessageSquare, Plug, RotateCcw, Shuffle, Sparkles, Users, Wand2 } from "lucide-react";
8import { type ReactNode, useEffect, useRef, useState } from "react";
9import { Form, Link, useFetcher, useNavigation } from "react-router";
10
11import { type AgentProposal, type AgentRedraft, type AgentTemplate, type DraftTurn, FOUNDATIONAL_SKILLS, type WorkspaceAgent } from "@g1t/contracts";
12
An agent is on teams the way a person is: through team membership, with no team or department of its own. Add it to teams when you create it, from its profile or from the team's page; the Agents sidebar groups agents by the teams they're on, with those on none last; what an agent is told about its teammates, team budgets, skills reach, Spend's By team and the People pages all read memberships. Agents with an old team name matching a team are moved onto it once; the rest are left off a team. Templates name a role only. The agents, teams and people guides say how.13import { TeamPicker } from "../agent-role";
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.14import { AgentAvatar, PixelCreature } from "../agent-avatar";
15import { Markdown } from "../markdown";
16import { Badge } from "../ui/badge";
The top of every page is one surface: the breadcrumb, search, spend, Ask g1t, bell and + share one height, corners and spacing, Ask g1t is a plain tinted button with the g1t mark drawn on whole pixels, a page's own header sits directly under the bar at the same left edge, and every section, conversations and open artifacts included, draws the same rounded panel. Tables written in chat render as tables, and the site's file, checkbox, radio, number and date controls are the design system's. Button, Separator, Breadcrumb, Table and FileDrop join the shared components; the chat guide says how tables work.17import { Checkbox } from "../ui/checkbox";
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.18import { Hint } from "../ui/hint";
19import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
20import { type BuilderDefinition, type ChangeRow, changeRows, integrationHint } from "../../lib/agent-builder";
21import { PRESETS, TIER_LABELS, cleanHandle, dollarsField, microsFromDollars } from "../../lib/agent-form";
22import { cn } from "../../lib/cn";
23import { money } from "../../lib/usage";
24
25const FIELD =
26 "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim";
27const PRIMARY = "inline-flex h-9 items-center justify-center gap-2 rounded-md bg-accent px-4 text-sm font-medium text-bg transition-colors hover:bg-accent-hover disabled:opacity-60";
28const QUIET =
29 "inline-flex h-9 items-center justify-center gap-2 rounded-md border border-line px-3.5 text-sm font-medium text-fg/85 transition-colors hover:border-line-strong hover:bg-raised hover:text-fg disabled:opacity-60";
30
31/** What the builder's actions answer. */
32export type BuilderAnswer =
33 | { intent: "draft"; ok: true; proposal: AgentProposal }
34 | { intent: "try"; ok: true; text: string; charged_micros: number }
35 | { intent: "redraft"; ok: true; redraft: AgentRedraft }
36 | { intent: string; ok: false; error: string };
37
38/** Things people ask agents to do, to start the box when it is empty. */
39const EXAMPLES = [
40 "Every Monday, read last week's merged pull requests and write release notes in our Releases space.",
41 "Triage new bug reports: reproduce them from the steps, label them, and ask for what's missing.",
42 "Keep me on top of my week: summarise what I'm mentioned in and what's waiting on me.",
43 "Review database migrations for locking, data loss and missing indexes before they merge.",
44];
45
46// ── Describe it ───────────────────────────────────────────────────────────
47
48/**
49 * The first thing New agent shows: one box, "What should this agent do?",
50 * the roles to start from under it, and the full form for those who want it.
51 */
52export function DescribeBox({
53 slug,
54 owner,
55 mayCreate,
56 templates,
57 fetcher,
58 initial,
59}: {
60 slug: string;
61 owner: boolean;
62 /** Whether the viewer may create an agent here at all. */
63 mayCreate: boolean;
64 templates: AgentTemplate[];
65 fetcher: ReturnType<typeof useFetcher<BuilderAnswer>>;
66 initial?: string;
67}) {
68 const [text, setText] = useState(initial ?? "");
69 const [scope, setScope] = useState<"workspace" | "personal">(owner ? "workspace" : "personal");
70 const busy = fetcher.state !== "idle";
71 const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok && fetcher.data.intent === "draft" ? fetcher.data.error : null;
72 return (
73 <div>
74 <fetcher.Form method="post" className="relative">
75 <input type="hidden" name="intent" value="draft" />
76 <input type="hidden" name="scope" value={scope} />
77 <label htmlFor="description" className="sr-only">
78 What should this agent do?
79 </label>
80 <div
81 className={cn(
82 "rounded-2xl border border-line bg-surface p-1.5 shadow-sm transition-colors focus-within:border-accent/60 focus-within:ring-4 focus-within:ring-accent/10",
83 !mayCreate && "opacity-60",
84 )}
85 >
86 <textarea
87 id="description"
88 name="description"
89 value={text}
90 onChange={(event) => setText(event.target.value)}
91 onKeyDown={(event) => {
92 if (event.key === "Enter" && (event.metaKey || event.ctrlKey) && text.trim().length >= 3) event.currentTarget.form?.requestSubmit();
93 }}
94 disabled={!mayCreate || busy}
95 rows={4}
96 maxLength={2000}
97 placeholder="Describe the job: what it does, for whom, and what good looks like."
98 className="block w-full resize-none bg-transparent px-3 pt-2.5 pb-1 text-base leading-relaxed outline-none placeholder:text-faint sm:text-[0.9375rem]"
99 />
100 <div className="flex flex-wrap items-center gap-2 px-1.5 pb-1">
101 {owner ? (
102 <div role="radiogroup" aria-label="Who it's for" className="flex rounded-lg bg-bg p-0.5 ring-1 ring-line">
103 {(
104 [
105 ["workspace", "The workspace", <Users key="w" size={13} />],
106 ["personal", "Just me", <Lock key="p" size={13} />],
107 ] as const
108 ).map(([value, label, icon]) => (
109 <button
110 key={value}
111 type="button"
112 role="radio"
113 aria-checked={scope === value}
114 onClick={() => setScope(value)}
115 className={cn(
116 "inline-flex h-7 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium transition-colors",
117 scope === value ? "bg-raised text-fg shadow-sm ring-1 ring-line-strong" : "text-muted hover:text-fg",
118 )}
119 >
120 {icon}
121 {label}
122 </button>
123 ))}
124 </div>
125 ) : (
126 <span className="inline-flex items-center gap-1.5 text-xs text-muted">
127 <Lock size={12} />
128 Your personal agent
129 </span>
130 )}
131 <span className="grow" />
132 <span className="hidden text-xs text-faint sm:inline">{busy ? "Drafting…" : "Ctrl+Enter"}</span>
133 <button type="submit" disabled={!mayCreate || busy || text.trim().length < 3} className={cn(PRIMARY, "h-8 px-3")}>
134 <Sparkles size={14} className={busy ? "animate-pulse motion-reduce:animate-none" : ""} />
135 {busy ? "Drafting…" : "Draft it"}
136 </button>
137 </div>
138 </div>
139 </fetcher.Form>
140 {error && <p className="mt-3 rounded-lg border border-danger/40 bg-danger/10 px-4 py-2.5 text-sm text-danger">{error}</p>}
141 <p className="mt-3 text-xs leading-relaxed text-muted">
142 {scope === "personal"
143 ? "Only you can talk to it, in your direct message with it. It spends from your own budget: $20 a month and $2 a session to start."
144 : "Everyone in the workspace can talk to it. It starts with the workspace's budget for a new agent."}{" "}
145 Drafting is a quick model call charged to you; nothing is saved until you create it.
146 </p>
147 {!text && mayCreate && (
148 <div className="mt-5">
149 <p className="text-xs font-medium text-faint">For example</p>
150 <ul className="mt-2 grid gap-2 sm:grid-cols-2">
151 {EXAMPLES.map((example) => (
152 <li key={example}>
153 <button
154 type="button"
155 onClick={() => setText(example)}
156 className="w-full rounded-lg border border-line bg-bg px-3 py-2 text-left text-[0.8125rem] leading-snug text-muted transition-colors hover:border-line-strong hover:text-fg"
157 >
158 {example}
159 </button>
160 </li>
161 ))}
162 </ul>
163 </div>
164 )}
165 {templates.length > 0 && (
166 <div className="mt-8">
167 <p className="text-xs font-medium text-faint">or start from a role</p>
168 <ul className="mt-2 flex flex-wrap gap-2">
169 {templates.map((template) => (
170 <li key={template.id}>
171 <Link
172 to={`?template=${encodeURIComponent(template.id)}`}
173 preventScrollReset
174 className="inline-flex h-8 items-center gap-2 rounded-full border border-line bg-surface pr-3 pl-1 text-[0.8125rem] text-fg/85 transition-colors hover:border-line-strong hover:text-fg"
175 >
176 <PixelCreature seed={template.handle} size={24} className="rounded-full" />
177 {template.title}
178 </Link>
179 </li>
180 ))}
181 <li>
182 <Link
183 to="?template=blank"
184 preventScrollReset
185 className="inline-flex h-8 items-center gap-1.5 rounded-full border border-dashed border-line-strong px-3 text-[0.8125rem] text-muted transition-colors hover:text-fg"
186 >
187 Edit all fields
188 </Link>
189 </li>
190 </ul>
191 <p className="mt-3 text-xs text-faint">
192 <Link to={`/${slug}/-/agents/templates`} className="hover:text-fg hover:underline">
193 Read each role in full
194 </Link>
195 </p>
196 </div>
197 )}
198 </div>
199 );
200}
201
202// ── The drafted agent ─────────────────────────────────────────────────────
203
204function Label({ htmlFor, children, hint }: { htmlFor?: string; children: ReactNode; hint?: ReactNode }) {
205 return (
206 <div className="mb-1.5 flex items-baseline justify-between gap-3">
207 <label htmlFor={htmlFor} className="text-xs font-medium text-fg-soft">
208 {children}
209 </label>
210 {hint && <span className="text-xs text-faint">{hint}</span>}
211 </div>
212 );
213}
214
215function Section({ title, about, children }: { title: string; about?: ReactNode; children: ReactNode }) {
216 return (
217 <section className="border-t border-line px-4 py-4 sm:px-5">
218 <h3 className="text-xs font-semibold tracking-wide text-faint uppercase">{title}</h3>
219 {about && <p className="mt-1 text-xs text-muted">{about}</p>}
220 <div className="mt-3 space-y-3">{children}</div>
221 </section>
222 );
223}
224
225/**
226 * The drafted agent as a card to edit: who it is, its job, what it is
227 * responsible for, its voice, the skills it keeps on, what it needs
228 * connected, routines that would suit it, its model limits and budget.
229 * Only the name and the job are required.
230 */
231export function ProposalCard({
232 slug,
233 owner,
234 proposal,
235 value,
236 onChange,
An agent is on teams the way a person is: through team membership, with no team or department of its own. Add it to teams when you create it, from its profile or from the team's page; the Agents sidebar groups agents by the teams they're on, with those on none last; what an agent is told about its teammates, team budgets, skills reach, Spend's By team and the People pages all read memberships. Agents with an old team name matching a team are moved onto it once; the rest are left off a team. Templates name a role only. The agents, teams and people guides say how.237 joinable = [],
238 teams = [],
239 onTeams,
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.240}: {
241 slug: string;
242 owner: boolean;
243 proposal: AgentProposal;
244 value: BuilderDefinition;
245 onChange: (next: BuilderDefinition) => void;
An agent is on teams the way a person is: through team membership, with no team or department of its own. Add it to teams when you create it, from its profile or from the team's page; the Agents sidebar groups agents by the teams they're on, with those on none last; what an agent is told about its teammates, team budgets, skills reach, Spend's By team and the People pages all read memberships. Agents with an old team name matching a team are moved onto it once; the rest are left off a team. Templates name a role only. The agents, teams and people guides say how.246 /** The teams the person may add it to; a workspace agent only. */
247 joinable?: { slug: string; name: string }[];
248 /** The teams chosen to add it to: memberships, posted with Create, never part of the draft. */
249 teams?: string[];
250 onTeams?: (next: string[]) => void;
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.251}) {
252 const ideas = [proposal.definition.display_name, ...proposal.name_ideas];
253 const [idea, setIdea] = useState(0);
254 const [handleEdited, setHandleEdited] = useState(false);
255 const set = (patch: Partial<BuilderDefinition>) => onChange({ ...value, ...patch });
256 const rename = (name: string) => set({ display_name: name, ...(handleEdited ? {} : { handle: cleanHandle(name) }) });
257 const off = new Set(value.skills_off ?? []);
258 const suggested = new Set(proposal.skills);
259 const hints = proposal.integrations.map((s) => integrationHint(slug, s, owner)).filter((h) => h !== null);
260 const seed = value.avatar_seed || value.handle || "agent";
261 return (
262 <div className="overflow-hidden rounded-2xl border border-line bg-surface">
263 <div className="flex items-start gap-3 px-4 pt-4 pb-4 sm:px-5">
264 <div className="relative shrink-0">
265 <AgentAvatar agent={{ handle: value.handle, avatar_seed: seed }} size={56} />
266 <Hint label="Another face">
267 <button
268 type="button"
269 aria-label="Another face"
270 onClick={() => set({ avatar_seed: `${value.handle || "agent"}-${Math.random().toString(36).slice(2, 7)}` })}
271 className="absolute -right-1.5 -bottom-1.5 flex size-6 items-center justify-center rounded-full border border-line bg-bg text-muted shadow-sm transition-colors hover:text-fg"
272 >
273 <Dices size={13} />
274 </button>
275 </Hint>
276 </div>
277 <div className="grid min-w-0 grow gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,0.8fr)]">
278 <div>
279 <Label htmlFor="b-name">Name</Label>
280 <div className="flex gap-1.5">
281 <input id="b-name" value={value.display_name} onChange={(e) => rename(e.target.value)} className={FIELD} autoComplete="off" data-1p-ignore required />
282 {ideas.length > 1 && (
283 <Hint label="Another name">
284 <button
285 type="button"
286 aria-label="Another name"
287 onClick={() => {
288 const next = (idea + 1) % ideas.length;
289 setIdea(next);
290 rename(ideas[next]!);
291 }}
292 className="flex size-9.5 shrink-0 items-center justify-center rounded-md border border-line text-muted transition-colors hover:border-line-strong hover:bg-raised hover:text-fg"
293 >
294 <Shuffle size={15} />
295 </button>
296 </Hint>
297 )}
298 </div>
299 </div>
300 <div>
301 <Label htmlFor="b-handle">Handle</Label>
302 <div className="flex items-center rounded-md border border-line bg-bg transition-colors focus-within:border-accent-dim hover:border-line-strong">
303 <span className="pl-3 font-mono text-sm text-faint">@</span>
304 <input
305 id="b-handle"
306 value={value.handle}
307 onChange={(e) => {
308 setHandleEdited(true);
309 set({ handle: e.target.value });
310 }}
311 className="min-w-0 grow bg-transparent py-2 pr-3 pl-0.5 font-mono text-sm outline-none"
312 autoComplete="off"
313 data-1p-ignore
314 />
315 </div>
316 </div>
317 <div>
318 <Label htmlFor="b-title">Title</Label>
319 <input id="b-title" value={value.title ?? ""} onChange={(e) => set({ title: e.target.value })} placeholder="Release Manager" className={FIELD} autoComplete="off" />
320 </div>
321 </div>
322 </div>
323
An agent is on teams the way a person is: through team membership, with no team or department of its own. Add it to teams when you create it, from its profile or from the team's page; the Agents sidebar groups agents by the teams they're on, with those on none last; what an agent is told about its teammates, team budgets, skills reach, Spend's By team and the People pages all read memberships. Agents with an old team name matching a team are moved onto it once; the rest are left off a team. Templates name a role only. The agents, teams and people guides say how.324 {onTeams && (
325 <Section title="Add to teams" about={value.scope === "personal" ? "A personal agent is on no team." : "Optional. It joins them like anyone, and you can change them on its profile or the team's page."}>
326 {value.scope === "personal" ? null : <TeamPicker teams={joinable} value={teams} onChange={onTeams} />}
327 </Section>
328 )}
329
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.330 <Section title="Job" about="What it is responsible for and how it works. It reads this before every reply.">
331 <textarea
332 aria-label="Job"
333 value={value.instructions}
334 onChange={(e) => set({ instructions: e.target.value })}
335 rows={8}
336 required
337 className={`${FIELD} resize-y font-mono text-[0.8125rem] leading-relaxed`}
338 />
339 <div>
340 <Label htmlFor="b-duties" hint="One a line, 2 to 8">
341 Responsibilities
342 </Label>
343 <textarea
344 id="b-duties"
345 value={(value.responsibilities ?? []).join("\n")}
346 onChange={(e) => set({ responsibilities: e.target.value.split("\n").slice(0, 8) })}
347 onBlur={() => set({ responsibilities: (value.responsibilities ?? []).map((d) => d.trim()).filter(Boolean) })}
348 rows={Math.max(3, (value.responsibilities ?? []).length)}
349 className={`${FIELD} resize-y`}
350 />
351 </div>
352 </Section>
353
354 <Section title="Personality" about="Its voice only. A personality never changes what it may do.">
355 <div role="radiogroup" aria-label="Voice" className="grid grid-cols-2 gap-1 rounded-lg bg-bg p-1 ring-1 ring-line sm:grid-cols-4">
356 {PRESETS.map((option) => (
357 <button
358 key={option.value}
359 type="button"
360 role="radio"
361 aria-checked={value.personality_preset === option.value}
362 onClick={() => set({ personality_preset: option.value })}
363 className={cn(
364 "h-8 rounded-md text-[0.8125rem] font-medium transition-colors",
365 value.personality_preset === option.value ? "bg-raised text-fg shadow-sm ring-1 ring-line-strong" : "text-muted hover:text-fg",
366 )}
367 >
368 {option.label}
369 </button>
370 ))}
371 </div>
372 <input aria-label="In its own words" value={value.personality ?? ""} onChange={(e) => set({ personality: e.target.value })} placeholder="In its own words (optional)" className={FIELD} />
373 </Section>
374
375 <Section title="Skills" about="g1t's playbooks it keeps on. The suggested ones fit its job; turn any on or off.">
376 <div className="grid gap-1.5 sm:grid-cols-2">
377 {FOUNDATIONAL_SKILLS.map((skill) => {
378 const on = !off.has(skill.id);
379 return (
380 <label
381 key={skill.id}
The top of every page is one surface: the breadcrumb, search, spend, Ask g1t, bell and + share one height, corners and spacing, Ask g1t is a plain tinted button with the g1t mark drawn on whole pixels, a page's own header sits directly under the bar at the same left edge, and every section, conversations and open artifacts included, draws the same rounded panel. Tables written in chat render as tables, and the site's file, checkbox, radio, number and date controls are the design system's. Button, Separator, Breadcrumb, Table and FileDrop join the shared components; the chat guide says how tables work.382 className="flex min-h-10 cursor-pointer items-center gap-2.5 rounded-lg border border-line bg-bg px-3 py-2 text-sm has-[[data-state=checked]]:border-accent/50 has-[[data-state=checked]]:bg-accent/5"
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.383 >
The top of every page is one surface: the breadcrumb, search, spend, Ask g1t, bell and + share one height, corners and spacing, Ask g1t is a plain tinted button with the g1t mark drawn on whole pixels, a page's own header sits directly under the bar at the same left edge, and every section, conversations and open artifacts included, draws the same rounded panel. Tables written in chat render as tables, and the site's file, checkbox, radio, number and date controls are the design system's. Button, Separator, Breadcrumb, Table and FileDrop join the shared components; the chat guide says how tables work.384 <Checkbox
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.385 checked={on}
The top of every page is one surface: the breadcrumb, search, spend, Ask g1t, bell and + share one height, corners and spacing, Ask g1t is a plain tinted button with the g1t mark drawn on whole pixels, a page's own header sits directly under the bar at the same left edge, and every section, conversations and open artifacts included, draws the same rounded panel. Tables written in chat render as tables, and the site's file, checkbox, radio, number and date controls are the design system's. Button, Separator, Breadcrumb, Table and FileDrop join the shared components; the chat guide says how tables work.386 onCheckedChange={() => {
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.387 const next = new Set(off);
388 if (on) next.add(skill.id);
389 else next.delete(skill.id);
390 set({ skills_off: FOUNDATIONAL_SKILLS.map((s) => s.id).filter((id) => next.has(id)) });
391 }}
392 />
393 <span className="min-w-0 grow truncate">{skill.name}</span>
394 {suggested.has(skill.id) && suggested.size < FOUNDATIONAL_SKILLS.length && (
395 <Badge tone="accent" className="shrink-0">
396 Suggested
397 </Badge>
398 )}
399 </label>
400 );
401 })}
402 </div>
403 </Section>
404
405 {hints.length > 0 && (
406 <Section title="What it needs connected" about="Integrations its job would use. It works without them, and says so when asked for something that needs one.">
407 <ul className="divide-y divide-line overflow-hidden rounded-lg border border-line bg-bg">
408 {hints.map((hint) => (
409 <li key={hint.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2.5">
410 <Plug size={14} className="shrink-0 text-faint" />
411 <span className="min-w-0 grow basis-40">
412 <span className="block text-sm font-medium">{hint.name}</span>
413 {hint.why && <span className="block text-xs text-muted">{hint.why}</span>}
414 </span>
415 {hint.action === "soon" ? (
416 <Badge>Soon</Badge>
417 ) : (
418 <Link to={hint.href} target="_blank" rel="noreferrer" className="text-xs font-medium text-accent hover:underline">
419 {hint.action === "connect" ? "Connect" : "Ask an owner"}
420 </Link>
421 )}
422 </li>
423 ))}
424 </ul>
425 </Section>
426 )}
427
428 {proposal.routines.length > 0 && (
429 <Section
430 title="Routines that would suit it"
431 about={value.scope === "personal" ? "Personal agents can't run routines yet. An owner can promote it to a workspace agent, which can." : "Set them up on its Routines tab once it exists."}
432 >
433 <ul className="space-y-2">
434 {proposal.routines.map((routine) => (
435 <li key={routine.name} className="rounded-lg border border-line bg-bg px-3 py-2.5">
436 <p className="text-sm font-medium">
437 {routine.name} <span className="font-normal text-muted">· {routine.when}</span>
438 </p>
439 {routine.instructions && <p className="mt-0.5 text-xs text-muted">{routine.instructions}</p>}
440 </li>
441 ))}
442 </ul>
443 </Section>
444 )}
445
446 <Section title="Models and budget" about="g1t routes each step to the tier it needs; these only limit it. Budgets are checked before work starts.">
447 <div className="grid gap-3 sm:grid-cols-2">
448 {(["floor", "ceiling"] as const).map((key) => (
449 <div key={key}>
450 <Label htmlFor={`b-${key}`}>{key === "floor" ? "Floor" : "Ceiling"}</Label>
451 <Select
452 value={value.routing?.[key] ?? "none"}
453 onValueChange={(tier) =>
454 set({ routing: { floor: null, ceiling: null, providers: [], pinned: null, ...value.routing, [key]: tier === "none" ? null : (tier as "small") } })
455 }
456 >
457 <SelectTrigger id={`b-${key}`}>
458 <SelectValue />
459 </SelectTrigger>
460 <SelectContent>
461 <SelectItem value="none">{key === "floor" ? "No floor" : "No ceiling"}</SelectItem>
462 {(["small", "large", "frontier"] as const).map((tier) => (
463 <SelectItem key={tier} value={tier} description={TIER_LABELS[tier].split(": ")[1]}>
464 {TIER_LABELS[tier].split(":")[0]}
465 </SelectItem>
466 ))}
467 </SelectContent>
468 </Select>
469 </div>
470 ))}
471 {(
472 [
473 ["monthly_micros", "Monthly"],
474 ["task_micros", "Per session"],
475 ] as const
476 ).map(([key, label]) => (
477 <div key={key}>
478 <Label htmlFor={`b-${key}`} hint={key === "monthly_micros" ? "Per calendar month" : "Going over asks you"}>
479 {label}
480 </Label>
481 <div className="flex items-center rounded-md border border-line bg-bg transition-colors focus-within:border-accent-dim hover:border-line-strong">
482 <span className="pl-3 text-sm text-faint">$</span>
483 <input
484 id={`b-${key}`}
485 inputMode="decimal"
486 defaultValue={dollarsField(value.budget?.[key])}
487 onBlur={(e) => {
488 const micros = microsFromDollars(e.target.value);
489 set({ budget: { ...value.budget, [key]: micros == null || Number.isNaN(micros) ? null : micros } });
490 }}
491 placeholder="No cap"
492 className="min-w-0 grow bg-transparent px-2 py-2 text-sm tabular-nums outline-none placeholder:text-faint"
493 autoComplete="off"
494 data-1p-ignore
495 />
496 </div>
497 </div>
498 ))}
499 </div>
500 </Section>
501 </div>
502 );
503}
504
505// ── Try it ────────────────────────────────────────────────────────────────
506
507/**
508 * A test chat with the drafted agent, as it is in the card right now:
509 * charged to the person trying it, and nothing kept once they leave.
510 */
511export function TryChat({ definition, onReset }: { definition: BuilderDefinition; onReset?: () => void }) {
512 const fetcher = useFetcher<BuilderAnswer>({ key: "agent-try" });
513 const [turns, setTurns] = useState<DraftTurn[]>([]);
514 const [text, setText] = useState("");
515 const [spent, setSpent] = useState(0);
516 const handled = useRef<unknown>(null);
517 const log = useRef<HTMLDivElement>(null);
518 const busy = fetcher.state !== "idle";
519 useEffect(() => {
520 if (fetcher.state !== "idle" || !fetcher.data || handled.current === fetcher.data) return;
521 handled.current = fetcher.data;
522 if (fetcher.data.ok && fetcher.data.intent === "try") {
523 const answer = fetcher.data;
524 setTurns((now) => [...now, { role: "assistant", content: answer.text }]);
525 setSpent((n) => n + answer.charged_micros);
526 }
527 }, [fetcher.state, fetcher.data]);
528 // The newest message in view, scrolling only the conversation, never the page.
529 useEffect(() => {
530 if (log.current) log.current.scrollTop = log.current.scrollHeight;
531 }, [turns.length, busy]);
532 const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok && fetcher.data.intent === "try" ? fetcher.data.error : null;
533 const send = () => {
534 const body = text.trim();
535 if (!body || busy) return;
536 const next: DraftTurn[] = [...turns, { role: "user", content: body }];
537 setTurns(next);
538 setText("");
539 fetcher.submit({ intent: "try", definition: JSON.stringify(definition), messages: JSON.stringify(next.slice(-24)) }, { method: "post" });
540 };
541 const name = definition.display_name || "your agent";
542 return (
543 <div className="flex h-full min-h-104 flex-col overflow-hidden rounded-2xl border border-line bg-surface">
544 <div className="flex items-center gap-2.5 border-b border-line px-4 py-3">
545 <MessageSquare size={15} className="text-faint" />
546 <div className="min-w-0 grow">
547 <p className="text-sm font-semibold">Try it</p>
548 <p className="truncate text-xs text-muted">Talk to {name} as it is drafted. Charged to you; nothing is saved.</p>
549 </div>
550 {turns.length > 0 && (
551 <Hint label="Start over">
552 <button
553 type="button"
554 aria-label="Start over"
555 onClick={() => {
556 setTurns([]);
557 onReset?.();
558 }}
559 className="flex size-8 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg"
560 >
561 <RotateCcw size={14} />
562 </button>
563 </Hint>
564 )}
565 </div>
566 <div ref={log} className="min-h-0 grow space-y-3 overflow-y-auto px-4 py-4" aria-live="polite">
567 {turns.length === 0 && (
568 <div className="flex h-full flex-col items-center justify-center py-8 text-center">
569 <AgentAvatar agent={{ handle: definition.handle, avatar_seed: definition.avatar_seed || definition.handle }} size={40} />
570 <p className="mt-3 text-sm font-medium">Ask {name} something it would get asked</p>
571 <p className="mt-1 max-w-xs text-xs text-muted">It answers from its job and voice. It has no tools or memory until you create it.</p>
572 </div>
573 )}
574 {turns.map((turn, at) =>
575 turn.role === "user" ? (
576 <div key={at} className="flex justify-end">
577 <p className="max-w-[85%] rounded-2xl rounded-br-md bg-accent/15 px-3.5 py-2 text-sm whitespace-pre-wrap text-fg">{turn.content}</p>
578 </div>
579 ) : (
580 <div key={at} className="flex gap-2.5">
581 <AgentAvatar agent={{ handle: definition.handle, avatar_seed: definition.avatar_seed || definition.handle }} size={24} />
582 <div className="min-w-0 grow text-sm [&_.prose]:text-sm">
583 <Markdown source={turn.content} />
584 </div>
585 </div>
586 ),
587 )}
588 {busy && (
589 <div className="flex items-center gap-2.5 text-xs text-muted">
590 <AgentAvatar agent={{ handle: definition.handle, avatar_seed: definition.avatar_seed || definition.handle }} size={24} />
591 {name} is typing…
592 </div>
593 )}
594 {error && <p className="rounded-lg border border-danger/40 bg-danger/10 px-3 py-2 text-xs text-danger">{error}</p>}
595 </div>
596 <form
597 className="flex items-end gap-2 border-t border-line p-2.5"
598 onSubmit={(event) => {
599 event.preventDefault();
600 send();
601 }}
602 >
603 <label htmlFor="try-message" className="sr-only">
604 Message {name}
605 </label>
606 <textarea
607 id="try-message"
608 value={text}
609 onChange={(e) => setText(e.target.value)}
610 onKeyDown={(e) => {
611 if (e.key === "Enter" && !e.shiftKey) {
612 e.preventDefault();
613 send();
614 }
615 }}
616 rows={1}
617 maxLength={4000}
618 placeholder={`Message ${name}`}
619 className="max-h-32 min-h-9 min-w-0 grow resize-none rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none placeholder:text-faint focus:border-accent-dim"
620 />
621 <button type="submit" aria-label="Send" disabled={busy || !text.trim()} className={cn(PRIMARY, "size-9 shrink-0 px-0")}>
622 <ArrowUp size={16} />
623 </button>
624 </form>
625 {spent > 0 && <p className="border-t border-line px-4 py-1.5 text-[0.6875rem] text-faint tabular-nums">Trying it has cost you {money(spent)} so far.</p>}
626 </div>
627 );
628}
629
630// ── Create ────────────────────────────────────────────────────────────────
631
632/** Saves the drafted agent: its hello is waiting in a direct message. */
An agent is on teams the way a person is: through team membership, with no team or department of its own. Add it to teams when you create it, from its profile or from the team's page; the Agents sidebar groups agents by the teams they're on, with those on none last; what an agent is told about its teammates, team budgets, skills reach, Spend's By team and the People pages all read memberships. Agents with an old team name matching a team are moved onto it once; the rest are left off a team. Templates name a role only. The agents, teams and people guides say how.633export function CreateDraft({ definition, teams = [], onEditAll }: { definition: BuilderDefinition; teams?: string[]; onEditAll: () => void }) {
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.634 const navigation = useNavigation();
635 const busy = navigation.state !== "idle" && navigation.formData?.get("intent") === "create_draft";
636 const ready = definition.display_name.trim() && definition.instructions.trim() && definition.handle.trim();
637 return (
638 <Form method="post" className="flex flex-wrap items-center justify-end gap-2">
639 <input type="hidden" name="intent" value="create_draft" />
640 <input type="hidden" name="definition" value={JSON.stringify(definition)} />
An agent is on teams the way a person is: through team membership, with no team or department of its own. Add it to teams when you create it, from its profile or from the team's page; the Agents sidebar groups agents by the teams they're on, with those on none last; what an agent is told about its teammates, team budgets, skills reach, Spend's By team and the People pages all read memberships. Agents with an old team name matching a team are moved onto it once; the rest are left off a team. Templates name a role only. The agents, teams and people guides say how.641 {teams.map((slug) => (
642 <input key={slug} type="hidden" name="teams" value={slug} />
643 ))}
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.644 <button type="button" onClick={onEditAll} className={QUIET}>
645 Edit all fields
646 </button>
647 <button type="submit" disabled={busy || !ready} className={PRIMARY}>
648 <Check size={15} />
649 {busy ? "Creating…" : `Create ${definition.display_name.trim() || "agent"}`}
650 </button>
651 </Form>
652 );
653}
654
655// ── Change it in words ────────────────────────────────────────────────────
656
657/**
658 * "Tell <name> what to change": the change is drafted, shown field by
659 * field with the job as a diff, and saved as a new version only when the
660 * person says so.
661 */
662export function RedraftBox({ agent }: { agent: WorkspaceAgent }) {
663 const fetcher = useFetcher<BuilderAnswer>({ key: `redraft:${agent.id}` });
664 const save = useFetcher<{ ok?: boolean; saved?: boolean; errors?: Record<string, string> }>({ key: `redraft-save:${agent.id}` });
665 const [text, setText] = useState("");
666 const [shown, setShown] = useState<AgentRedraft | null>(null);
667 const handled = useRef<unknown>(null);
668 useEffect(() => {
669 if (fetcher.state !== "idle" || !fetcher.data || handled.current === fetcher.data) return;
670 handled.current = fetcher.data;
671 if (fetcher.data.ok && fetcher.data.intent === "redraft") setShown(fetcher.data.redraft);
672 }, [fetcher.state, fetcher.data]);
673 useEffect(() => {
674 if (save.state === "idle" && save.data?.saved) {
675 setShown(null);
676 setText("");
677 }
678 }, [save.state, save.data]);
679 const busy = fetcher.state !== "idle";
680 const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok && fetcher.data.intent === "redraft" ? fetcher.data.error : null;
681 const saveError = save.state === "idle" && save.data && !save.data.saved ? (save.data.errors?.form ?? save.data.errors?.handle ?? null) : null;
682 const skillName = (id: string) => FOUNDATIONAL_SKILLS.find((s) => s.id === id)?.name ?? id;
683 const rows = shown ? changeRows(agent, shown.changes, skillName) : [];
684 return (
685 <section aria-labelledby="redraft" className="mb-10 rounded-2xl border border-line bg-surface">
686 <fetcher.Form method="post" className="p-4 sm:p-5">
687 <input type="hidden" name="intent" value="redraft" />
688 <h2 id="redraft" className="flex items-center gap-2 text-sm font-semibold">
689 <Wand2 size={15} className="text-accent" />
690 Tell {agent.display_name} what to change
691 </h2>
692 <p className="mt-1 text-xs text-muted">Say it in your own words. You'll see the change before it's saved as version {agent.version + 1}. Drafting it is charged to you.</p>
693 <div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-end">
694 <label htmlFor="redraft-request" className="sr-only">
695 What should change
696 </label>
697 <textarea
698 id="redraft-request"
699 name="request"
700 value={text}
701 onChange={(e) => setText(e.target.value)}
702 onKeyDown={(e) => {
703 if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && text.trim().length >= 3) e.currentTarget.form?.requestSubmit();
704 }}
705 rows={2}
706 maxLength={1000}
707 placeholder={`Answer in Spanish, and keep replies under five sentences`}
708 className={`${FIELD} grow resize-y`}
709 />
710 <button type="submit" disabled={busy || text.trim().length < 3} className={cn(PRIMARY, "shrink-0")}>
711 <Sparkles size={14} className={busy ? "animate-pulse motion-reduce:animate-none" : ""} />
712 {busy ? "Drafting…" : "Draft the change"}
713 </button>
714 </div>
715 {error && <p className="mt-3 rounded-lg border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</p>}
716 </fetcher.Form>
717 {shown && (
718 <div className="border-t border-line p-4 sm:p-5">
719 <p className="text-sm">
720 <span className="font-medium">{shown.summary}</span>{" "}
721 <span className="text-muted">
722 {rows.length} {rows.length === 1 ? "change" : "changes"} to version {shown.from_version}.
723 </span>
724 </p>
725 <ChangeList rows={rows} />
726 {saveError && <p className="mt-3 rounded-lg border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger">{saveError}</p>}
727 <save.Form method="post" className="mt-4 flex flex-wrap items-center justify-end gap-2">
728 <input type="hidden" name="intent" value="apply" />
729 <input type="hidden" name="changes" value={JSON.stringify(shown.changes)} />
730 <input type="hidden" name="from_version" value={shown.from_version} />
731 <button type="button" onClick={() => setShown(null)} className={QUIET}>
732 Discard
733 </button>
734 <button type="submit" disabled={save.state !== "idle"} className={PRIMARY}>
735 <Check size={15} />
736 {save.state !== "idle" ? "Saving…" : `Save as version ${agent.version + 1}`}
737 </button>
738 </save.Form>
739 </div>
740 )}
741 </section>
742 );
743}
744
745/** Each field a change touches, before and after; the job and duties as a line diff. */
746export function ChangeList({ rows }: { rows: ChangeRow[] }) {
747 return (
748 <dl className="mt-3 divide-y divide-line overflow-hidden rounded-lg border border-line bg-bg">
749 {rows.map((row) => (
750 <div key={row.field} className="px-3 py-2.5">
751 <dt className="text-xs font-medium text-faint">{row.label}</dt>
752 {row.lines ? (
753 <dd className="mt-1.5 max-h-80 overflow-auto rounded-md border border-line font-mono text-[0.75rem] leading-relaxed">
754 {row.lines.map((line, at) => (
755 <div
756 key={at}
757 className={cn(
758 "flex gap-2 px-2 whitespace-pre-wrap",
759 line.kind === "added" && "bg-success/10 text-success",
760 line.kind === "removed" && "bg-danger/10 text-danger line-through decoration-danger/40",
761 line.kind === "same" && "text-muted",
762 )}
763 >
764 <span aria-hidden="true" className="w-3 shrink-0 select-none">
765 {line.kind === "added" ? "+" : line.kind === "removed" ? "−" : " "}
766 </span>
767 <span className="sr-only">{line.kind === "added" ? "Added: " : line.kind === "removed" ? "Removed: " : ""}</span>
768 <span className="min-w-0">{line.text || " "}</span>
769 </div>
770 ))}
771 </dd>
772 ) : (
773 <dd className="mt-1 flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-sm">
774 <span className="text-muted line-through decoration-line-strong">{row.before}</span>
775 <span aria-hidden="true" className="text-faint">
776 →
777 </span>
778 <span className="sr-only">becomes</span>
779 <span className="font-medium text-fg">{row.after}</span>
780 </dd>
781 )}
782 </div>
783 ))}
784 </dl>
785 );
786}
787
788/** Whose personal agent it is, and, for owners, making it the workspace's. */
789export function PersonalNotice({ agent, owner, mine }: { agent: WorkspaceAgent; owner: boolean; mine: boolean }) {
790 const navigation = useNavigation();
791 const [asking, setAsking] = useState(false);
792 const busy = navigation.state !== "idle" && navigation.formData?.get("intent") === "promote";
793 return (
794 <section aria-labelledby="personal" className="mb-10 rounded-2xl border border-line bg-surface p-4 sm:p-5">
795 <h2 id="personal" className="flex items-center gap-2 text-sm font-semibold">
796 <Lock size={14} className="text-faint" />
797 {mine ? "Your personal agent" : `@${agent.personal_owner ?? "a member"}'s personal agent`}
798 </h2>
799 <p className="mt-1 text-sm text-muted">
800 {mine
801 ? "Only you can talk to it, in your direct message with it, and it spends from your budget. It isn't in channels, and other agents don't hand it work."
802 : `Only @${agent.personal_owner ?? "its member"} talks to it, and it spends from their budget. As an owner you can archive it, or make it a workspace agent everyone can talk to.`}
803 </p>
804 {owner && (
805 <div className="mt-4">
806 {asking ? (
807 <Form method="post" className="space-y-3">
808 <input type="hidden" name="intent" value="promote" />
809 <p className="text-sm text-fg-soft">
810 {agent.display_name} becomes a workspace agent with the same handle, job and every version so far. What it remembers from{" "}
811 {mine ? "your" : `@${agent.personal_owner}'s`} direct messages stays behind with the personal agent, which is archived.
812 </p>
813 <div className="flex flex-wrap gap-2">
814 <button type="submit" disabled={busy} className={PRIMARY}>
815 <Users size={14} />
816 {busy ? "Promoting…" : `Promote ${agent.display_name}`}
817 </button>
818 <button type="button" onClick={() => setAsking(false)} className={QUIET}>
819 Cancel
820 </button>
821 </div>
822 </Form>
823 ) : (
824 <button type="button" onClick={() => setAsking(true)} className={QUIET}>
825 <Users size={14} />
826 Promote to a workspace agent…
827 </button>
828 )}
829 </div>
830 )}
831 </section>
832 );
833}