From d4f74374335fcc775179420ad0d04ee7aa98948c Mon Sep 17 00:00:00 2001
From: David McKay
Date: Wed, 19 Aug 2026 20:44:06 -0700
Subject: [PATCH 1/2] Show an administrator what a component grant actually
draws
The Components page listed a name, an identifier and a paragraph, and asked somebody to decide from
that whether a Bot should be allowed to answer with it. The one question they arrived with, what
does this put in front of a person, was the one thing the page could not answer.
Each component now introduces itself. `See it` draws the real component, from props it carries
beside its own definition, so a new component cannot be added without deciding how it shows itself
and a preview cannot drift from what it is previewing.
Closed by default. This is a list an administrator scans, and thirteen components drawn at once is a
page nobody reads.
Props rather than tool arguments, because for a component that suspends a run those are not the same
thing: it is handed the whole interaction, `{ status, args, respond }`, and would throw on arguments
alone. A component with nothing to preview says why rather than showing an empty frame, which is the
honest answer for the two that draw this deployment's own records and have nothing to draw until a
Bot asks.
Taken from CopilotKit/openkai. Only the applicable half: the preview and the admin disclosure, not
the visitor demo it was built for.
---
app/src/components/gallery/cards.tsx | 42 ++++++
app/src/components/gallery/charts.tsx | 37 +++++
app/src/components/gallery/decisions.tsx | 37 +++++
app/src/components/gallery/preview.tsx | 145 ++++++++++++++++++++
app/src/components/gallery/quote.tsx | 6 +
app/src/lib/copilot/gallery-registry.ts | 12 ++
app/src/routes/_authed/admin/components.tsx | 18 +++
7 files changed, 297 insertions(+)
create mode 100644 app/src/components/gallery/preview.tsx
diff --git a/app/src/components/gallery/cards.tsx b/app/src/components/gallery/cards.tsx
index 24c51f2..8e60b1d 100644
--- a/app/src/components/gallery/cards.tsx
+++ b/app/src/components/gallery/cards.tsx
@@ -230,6 +230,16 @@ export const GALLERY: GalleryComponent[] = [
"Show one thing and its fields, an order, a person, a ticket. Use instead of describing a record in prose.",
parameters: RecordCardProps,
Component: RecordCard as GalleryComponent["Component"],
+ preview: {
+ title: "Invoice 2043",
+ subtitle: "Northwind Traders",
+ status: "Approved",
+ fields: [
+ { label: "Amount", value: "$4,280.00" },
+ { label: "Raised", value: "12 March" },
+ { label: "Owner", value: "Priya Raman" },
+ ],
+ },
confirmation: "The record is now on screen for the person.",
},
{
@@ -240,6 +250,24 @@ export const GALLERY: GalleryComponent[] = [
"Show up to six headline figures, each with an optional movement. Use for a summary somebody reads at a glance.",
parameters: MetricsCardProps,
Component: MetricsCard as GalleryComponent["Component"],
+ preview: {
+ title: "This month",
+ metrics: [
+ {
+ label: "Revenue",
+ value: "$412k",
+ change: "+12% on last month",
+ changeTone: "positive",
+ },
+ { label: "Open deals", value: "38" },
+ {
+ label: "Churn",
+ value: "1.4%",
+ change: "+0.3pt",
+ changeTone: "caution",
+ },
+ ],
+ },
confirmation: "The figures are now on screen for the person.",
},
{
@@ -250,6 +278,14 @@ export const GALLERY: GalleryComponent[] = [
"Show a list of things and which are done. Reporting only, the person cannot tick these, so do not use it to ask for anything.",
parameters: ChecklistCardProps,
Component: ChecklistCard as GalleryComponent["Component"],
+ preview: {
+ title: "Before the release",
+ items: [
+ { text: "Migrations applied", done: true },
+ { text: "Changelog written", done: true },
+ { text: "Load test", done: false, note: "Waiting on staging" },
+ ],
+ },
confirmation: "The checklist is now on screen for the person.",
},
{
@@ -260,6 +296,12 @@ export const GALLERY: GalleryComponent[] = [
"Show a headline, a short explanation and optional supporting points. Use instead of writing several paragraphs of prose.",
parameters: NoticeCardProps,
Component: NoticeCard as GalleryComponent["Component"],
+ preview: {
+ title: "Certificate expires in 30 days",
+ body: "The checkout certificate has an owner now, and this is the first of the new alerts.",
+ tone: "caution",
+ points: ["Owner: Platform", "Renews automatically once approved"],
+ },
confirmation: "The notice is now on screen for the person.",
},
];
diff --git a/app/src/components/gallery/charts.tsx b/app/src/components/gallery/charts.tsx
index 6ea488e..6a10398 100644
--- a/app/src/components/gallery/charts.tsx
+++ b/app/src/components/gallery/charts.tsx
@@ -405,6 +405,15 @@ export const GALLERY: GalleryComponent[] = [
"Show values as a bar chart. Use when comparing a handful of named things, teams, months, categories. Not for a trend over time, which is showLineChart.",
parameters: BarChartProps,
Component: BarChartCard as GalleryComponent["Component"],
+ preview: {
+ title: "Revenue by team",
+ caption: "Sales leads, and Engineering is closing the gap.",
+ points: [
+ { label: "Sales", value: 120 },
+ { label: "Engineering", value: 80 },
+ { label: "Support", value: 45 },
+ ],
+ },
confirmation: "The bar chart is now on screen for the person.",
},
{
@@ -415,6 +424,14 @@ export const GALLERY: GalleryComponent[] = [
"Show how a whole is divided, as a donut with a legend. Use only when the parts sum to something meaningful, and prefer a bar chart above about six slices.",
parameters: PieChartProps,
Component: PieChartCard as GalleryComponent["Component"],
+ preview: {
+ title: "Where the month went",
+ points: [
+ { label: "Build", value: 48 },
+ { label: "Support", value: 26 },
+ { label: "Meetings", value: 26 },
+ ],
+ },
confirmation: "The donut chart is now on screen for the person.",
},
{
@@ -425,6 +442,12 @@ export const GALLERY: GalleryComponent[] = [
"Show one or more series over an ordered axis, usually time. Every series must have one value per label.",
parameters: LineChartProps,
Component: LineChartCard as GalleryComponent["Component"],
+ preview: {
+ title: "Signups",
+ caption: "Six weeks, one release.",
+ labels: ["W1", "W2", "W3", "W4", "W5", "W6"],
+ series: [{ name: "Signups", values: [120, 180, 160, 240, 300, 420] }],
+ },
confirmation: "The line chart is now on screen for the person.",
},
{
@@ -435,6 +458,12 @@ export const GALLERY: GalleryComponent[] = [
"The same as showLineChart with the area under each line filled. Use for volume or accumulation rather than for a rate.",
parameters: AreaChartProps,
Component: AreaChartCard as GalleryComponent["Component"],
+ preview: {
+ title: "Storage used",
+ caption: "Growing steadily since the migration.",
+ labels: ["Jan", "Feb", "Mar", "Apr", "May"],
+ series: [{ name: "TB", values: [12, 19, 26, 31, 44] }],
+ },
confirmation: "The area chart is now on screen for the person.",
},
{
@@ -445,6 +474,14 @@ export const GALLERY: GalleryComponent[] = [
"Show values against their targets as progress bars. Use for 'are we there yet' questions, budget spent against budget, done against planned.",
parameters: ProgressChartProps,
Component: ProgressChartCard as GalleryComponent["Component"],
+ preview: {
+ title: "Migration to the new runtime",
+ caption: "Two services left.",
+ points: [
+ { label: "Services moved", value: 18, target: 20 },
+ { label: "Tests ported", value: 240, target: 240 },
+ ],
+ },
confirmation: "The progress chart is now on screen for the person.",
},
];
diff --git a/app/src/components/gallery/decisions.tsx b/app/src/components/gallery/decisions.tsx
index 539d18e..208d0f3 100644
--- a/app/src/components/gallery/decisions.tsx
+++ b/app/src/components/gallery/decisions.tsx
@@ -254,6 +254,23 @@ export const GALLERY: GalleryComponent[] = [
"Ask the person to approve or decline something, and WAIT for their answer. Use before doing anything you cannot undo, spending money, sending a message, changing a record. You are given their decision and any reason they typed.",
parameters: ApprovalCardProps,
Component: ApprovalCard as GalleryComponent["Component"],
+ preview: {
+ // The whole interaction, because that is what this component is handed: it suspends a run,
+ // so its arguments arrive wrapped in the state of the decision it is waiting on.
+ status: "executing",
+ args: {
+ title: "Refund this order?",
+ summary:
+ "The customer was charged twice for the same order and the second charge has not settled.",
+ details: [
+ { label: "Amount", value: "$128.40" },
+ { label: "Customer", value: "Northwind Traders" },
+ { label: "Order", value: "2043" },
+ ],
+ approveLabel: "Refund",
+ },
+ respond: async () => {},
+ },
},
{
name: "askChoice",
@@ -263,5 +280,25 @@ export const GALLERY: GalleryComponent[] = [
"Ask the person to pick one of several options, and WAIT for their answer. Use when you cannot sensibly guess which one they meant. You are given the id of the option they chose.",
parameters: ChoiceCardProps,
Component: ChoiceCard as GalleryComponent["Component"],
+ preview: {
+ status: "executing",
+ args: {
+ title: "Which environment should this go to?",
+ summary: "The build is green and nothing else is queued.",
+ options: [
+ {
+ id: "staging",
+ label: "Staging",
+ description: "Safe, and reversible",
+ },
+ {
+ id: "production",
+ label: "Production",
+ description: "Live customers",
+ },
+ ],
+ },
+ respond: async () => {},
+ },
},
];
diff --git a/app/src/components/gallery/preview.tsx b/app/src/components/gallery/preview.tsx
new file mode 100644
index 0000000..72d260d
--- /dev/null
+++ b/app/src/components/gallery/preview.tsx
@@ -0,0 +1,145 @@
+import { GALLERY_COMPONENTS } from "@/lib/copilot/gallery-registry";
+
+/**
+ * The answers a Bot can give, drawn rather than described.
+ *
+ * Somebody meeting this product asks one question first: what does an answer look like? A list of
+ * names answers it for nobody, and a screenshot ages the moment a component changes. So every tile
+ * is the real component, rendered from the arguments it ships as its own introduction: the actual
+ * donut, the actual line chart, the actual card that waits for an answer.
+ *
+ * What is shown is what the Bot was granted, not what the build contains. A component can be
+ * unpublished, or held back from this Bot, and showing it anyway would be advertising something the
+ * person cannot have.
+ *
+ * The tiles are quiet on purpose. Every one of them is a different shape, colour and density, and
+ * the eye should land on those rather than on frames we drew around them: one hairline, one label,
+ * and the component left to be the interesting thing.
+ */
+
+/**
+ * Charts first.
+ *
+ * They answer "is any of this visual" in one glance. A rail that opens with two decision cards and
+ * a paragraph makes somebody scroll to find that out, and most people will not.
+ */
+const ORDER: Record = { chart: 0, card: 1, decision: 2 };
+
+export function GalleryPreview({
+ /**
+ * What this Bot may actually answer with, as the deployment reports it.
+ *
+ * Undefined while it is still being asked for, which is not the same as an empty list: one is
+ * "not yet" and the other is "none", and they must not look alike.
+ */
+ available,
+ /**
+ * How many across. One by default, and that is not timidity.
+ *
+ * These are conversation-sized components: a record card has a label column and a value column, a
+ * chart has an axis. Give them half of a narrow rail and the values wrap to one character per
+ * line, which is not a smaller version of the component but a broken one. Two-up is for a page
+ * with a page's width.
+ */
+ columns = 1,
+}: {
+ available?: readonly { name: string; description: string }[];
+ columns?: 1 | 2;
+}) {
+ if (!available) {
+ return (
+
+ );
+}
+
+/**
+ * One component, drawn from its own sample props.
+ *
+ * Separate from the gallery because the two surfaces want different framing: the gallery labels
+ * every tile, and an admin page already has the name and the description above it.
+ */
+export function PreviewOf({ name }: { name: string }) {
+ const component = GALLERY_COMPONENTS.find((entry) => entry.name === name);
+ if (!component) return null;
+ if (!component.preview) {
+ /*
+ * Said rather than faked. This one draws whatever the deployment's own records hold, and
+ * inventing numbers for a tile would be the single dishonest thing on a page whose whole
+ * argument is that answers come from somewhere real.
+ */
+ return (
+
+ Draws this deployment's own records, so it has nothing to show until a
+ Bot asks for it.
+
+ );
+ }
+ return (
+ /*
+ * Not interactive: a decision card here has nothing to decide, and a button that does nothing
+ * is worse than one that is plainly a picture.
+ */
+
+
+
+ );
+}
+
+/**
+ * The first sentence of what the model is told.
+ *
+ * The full description is written for a model deciding whether to call this, and carries the
+ * comparisons that matter to that decision. A person reading a gallery wants to know what it is.
+ */
+function firstSentence(description: string): string {
+ const end = description.indexOf(". ");
+ return end === -1 ? description : description.slice(0, end + 1);
+}
diff --git a/app/src/components/gallery/quote.tsx b/app/src/components/gallery/quote.tsx
index d912c43..837e68d 100644
--- a/app/src/components/gallery/quote.tsx
+++ b/app/src/components/gallery/quote.tsx
@@ -55,6 +55,12 @@ export const GALLERY: GalleryComponent[] = [
"Show a quotation with its attribution. Use when the exact words matter, something a person said, or a line from a document you were given.",
parameters: QuoteCardProps,
Component: QuoteCard as GalleryComponent["Component"],
+ preview: {
+ quote:
+ "Meals under $75 need no receipt. Anything above needs one, and anything above $500 needs your manager before you spend it.",
+ attribution: "the expense policy",
+ context: "Last changed in March.",
+ },
confirmation: "The quotation is now on screen for the person.",
},
];
diff --git a/app/src/lib/copilot/gallery-registry.ts b/app/src/lib/copilot/gallery-registry.ts
index 465bd11..152f774 100644
--- a/app/src/lib/copilot/gallery-registry.ts
+++ b/app/src/lib/copilot/gallery-registry.ts
@@ -28,6 +28,18 @@ export type GalleryComponent = {
*/
description: string;
parameters: ToolParameters;
+ /**
+ * The props that show this component at its best, for anywhere it is displayed rather than called.
+ *
+ * A gallery of names tells somebody nothing: the question they arrived with is "what does an
+ * answer from this thing look like", and only the component itself answers that. Kept beside the
+ * component so a new one cannot be added without deciding how it introduces itself.
+ *
+ * Props rather than tool arguments, because they are not always the same thing: a component that
+ * suspends the run is handed the whole interaction, `{ status, args, respond }`, and would crash
+ * on arguments alone.
+ */
+ preview?: Record;
Component: (props: Record) => ReactElement | null;
/**
* The line the model is given once it is on screen. Ignored for a `decision`, whose result is the
diff --git a/app/src/routes/_authed/admin/components.tsx b/app/src/routes/_authed/admin/components.tsx
index 27c40ea..e927f0f 100644
--- a/app/src/routes/_authed/admin/components.tsx
+++ b/app/src/routes/_authed/admin/components.tsx
@@ -6,6 +6,7 @@ import {
PageSection,
PageShell,
} from "@/components/layout/page-shell";
+import { PreviewOf } from "@/components/gallery/preview";
import { StaggerItem } from "@/components/layout/stagger";
import { Button } from "@/components/ui/button";
import {
@@ -255,6 +256,23 @@ function ComponentRow({
Last changed {new Date(component.updatedAt).toLocaleString()}
{component.updatedBy ? ` by ${component.updatedBy}` : null}
+ {/*
+ * What granting this actually puts in front of somebody.
+ *
+ * Closed by default: this page is a list an administrator scans, and thirteen components
+ * drawn at once is a page nobody reads. Open, it is the real component, which is the only
+ * thing that answers "should this Bot have it".
+ */}
+ {RENDERABLE_NAMES.has(component.name) ? (
+
+
+ See it
+
+
+
+
+
+ ) : null}
From 2a407e1f35ca02280b86b1c28f8546d2b0f61a17 Mon Sep 17 00:00:00 2001
From: David McKay
Date: Wed, 19 Aug 2026 21:16:28 -0700
Subject: [PATCH 2/2] Make the admin screens say what they are asking
Three places where the interface named a thing and then relied on somebody already knowing what it
was.
"May read" sat under a row of Bot names, in the same shape as the row above it, and is a different
kind of grant entirely: the row above decides which Bots may draw a component, this decides what
that component may go and fetch in order to draw itself. Read as one switch, an administrator grants
a component to every Bot believing that is all they did. Both rows now say which question they are
answering, and the Bot row says what switching one off actually does, which is that the Bot is never
told the component exists rather than being told no.
The boundary's deny list said "It may never" and offered a CEL box with one example in it. Nothing
named the vocabulary, so writing a second rule meant reading the source. The section now lists what
an expression may ask about, and says the thing an operator most needs to know before typing:
a rule that cannot be evaluated counts as a match, so a mistyped deny refuses rather than quietly
permitting what it was meant to forbid.
"changes things" beside a tool reads as a description of the tool. It is not: it is the effect the
boundary evaluates as `mcp.effect`, and anything not positively known to be read-only gets it, which
is every tool on a server somebody added by URL. That is a governance default nobody could see.
None of this is new behaviour. All of it was already true and none of it was on screen.
---
app/src/routes/_authed/admin/boundaries.tsx | 28 +++++++-
app/src/routes/_authed/admin/components.tsx | 75 ++++++++++++++-------
app/src/routes/_authed/admin/plugins.tsx | 14 ++++
3 files changed, 91 insertions(+), 26 deletions(-)
diff --git a/app/src/routes/_authed/admin/boundaries.tsx b/app/src/routes/_authed/admin/boundaries.tsx
index dd920c2..518ea30 100644
--- a/app/src/routes/_authed/admin/boundaries.tsx
+++ b/app/src/routes/_authed/admin/boundaries.tsx
@@ -141,7 +141,10 @@ function BoundariesPage() {
}
title="Boundaries"
>
-
+
{(["enforce", "dry-run"] as PolicyMode[]).map((mode) => (