Skip to content

Commit d055cea

Browse files
committed
Write a skill in the conversation, not by retyping it into a form
1 parent 257c128 commit d055cea

13 files changed

Lines changed: 1341 additions & 2 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ Leave `EMBEDDED_POSTGRES` off and set `DATABASE_URL` to point at a database you
154154
- **Bring your own agent**: any AG-UI endpoint is a Bot, on a framework or hand-written. Endpoints are validated with the same target checks used for browser navigation, and an auth header is stored write-only.
155155
- **Components instead of prose**: compiled React components live in `app/src/components/gallery/`, sandboxed ones are authored in `/admin/playground` and published with no deployment. Every call asks the server whether the component exists, is published, and is not withheld from that Bot. Data functions are granted per component.
156156
- **Governed MCP**: Google Drive and Notion ship in the catalogue, reached as the person asking. The catalogue carries only vendors this deployment stands behind, so adding one is a review of that vendor. Custom servers must pass URL checks; unknown tools and custom-server tools are treated as writes, and a catalogue tool the server advertises but does not name as a write classifies as a read. A Bot is told which connectors exist here and which it holds, so it says it has not been granted one rather than browsing to the vendor's website.
157-
- **Skills are instructions, not capabilities**: personal skills attach only to Bots their author owns, deployment skills are admin-owned, and both are invoked with `/` in the composer.
157+
- **Skills are instructions, not capabilities**: personal skills attach only to Bots their author owns, deployment skills are admin-owned, and both are invoked with `/` in the composer. A Bot granted the shipped `skill-creator` skill can write one with you in the conversation, and saves it only when you press the button on the card.
158158
- **Sign in with what your company already has**: Google, Microsoft or Okta from the environment, or a company's own SAML or OpenID Connect provider registered while the deployment runs and routed by email domain. Any one turns sign-in on; several may be configured at once.
159159
- **Decide who gets in**: `/admin/people` lists everybody who has signed in, promotes and demotes them, and removes access, which ends the session they are using and stops the next sign-in. Every change is on the audit trail.
160160
- **An audit trail you can read**: `/admin/audit` lists what was permitted, what was refused and what failed, and every refusal carries the rule that caused it.
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import { Link } from "@tanstack/react-router";
2+
import { useEffect, useRef, useState } from "react";
3+
import { Badge, GalleryFrame } from "@/components/gallery/frame";
4+
import { Button } from "@/components/ui/button";
5+
import type { SkillFormValues } from "@/lib/skills/form";
6+
import {
7+
checkProposal,
8+
type ProposedSkill,
9+
skillCardAnswer,
10+
wasSaved,
11+
} from "@/lib/skills/proposal";
12+
13+
/**
14+
* The skill a Bot has written, put in front of the person before it is saved.
15+
*
16+
* WHY THERE IS A CARD AT ALL, when a skill adds no capability and anybody signed in may write one.
17+
* Two reasons, and neither is about permission. The first is authorship: this is a named thing that
18+
* will appear in everybody's `/` menu with somebody's name on it, and a person should read the
19+
* instruction their Bot drafted before it starts running on their behalf. The second is the slug:
20+
* saving is how an edit is spelled, so an unattended save can replace a skill somebody is using.
21+
*
22+
* So the whole tool is this card. There is no handler behind it — the run suspends here, and nothing
23+
* is written until a button is pressed.
24+
*/
25+
26+
export type ProposedSkillCardProps = {
27+
/** Partial while the model is still streaming the arguments. */
28+
args: Partial<ProposedSkill>;
29+
/**
30+
* What this slug already names here, when it names something.
31+
*
32+
* Drives the wording rather than the outcome — "Replace" instead of "Create", and whose it is.
33+
* The server decides whether the replacement is allowed and says so in its own words.
34+
*/
35+
replaces?: { title: string; ownership: string };
36+
/** Answering resumes the Bot. Absent while streaming, and once this card has been answered. */
37+
respond?: (result: unknown) => Promise<void>;
38+
/** The recorded answer, once there is one. Completed cards show it instead of controls. */
39+
result?: string;
40+
save: (values: SkillFormValues) => Promise<void>;
41+
};
42+
43+
export function ProposedSkillCard({
44+
args,
45+
replaces,
46+
respond,
47+
result,
48+
save,
49+
}: ProposedSkillCardProps) {
50+
const [sending, setSending] = useState<"save" | "decline" | null>(null);
51+
/**
52+
* A refusal from the server, kept on the card rather than answered with.
53+
*
54+
* The run stays suspended, because the two things worth doing next both need it to be — pressing
55+
* Create again after connecting the connector the refusal named, or declining. Answering the tool
56+
* with the error would end the turn and leave the person re-typing their request.
57+
*/
58+
const [refusal, setRefusal] = useState<string | null>(null);
59+
60+
if (result !== undefined) {
61+
return (
62+
<GalleryFrame
63+
action={<Badge tone="neutral">Done</Badge>}
64+
title={titleFor(args.slug, replaces)}
65+
>
66+
<p className="text-sm">{result}</p>
67+
{args.slug && wasSaved(result) ? (
68+
<PutItOnABot slug={args.slug} />
69+
) : null}
70+
</GalleryFrame>
71+
);
72+
}
73+
74+
if (!respond) {
75+
return (
76+
<GalleryFrame title={titleFor(args.slug, replaces)}>
77+
<p className="text-sm text-muted-foreground">Writing the skill…</p>
78+
</GalleryFrame>
79+
);
80+
}
81+
82+
const checked = checkProposal(args);
83+
if (!checked.ok) {
84+
return <Unwritable problems={checked.problems} respond={respond} />;
85+
}
86+
const values = checked.values;
87+
88+
const create = async () => {
89+
setSending("save");
90+
setRefusal(null);
91+
try {
92+
await save(values);
93+
} catch (cause) {
94+
// Left on the card with the buttons still live. See `refusal` above.
95+
setSending(null);
96+
setRefusal(
97+
cause instanceof Error ? cause.message : "The skill was not saved.",
98+
);
99+
return;
100+
}
101+
await answer(respond, skillCardAnswer.saved(values.slug));
102+
};
103+
104+
const decline = async () => {
105+
setSending("decline");
106+
await answer(respond, skillCardAnswer.declined());
107+
};
108+
109+
return (
110+
<GalleryFrame
111+
action={<Badge tone="caution">Waiting on you</Badge>}
112+
caption={
113+
replaces
114+
? `This replaces ${replaces.title}, which is ${replaces.ownership}.`
115+
: "Nothing is saved until you press the button."
116+
}
117+
title={titleFor(values.slug, replaces)}
118+
>
119+
<dl className="grid grid-cols-[minmax(0,7rem)_1fr] gap-x-4 gap-y-1.5 text-sm">
120+
<dt className="truncate text-muted-foreground">Command</dt>
121+
<dd className="min-w-0 break-words font-mono">/{values.slug}</dd>
122+
<dt className="truncate text-muted-foreground">Title</dt>
123+
<dd className="min-w-0 break-words">{values.title}</dd>
124+
{values.summary ? (
125+
<>
126+
<dt className="truncate text-muted-foreground">One-liner</dt>
127+
<dd className="min-w-0 break-words">{values.summary}</dd>
128+
</>
129+
) : null}
130+
{values.tools.length > 0 ? (
131+
<>
132+
<dt className="truncate text-muted-foreground">Needs</dt>
133+
<dd className="min-w-0 break-words font-mono text-xs">
134+
{values.tools.join(", ")}
135+
</dd>
136+
</>
137+
) : null}
138+
</dl>
139+
140+
<p className="mt-3 text-muted-foreground text-xs">Instructions</p>
141+
{/* Scrolled rather than clamped: this is the part worth reading before agreeing to it. */}
142+
<p className="mt-1 max-h-56 overflow-y-auto whitespace-pre-wrap rounded-md border border-border bg-muted/40 px-3 py-2 text-sm">
143+
{values.instructions}
144+
</p>
145+
146+
{values.tools.length > 0 ? (
147+
<p className="mt-2 text-muted-foreground text-xs">
148+
Naming a tool grants nothing. A Bot is offered these only if an
149+
administrator has already granted them to it.
150+
</p>
151+
) : null}
152+
153+
{refusal ? (
154+
<p className="mt-3 text-destructive text-sm" role="alert">
155+
{refusal}
156+
</p>
157+
) : null}
158+
159+
<div className="mt-4 flex gap-2">
160+
<Button
161+
disabled={Boolean(sending)}
162+
onClick={() => void create()}
163+
size="sm"
164+
>
165+
{sending === "save"
166+
? "Saving…"
167+
: replaces
168+
? "Replace it"
169+
: "Create it"}
170+
</Button>
171+
<Button
172+
disabled={Boolean(sending)}
173+
onClick={() => void decline()}
174+
size="sm"
175+
variant="outline"
176+
>
177+
{sending === "decline" ? "…" : "Don't save"}
178+
</Button>
179+
</div>
180+
</GalleryFrame>
181+
);
182+
}
183+
184+
function titleFor(
185+
slug: string | undefined,
186+
replaces: { title: string } | undefined,
187+
): string {
188+
const name = slug ? `/${slug}` : "a new skill";
189+
return replaces ? `Replace ${name}` : `Create ${name}`;
190+
}
191+
192+
/** Where the remaining step happens. A skill on no Bot is inert, and saying so beats implying done. */
193+
function PutItOnABot({ slug }: { slug: string }) {
194+
return (
195+
<Link
196+
className="mt-2 inline-block text-sm underline underline-offset-4"
197+
search={{ edit: slug }}
198+
to="/skills"
199+
>
200+
Put it on a Bot
201+
</Link>
202+
);
203+
}
204+
205+
/**
206+
* A proposal that cannot be saved as written.
207+
*
208+
* Answered rather than shown as a question, because there is nothing for the person to decide: the
209+
* fields are wrong in a way the model can fix, and the problems name their field so it can. The card
210+
* still appears, so the transcript does not have a silent gap where a skill was nearly written.
211+
*/
212+
function Unwritable({
213+
problems,
214+
respond,
215+
}: {
216+
problems: string[];
217+
respond: (result: unknown) => Promise<void>;
218+
}) {
219+
/**
220+
* Answered once, guarded by a ref rather than by the dependency list.
221+
*
222+
* `problems` is rebuilt from the arguments on every render and the grant queries behind this card
223+
* poll, so a value-equal array arrives as a new identity every few seconds. Left to the deps, this
224+
* would answer the same tool call again on each of them.
225+
*/
226+
const answered = useRef(false);
227+
useEffect(() => {
228+
if (answered.current) return;
229+
answered.current = true;
230+
void answer(respond, skillCardAnswer.unwritable(problems));
231+
}, [problems, respond]);
232+
233+
return (
234+
<GalleryFrame
235+
action={<Badge tone="negative">Not saved</Badge>}
236+
title="Skill"
237+
>
238+
<p className="text-sm">
239+
The Bot's draft was not a valid skill, so nothing was saved. It has been
240+
told what to fix.
241+
</p>
242+
</GalleryFrame>
243+
);
244+
}
245+
246+
/** Resuming a run that is no longer there is not a failure worth reporting: nothing is left to answer. */
247+
function answer(
248+
respond: (result: unknown) => Promise<void>,
249+
sentence: string,
250+
): Promise<void> {
251+
return respond(sentence).catch(() => {});
252+
}

app/src/lib/copilot/provider.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { GalleryTools } from "./gallery-tools";
99
import { GENERATIVE_UI_DESIGN_SKILL } from "./generative-ui";
1010
import { HandoffTool } from "./handoff-tool";
1111
import { SandboxedTools } from "./sandboxed-tools";
12+
import { SkillTools } from "./skill-tools";
1213

1314
/**
1415
* The CopilotKit client, wrapped once for the whole authenticated app.
@@ -62,6 +63,8 @@ export function CopilotProvider({ children }: { children: ReactNode }) {
6263
<GalleryTools />
6364
{/* Browser-authored components use the same component grants as the compiled gallery. */}
6465
<SandboxedTools />
66+
{/* Offered only on a Bot holding the skill-creator skill; see skill-tools.tsx. */}
67+
<SkillTools />
6568
{children}
6669
</ActiveBotProvider>
6770
</CopilotKitProvider>

0 commit comments

Comments
 (0)