Skip to content

Show what a Bot can draw, and put every request behind one client - #60

Merged
guidovizoso merged 14 commits into
mainfrom
guido/improvements
Aug 20, 2026
Merged

Show what a Bot can draw, and put every request behind one client#60
guidovizoso merged 14 commits into
mainfrom
guido/improvements

Conversation

@guidovizoso

Copy link
Copy Markdown
Contributor

What this changes

Admin listed the components this deployment ships and said nothing about any of them. This makes the
gallery visible, gives every component an address, and puts every request in the app behind one
client.

Components are visible. Each gallery component now declares preview — the props to draw it
with — so Admin can show what it looks like rather than only what it is called.
/admin/components is a grid of real renders; /admin/components/$name is one component's page in
the settings shape, with publication as a switch and the description, Bot grants and data-function
grants each behind a dialog. /settings/components-gallery is the same gallery for everybody else,
read-only, published components only.

One client. lib/client.ts replaces four per-entity request helpers and 27 hand-rolled call
sites. There is now exactly one fetch in the app. Two behaviour improvements fell out of it:
credential failures used to reach a person as a fixed sentence while the server was naming the field
that broke, and sign-out was printing a status code.

Two skills, so this is repeatable: openbot-screen-layout (the default shape of a configuration
screen) and an updated openbot-data-access (the client, and the three kinds of request).

Also: the sidebar no longer changes height on the way into Admin, Skills says so when you have
written none, and the preview's fallback text is legible in the dark theme.

Where it runs

  • New state that outlives a request? None. client.ts holds no state; every call is a
    function of its arguments. The one module-level value is the existing input queue in the
    computer control surface, which is per-tab ordering and was already there.
  • What happens on the second replica? Identical behaviour. Everything added is browser code
    plus two markdown files; the server is untouched.
  • Anything serialised? Nothing new. Publication, grants and drafts are the same endpoints
    with the same server-side guards.
  • Anything fanned out to a browser? No. Screens re-read through React Query invalidation, as
    before.
  • New listener, port, or schedule? None.

Boundary and audit

  • Every acting call still goes through the gateway. No acting path changed — the component
    decision and call endpoints, the plugin tool call and the computer control surface all still
    resolve, decide, audit, then act.
  • New refusals and failures still write their rows. The six call sites where a refusal is an
    answer rather than an error keep returning values instead of throwing, through tryClient;
    turning any of them into an exception would have made the boundary working look like the
    boundary breaking.
  • Nothing new is trusted from the client. showActivityReport deliberately has no preview,
    because it reads deployment data on mount and drawing it in Admin would attribute a real read
    to whichever Bot happened to be active.

Proof

Driven in the running app, not only typechecked.

  • Component detail: toggled a Bot grant (server confirmed withheldFrom: ["risk-analyst"], row
    summary changed to "2 of 3 Bots"), saved a description, published and unpublished. State restored.
  • Playground: saved, published and deleted a real sandboxed component. sandboxed_components empty
    afterwards.
  • Plugins: installed a skill, granted and revoked a Bot, deleted the skill. skills empty
    afterwards.
  • Boundaries: saved a preset rule, confirmed it in action_policy, restored dry-run with no rules.
  • Settings gallery: unpublished a component and confirmed its page reads "No such component" rather
    than showing a withdrawn one. Restored.
  • Error path: bad input surfaces the server's own sentence — "A name is lower-case letters, numbers
    and underscores." and "A slug is lower-case letters, numbers and hyphens." — rather than a
    paraphrase.
  • Verified the client sends a single-encoded body with the right content type, after nearly shipping
    a double-encode: the computer tool dispatcher took a RequestInit whose eleven callers had already
    stringified. TypeScript could not see it, because RequestInit["body"] is a string.

bun test 647 pass / 0 fail, tsc --noEmit clean, biome lint clean across 134 files.

Note for review

This branch forked before six commits landed on main, including #32, which edited
admin/components.tsx — the file this replaces with a directory. That is a modify/delete conflict,
and gallery/decisions.tsx conflicts on content. Resolving needs a decision about whether #32's
improvements to that page should carry into the new components/index.tsx and $name.tsx, so it
should not be resolved by taking one side wholesale.

Two skills that were only on one machine, and so were helping nobody
else.

openbot-screen-layout is new. It says a configuration screen is
PageShell, PageSection, PageRows and Item rows, at prose width, and that
this is what a new screen is unless the request says otherwise —
somebody who does not work in the frontend should be able to add a page
that looks like it belongs without making a visual decision. It carries
the rules that are not guessable from the components: the root font size
is 15px, so max-w-2xl is 630px and no pixel constant may be copied from
the Tailwind docs; an element passed to Item's render must have no
children, or the row draws empty; bg-card is invisible inside a dialog
because --card and --popover are the same colour; DialogBody has no
overflow of its own despite a comment saying it scrolls.

openbot-data-access was already written and already untracked. It is
committed here rather than left to be rediscovered.
.claude holds two unlike things: skills, which are written once and meant
for everybody, and worktrees plus settings.local.json, which are one
machine's business. Ignoring the whole directory hid the first kind;
ignoring none of it would have committed a worktree.
A component knew how to draw itself given arguments, and nothing could
supply arguments outside a conversation. Admin could therefore list what
this deployment ships but never show any of it.

Each gallery entry now carries a preview: the props to draw it with,
passed to the component verbatim. Not the tool arguments, because the
two are not the same shape — a chart takes its arguments flat and a
decision takes a status/args/respond contract — and not derivable from
the zod schema either, because these components say so when they have
nothing to draw, so a synthesised value renders an empty state rather
than an example.

showActivityReport deliberately has none. It reads the deployment through
callComponentFunction as soon as it mounts and needs the Bot and
conversation it was called in, so drawing it in Admin would attribute a
real read to whichever Bot happened to be active. The omission is
commented at the call site so it does not read as an oversight.
Admin listed the components this deployment ships and said nothing about
any of them. It is now a gallery of what each one looks like, and each
one has an address.

/admin/components is tiles, each drawing the real component scaled to
fit. /admin/components/$name is that component's page: publication, the
description the model reads, which Bots may answer with it, and which
data functions it may draw. Settings shape rather than a form — a switch
for what is binary, a summary and a chevron into a dialog for anything
larger, and no page-level save, because nothing is held at page level.

Two routes in a directory rather than components.tsx beside
components/$name.tsx. The second form nests the child under the parent,
and a parent that renders no Outlet never draws it —
/admin/connectors/google-drive has that bug today and shows the
connectors list instead of its own form.

Writes moved to lib/components/mutations.ts behind mutationOptions
factories. Six fetch calls in a route component were six reads no key
could invalidate, and the errors they threw discarded the server's
message, which is the one naming what actually failed.

The sidebar entry is UI Components, which is what people call them.
The section drew its rows or nothing at all, so a person who had written
nothing got a heading, a New skill button, and blank space under it. It
now says what the blank meant, in the box the agents roster uses for the
same sentence.

Gated on both queries rather than on the list being empty. `mine` is the
intersection of the skills and the person reading them, so it is empty
until both have arrived — the box would otherwise have opened by telling
somebody they had written nothing while their skills were still on the
way.
The app sidebar pins its header to h-12; Admin's sized itself to a lg
button inside p-2 and came out 60px against the app's 45px. The nav list
therefore started 15px lower, and crossing into Admin looked like the
whole rail moved.

The button takes its default height rather than h-full. h-full resolves
against the parent, which in the app sidebar is a flex row holding a
second control and here is not, so the same class produces two different
heights.
Admin already showed which components this deployment ships, but only an
administrator could reach it. The person a Bot draws for could not find
out what it was able to draw.

/settings/components-gallery is that list for them, and
/settings/components-gallery/$name is one component at the size it will
actually appear. Published only: an unpublished component is not
something any Bot can put on screen, so listing it would describe a
capability nobody has. The description shown is the published one, not a
draft an administrator may be part-way through rewording.

Nothing here writes. Not a hidden-controls version of the Admin page
either — that one is built out of four mutations and three kinds of row,
and threading a read-only flag through all of it would leave every row
carrying a branch for a case it mostly does not have.

The preview moves out of components/admin, since it is no longer only
Admin's. Its fallback text stops following the theme: the artwork behind
it is the same pale gradient in both, so a token that lightens in the
dark theme moved the text towards its background exactly when it needed
to move away.

The settings rail gains icons, a 1px gap, and the group outside the menu
rather than inside it — the other way round nests a list item in a div
inside the ul. General matches exactly, because /settings prefixes every
other route here and would otherwise stay lit on all of them.
Four entities had grown a private copy of the same request helper —
agentRequest, componentRequest, credentialRequest and one more — identical
apart from a fallback sentence. One of the four had quietly lost its
body.error extraction, so credential failures reached a person as
"Credential operation failed" while the server was naming the field that
broke. Sign-out was printing a status code at somebody, which this
skill's own error handling forbids. Four copies of one function is four
places to forget.

lib/client.ts owns the transport: credentials, the JSON content type,
serialising the body, and raising the server's message when there is one.
It owns nothing about meaning — the envelope key and the sentence a
person reads are facts about one endpoint, so they stay at the call site.
A client that guessed the envelope would be a client to argue with.

Six calls keep reading the status themselves, through tryClient: the
component decision and call endpoints, the catalogue announcement, a
plugin tool call, the channel activity write that is deliberately
fire-and-forget, and the current-user read where a 401 means nobody is
signed in. A refusal there is the boundary working, and turning it into
an exception would make it look like the opposite.

The skill named the per-entity helper as the model and pointed at
agentRequest. It now names it as superseded, and says why.
Four requests that components made for themselves, each with the
transport rebuilt around it.

Testing a coworker's endpoint is a verdict, not cached state, so it is a
plain function beside decideComponent and callPluginTool rather than a
factory — nothing holds its answer and there is no key for anything to
invalidate. The ConnectionVerdict type went with it: the module that
produces a verdict should be the one that says what a verdict is.

Setting up Google Drive becomes a mutationOptions factory, which is what
it always was. Its service-account JSON is a credential and still goes
one way only.

A thread's stored messages become a fail-closed read in lib/copilot,
where the runtime's own endpoints live. It returns Message[] rather than
unknown[], because a caller that has to cast is a caller the module
failed to tell what it was getting.

Also: audit was never a call site. Its "fetch(" was refetch(), which is
worth knowing before counting the rest.
The page carried its own mutation that took a thunk returning a Response,
checked the status and extracted the error — the same work client now
does — and three fetch calls fed it.

Three factories instead. Publishing keeps its two calls in one mutation,
because it acts on the stored draft rather than on the editors, and a
save that fails has to stop the publish: publishing a draft the server
never received would put something on screen that nobody wrote.

The three writes report into the same banner, so they share one onError
rather than each growing its own.
Eleven requests across five files, three of which had rebuilt the same
mutation wrapper: a thunk returning a Response, a status check, and an
error extraction. The plugins page also kept a private `post` helper that
prefixed /api/plugins for six of them.

lib/plugins/mutations.ts instead. Servers and skills are two kinds of one
thing here — a plugin the deployment holds and grants — so one grant
factory serves both and takes the kind, rather than each growing its own
pair of grant and revoke.

Storing an MCP token stays a plain function in lib/credentials, because
it is a step inside another write rather than a write anybody asked for:
a server record keeps only a credential id, so the token has to become a
credential before the record can exist. Adding a server is still those
two calls chained, and still on the page, because only the page knows a
token was typed.

Writing a skill and rewriting one were two copies of the same POST, both
carrying the same comment about why the server's sentence is the one
worth reading. One factory now, and client carries the sentence.
The last eight. Admin's computers and boundaries pages held their data in
useState and filled it from effects, so neither list had a cache key and
nothing could invalidate either. They are queries and mutations now.

The control surface — who holds the wheel, supplying a secret, sending
one human input — moves out of components as plain functions. None of it
is cached on purpose: who is driving is a fact about this second, and a
stale copy would be worse than none, showing somebody a screen they
cannot drive or letting them think they can. take-the-wheel keeps only
the coordinate conversion, which is about the image rather than the
machine.

A screen frame is the same kind of thing, and says why it failed, because
the person watching needs told and it is not a reason to tear down the
panel.

TWO THINGS THIS ALMOST BROKE. The computer tool dispatcher took a
RequestInit whose callers had already stringified the body, and the
client stringifies too — every tool call would have sent a JSON string of
a JSON string. Typescript could not see it, because RequestInit's body is
a string and the client's is unknown. The dispatcher now takes an object
and the eleven call sites pass one. And ActionPolicy's mode is
"dry-run" | "enforce"; the first version of the moved type guessed
"allow" | "deny", which typecheck only caught because the page still
named the real one.

The skill gains the third kind of request. Two categories were never
enough: a verdict about this moment, a tool call mid-turn, a frame, a
step inside another write — none of those are cached state, and giving
them a key would create one nothing reads. It also stops claiming
lib/copilot only streams. The conversation streams; the tool calls a Bot
makes are ordinary requests.
Six things this branch had left behind or introduced.

The overview said "Components" where the rail says "UI Components".
Renaming one and not the other was mine.

lib/motion.ts exists so the app's motion reads as one hand rather than
three sets of numbers that agree today — and its own comment names the
two files that had hand-written copies. Both still did. One EASE_OUT now,
and app-sidebar's duration came from the same place it always meant to.

PageRows squares its rows and clips them. Item carries rounded-lg, which
inside a card of divided rows drew a hover as a floating pill: a middle
row has no corners, and the first and last cannot be concentric with the
card while sitting a border inside it. Fixed once, for all seven screens
that use it.

Seven loading placeholders, against a skill committed on this same
branch: the pending branch draws nothing, because a placeholder that
appears and vanishes inside a local round-trip is a flicker rather than
information. Two of them sat on pages this branch had already moved onto
React Query, so the branch was shipping the rule and the exception
together. edit-skill also stopped saying "Loading…" and "that skill is
gone" through the same sentence; not yet arrived and missing must not
read the same.

DialogBody scrolls, which its comment always claimed. flex-1 min-h-0
lets it shrink but nothing made it scroll, so on a short viewport it
painted over the footer — visible only once a dialog was tall enough,
which took until this branch.

The admin rail gains the 1px row gap settings got, on the one menu whose
rows have siblings.

Not fixed: /admin/connectors/google-drive still renders the connectors
list, because that screen is being replaced.
Both branches added component previews independently, and arrived at the same
design: a `preview` field of props rather than tool arguments, because a
component that suspends the run is handed `{ status, args, respond }` and would
crash on arguments alone. Main's sample props are kept, since they are already
reviewed and mine added nothing theirs lacked. Git had auto-merged the two sets
into one object literal with two `preview` keys apiece, which is why the four
gallery files are taken from main outright rather than deduplicated by hand.

`activity.tsx` keeps this branch's comment. Both sides correctly gave it no
preview; only this side wrote down why, which is what stops the omission being
"fixed" later.

The list page is replaced by the grid and detail route rather than merged with
main's closed-by-default disclosure. Main's reasoning against drawing everything
at once was that thirteen conversation-width components stacked down a page is a
page nobody reads. The tiles are scaled thumbnails in a fixed square, so that
objection does not transfer; a component is drawn at full size on its own page.
Main's explanatory copy for the two grants does transfer, and is now in the
dialogs: switching a Bot off means it is never told the component exists, and
what it may read is a separate grant that the Bot grant does not imply.

`PreviewOf` is now private to its file. Its only caller was the deleted page,
but `GalleryPreview` renders through it, and that panel is unused on main and
plainly intended for a per-Bot surface, so it is not this merge's to remove.

Main's two callback-token mutations arrived calling `agentRequest`, a per-entity
helper this branch deleted; they go through `client` now. Nothing else in the
app calls `fetch`.
@guidovizoso
guidovizoso merged commit a5450a7 into main Aug 20, 2026
3 checks passed
@guidovizoso
guidovizoso deleted the guido/improvements branch August 20, 2026 21:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants