Skip to content

Send somebody a coworker, and grant it nothing on arrival - #299

Draft
jerelvelarde wants to merge 39 commits into
CopilotKit:mainfrom
jerelvelarde:feat/bot-templates
Draft

Send somebody a coworker, and grant it nothing on arrival#299
jerelvelarde wants to merge 39 commits into
CopilotKit:mainfrom
jerelvelarde:feat/bot-templates

Conversation

@jerelvelarde

@jerelvelarde jerelvelarde commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Read it before you run it

The same walkthrough as video, at full size: openbot-templates-catalogue.mp4

The catalogue, and the ceiling an import applied

Export a coworker, twice

Three documents that never become a Bot

The problem

A coworker is the most valuable thing a person builds here, and it cannot leave the deployment it was
built in. The only way to give a colleague the Bot you spent a week on is to describe it and have
them do the week again, and across a fleet the same Bot is rebuilt by hand in staging and in
production until the two drift.

Two things almost do this and neither fits. The tenant package is the whole deployment — brand,
channels, model, roster — read from a directory at boot by whoever runs the process; it cannot
describe one coworker, and it is unsafe on a stranger's file, since expandEnvironment substitutes
${NAME} out of the server's own environment before the YAML is parsed. duplicate copies four
presentation fields, drops the endpoint and every grant, and throws ManagedAgentUnavailableError
outright when the deployment has no Bot of its own — so on the one-container image the README
recommends, that button is dead.

The design is already written down in the code that needs it: tenant-package.ts:137 concludes a
skill is safe to seed because "a skill is an instruction and confers nothing", while an MCP grant is
not because "those reach a person's own account". Nothing used it.

The approach

A Bot template is one YAML file describing one coworker — identity and prose, the skills it
depends on, the capabilities it asks for, and a ceiling on what it may do. The vocabulary is the
tenant package's, so anyone who has read examples/fintech/ can read a template.

Configuration travels; capability does not. No id, endpoint URL, credential, MCP grant, component
source or policy rule can travel, because none are fields: a document carrying one fails to parse and
is told which key and why. That beats stripping them silently — an author who tried to ship a key
learns, and a reviewer is not reading a redacted copy of something larger. Import writes exactly one
grant, plugin_grants kind 'skill', marked template:<digest12> so a retraction takes back what
the import gave and never an administrator's own. Everything else lands in a ledger as requested and
not granted, satisfied afterwards on the screens that already decide those things.

Three divergences from the package loader, each a security decision. Parsing is strict, because
an ignored key is one the reviewer's eye slid over and the parser agreed to. There is no environment
interpolation at all, and the check reads raw bytes so a comment cannot carry one either. Slugs use
the API's rule rather than the package's looser one, which admits x and find- — both install
cleanly and are then permanently uneditable through the product.

The consent screen is the control, so it is built as one. The role description and every skill's
instructions render verbatim and unabridged, under the sentence that a stranger wrote them. Invisible
codepoints are a parse refusal rather than a rendering problem, because a review control that can be
made invisible is not a control.

An unmet ask never blocks the install, which is the decision most likely to be questioned.
Blocking would make "grant everything" the fastest route to a working Bot. The Bot arrives cold and
does not lie about it: grantedToolGuidance builds its self-description from the tools it was
actually offered, so it says it has no source rather than claiming what the template promised.

The ceiling is enforced, not just disclosed. The boundary: block compiles into CEL deny clauses
scoped to one Bot. The Bot is the LEADING conjunct because cel-js short-circuits, and an unscoped
clause that throws would refuse every action for every Bot rather than the one named. Host lists
compile to equality, never a pattern, because matches throws on a bad pattern and a throwing deny
counts as a match. Every clause is evaluated against neutral contexts — through the engine that will
judge it — before it is stored.

They live in template_boundaries, composed into what the engine reads and never written into
action_policy.deny: that array is replaced wholesale from a screen snapshot with no version column,
so a clause stored there would be erased by the next unrelated save. The screen is served what an
administrator wrote, with the generated clauses beside it, read-only.

Twenty-seven coworkers, in nine kinds of work. Enough that the gallery has to answer "is there
one that does my job" rather than only "what is here", so it carries a search field and category
chips with counts, both computed in the browser from the list it already holds. template.category
is a closed list of nine slugs: free text there would let whoever wrote a file invent a grouping, put
a sentence in a chip, or name itself something that sorts to the top, and a value outside the nine is
refused by name. The slug travels and the words do not, so relabelling a category changes no file.
Still no install count, download count or star anywhere — nothing here counts anything, and a number
an author supplies about their own template is worse than no number.

A catalogue, and sources pinned to a commit. The image ships templates and reads them per file,
so one malformed file names itself in the log instead of stopping the deployment booting. Beyond that
an administrator may register a repository pinned to a sha, allowlisted by an environment variable
that ships empty and that no screen can widen, fetched server-side so no browser acquires a
third-party origin. Moving the pin is the only update mechanism, and it rewrites the one row rather
than leaving two registrations disagreeing about which sha is current.

The registration is a row, so a restart does not quietly narrow what a deployment offers. Boot passes
each stored source back through the same allowlist the register route applies: a repository that has
left the environment variable is not fetched from merely because a row about it outlived the decision
to trust it, and the skip is logged with the sentence that says how to undo it. The row is kept — an
operator may be mid-rotation — but the database can only ever widen what a deployment offers inside
what the environment already allows.

docs/bot-templates.md carries an ASCII map of these screens, marking which write nothing at all
and which single controls write.

What is not covered

  • No hosted registry, and no share link. Sharing is a file, an in-box directory, or a repository
    an administrator pinned. A link that installs for a stranger would need a service somebody operates,
    a moderation queue and a signing key; the reference catalogue is
    jerelvelarde/awesome-openbot-templates,
    and its trust model is git's — whoever can merge there.
  • A source's trust is its pin. No signing and no publisher verification. author is a claim,
    rendered as one. Pin a commit you have read.
  • No update channel. template.version is a string nothing reads. Security fixes do not
    propagate either — that is the trade, and auto-update has no safe form without publisher identity.
  • type: built_in cannot travel, so exporting a shipped fintech Bot is not a faithful round trip.
  • Components are requested, never withheld, and declining an ask does not revoke a grant already
    made — revocation goes through the existing screens, because the ledger stores the ask and a
    decision, never a second source of truth for a permission.

Verification

format:check, lint and typecheck clean. bun run test:ci reports 2435 pass, 0 fail against
a baseline of 2149 on main — 286 new tests. All 27 templates parse through the real
parseBotTemplate, every category is in the closed list, and no two share a slug.

Driven end to end against a running deployment throughout, which is how several of these were found:
the routes were mounted behind an optional dependency the composition root never built, so
/api/templates answered 404 on a real server while every route test passed; the packer's own
secret-scanner fixtures were shaped enough like credentials to be blocked by push protection; and the
boundary screen was served the composed policy, so a clause an import applied appeared in the list an
administrator edits, beside a Remove that saving filters back out. The test covering that last one
asserted the composed value, so it agreed with the bug rather than catching it.

Two dead-ends came from the same habit of pressing a button twice. Exporting a coworker that had
already been exported collided on the draft's unique slug and answered "You already have a template
draft called X. Rename one of them." — on a screen with no rename control and no way to reach the
draft it named. The seam now asks WHICH Bot took the name: the same coworker gets its draft back with
its edits intact and the fresh pack offered as a separate press that says it overwrites, while a
genuine clash between two Bots still refuses, because only a person can decide which keeps the name.
And a registered source lived only in a Map, so a restart forgot it and the gallery narrowed with no
sign that anything had changed.

A review of these screens against their own screenshots raised forty-six things; eight were checked
by somebody trying to refute them and five survived, all fixed here. Two were real bugs rather than
polish: "Use this template" opened the paste box with the file already in it rather than the consent
screen, which meant three presses to a decision and a 409 blaming the file for any edit somebody made
in a box that could never install; and the reading page showed a 155-line template twelve lines at a
time, through a height cap chosen for the 560px consent panel and inherited when the component was
extracted. The other three: a stranger's prose was scrollable by mouse only, "Not granted by this
install." sat at 3.20:1 where 4.50 is required, and a 500 rendered as "this template does not exist".

Three more came from CI failing where a laptop could not, all of them the same shape: bun test runs
every file in one process, parts of the app decide at module scope whether they have a browser, and
whichever file loads the app graph first decides that for everybody. Six integration files held their
connection pool open; Happy DOM registers at about:blank, whose origin is the string "null", so
the first file to reach @/lib/auth/client threw while being imported; and auth-client.test.ts
assigns a window with a location and nothing else, which left window.history undefined for
app/src/router.tsx. Two of those fixes touch files this feature does not otherwise go near; they are
here because these tests are what made them reachable.

File What it holds down
shared/bot-template.test.ts Every refusal by reason code, invisible codepoints by Unicode property class, digest stability across key order and Unicode form
server/tests/template-pack.test.ts Every strip, and each secret shape refused with benign near-misses accepted
server/tests/template-install.integration.test.ts One transaction, no MCP grant written anywhere, the digest 409, retraction leaving a hand-made grant intact
server/tests/template-routes.integration.test.ts Each authz boundary, and that an ask recorded unavailable cannot be granted even after the connector appears
server/tests/template-boundary.test.ts Every vocabulary value, equality-only host lists, and a clause that would throw being refused
server/tests/template-boundary-enforcement.integration.test.ts The clause enforced against its Bot and no other, surviving an unrelated policy save, and lifted by retraction
server/tests/template-catalogue.test.ts A malformed file skipped and named; a repository not on the allowlist, and a branch instead of a sha, both refused
server/tests/template-gallery-routes.integration.test.ts A gallery install read from the catalogue rather than the posted body, and the author's sentence and clause on the admin payload
app/tests/import-template.test.tsx That nothing model-visible is truncated, and that a key typed for one template is not carried to the next
app/tests/template-gallery.test.tsx That a chip narrows the grid, that its count matches what is drawn, and that the empty state appears and clears
app/tests/template-categories.test.ts That the parser's closed list and the labels the gallery draws are the same nine, in the same order
app/tests/template-detail.test.tsx That the reading page carries every character a model is given, links no address on it, and has no control that writes
app/tests/export-template.test.tsx That a second export says the draft is the one that already existed, and that re-packing is a press of its own
server/tests/template-sources.integration.test.ts A source still registered after a restart, a moved pin rewriting one row, and a repository dropped from the allowlist not loading

A hostile review over the diff produced 27 candidate defects; 24 survived independent verification and
are fixed in the last five commits — including one where an ask both consent surfaces described as
inert sat beside a button that wrote a live MCP grant.

…tes never mount

The routes were wired into createApp behind an optional dependency and the composition root never
constructed one, so /api/templates answered 404 on a real server while every route test passed: the
tests build the app directly and hand it the dependency the process never had.

Assembled here rather than inside createApp because the installer needs the vault, the plugin store,
the trail and this deployment's endpoint policy, and each of those already exists exactly once in
this file. The endpoint policy is deliberately the same pair agentFetch is given: an address
registered by an import must be held to exactly what an address registered through /api/agents is.
A connector id was checked for length and nothing else, so a template could file a real tool ref as
one. With no tools under it the per-tool check that a ref belongs to its connector never runs, so
`id: google-drive/read_file_content` parsed cleanly and arrived where the server and the profile
screen both re-derive connector-from-tool by looking for a slash.

The invisible-character refusal is now the Unicode property classes rather than an enumerated list,
which had drifted nine blocks narrower than the sentence it defends and let the first sixteen
variation selectors through. Lengths are measured in the units the edit form measures them in, so a
template can no longer install a Bot its owner cannot then save.
The grant route read the ref and never the ledger row's status, so an ask the consent screen had
described as "nothing will be granted and nothing will be written", and the profile as "there is
nothing yet to grant", sat beside a button that wrote a live MCP grant.

Two guards, and the overlap is load-bearing. The status guard refuses an ask that was unavailable
when the person read it, because somebody connecting that server afterwards is not their consent.
The live re-check refuses a ref whose server or tool is absent right now, because the stored status
is a snapshot from resolve time and a grant for a server that does not exist is invisible on every
screen until the day one appears under that id.
The endpoint check and the byte-identical guard behind `reuse` both had no test, so either could
have been deleted and the suite would have stayed green. Two skills in one template can no longer
plan into the same slug, an imported Bot keeps the avatar its template named, and reuse of a skill
somebody else owns falls through to a suffix rather than quietly attaching theirs.
The audit write went to the pool while the caller held a transaction on a pooled connection, so
every import needed two at once and enough concurrent ones would wait on each other.
…be refused

Name and title were truncated on the consent screen and both are concatenated into the standing role
the model is given, so a stranger could write two hundred characters into a field the reviewer saw
forty of. Nothing model-visible is clipped now.

A deployment in dry-run enforces nothing, so it gets the amber warning rather than the reassuring
block. A key typed for one template is no longer carried to the next one's address. The audit page
knows the template events, so a refused import reads as refused rather than as allowed.
bun test runs every file in one process, so a pool left open is held for the rest of the suite. Six
new files opened one and never closed it, and past a certain point PostgreSQL stops handing out
connections — which does not read as a connection limit, it reads as the run dying at whatever file
happened to be next. On CI it died at a different one each time and printed no summary at all.

Every other integration test here already closes. These now do too, after their cleanup rather than
before it.
Happy DOM defaults to about:blank, whose origin is the string "null". Better Auth builds its base
URL from window.location.origin when it is not given one, so the first file to pull in the auth
client under a bare registration threw Invalid base URL: null while it was still being imported,
taking its tests with it and reporting an unhandled error rather than a failure anybody could place.

Locally auth-client.test.ts stubs a window with a real origin and, when it runs first, the client is
already cached by the time a rendered test needs it. That is an ordering accident. On CI the order
differs, eight tests never ran, and the suite reported a count nobody could reconcile.
The sign-in test assigns a window with a location and nothing else. That was harmless while nothing
in the suite installed a DOM; the rendered tests here do, and overwriting a registered window leaves
document defined and window.history undefined. app/src/router.tsx builds its history at module scope
from exactly that pair, so it threw while being imported by whichever file reached it next, and took
that file's tests with it.
…erstands

A template says shell, files, browser, hosts and mcp in a closed vocabulary; this turns that into
CEL the gateway evaluates like any other rule. A template never writes CEL, and could not: the
vocabulary has no way to say anything but less.

Three properties, each with a reason. The Bot is the LEADING conjunct because cel-js short-circuits
and an unscoped clause that throws refuses every action for every Bot rather than for the one named.
Host lists compile to equality and never to a pattern, because the matches helper throws on a bad
pattern and a throwing deny counts as a match. And every clause is evaluated against ten neutral
contexts before it is stored, through the same engine that will judge it, so a malformed one is
refused rather than written.

The emitter refuses a value it cannot write verbatim rather than escaping it: cel-js does not decode
escape sequences, so an escaped literal would be a rule that silently never matches the host it names.
The clauses are written in the same transaction that creates the Bot, and composed into the policy
the gateway reads rather than into the array an administrator edits.

Never into action_policy.deny, and the reason is a lost update nobody would see: that array is
replaced wholesale from a screen snapshot with no version column, so a clause stored there is erased
the next time somebody saves an unrelated rule. Separate storage makes that impossible. Retracting an
import lifts its ceiling the same way it takes back its grants.
The consent screen said a deployment does not yet enforce what a template declares. That was true and
is not any more, so it says what it now does instead. The separate warning about a deployment whose
policy still allows every action stays: a ceiling only ever subtracts, and a Bot held to browsing on
a deployment that permits everything is still a Bot that can browse.

Boundaries lists the generated clauses in a group of their own, grouped by the coworker, and offers
no way to edit them there — they are not in the array that screen posts.
…nned to

Two sources, and nothing is fetched from the network unless an administrator registers one.

The directory in the image is read per file rather than as a package. The tenant loader refuses to
boot on a malformed file, which is right for an operator's own configuration and wrong for a
directory of many authors' work: one bad file names itself in the log and the rest still load.

A git source is pinned to a commit, allowlisted by an environment variable that ships empty and that
no screen can widen, and fetched server-side so the browser never acquires a third-party origin and
the source never sees an end user's address. Moving the pin is the only update mechanism, it is an
administrator's deliberate act, and it changes nothing already installed.
…wser

Installing from the gallery re-reads the template server-side and refuses a digest that no longer
names that entry, so what is installed is what the deployment holds rather than what was posted back
to it. There is no way to publish into the gallery over HTTP: publishing is a git push.
The gallery lists what this deployment offers with the author rendered as a claim and no counts and
no ratings — there is nothing to count and a count is a thing to forge. Admin gains a page listing
every import, what it asked for, the ceiling it applied, and Retract.
bun test runs every file in one process, and parts of the component library decide at module scope
whether they have a browser at all — a portal that concludes it has none stays switched off for the
whole run. This file loads the app's entire route tree, so whichever order the suite was walked in,
it decided that for everybody: the admin dialog tests failed here while the dialog opened correctly
in a browser.

The import is dynamic so the registration stays in front of it. The file already needed browser
globals; it was getting them by accident from whichever rendering test happened to run first.
A values file written before this key existed must still render, and the chart proves that by
rendering without each key added since v0.0.5. Emitted unconditionally it produced an environment
variable with an empty value, which is not the same as an absent one: the server's own default
resolves to this same directory, and an empty string is a deployment told to read templates from
nowhere.
The boundary route answered with the composed policy, so a clause an import applied appeared in the
list the screen edits, with a Remove button beside it. Saving filters those clauses back out, so the
button succeeded, changed nothing, and left the rule enforced with nothing on screen saying why. A
screen that offers an action it will not take is worse than one that does not offer it.

Two readers now: get is what the engine enforces, authored is what a person wrote. The clauses are
still on the same page, in the read-only group that explains where they came from.

The test that covered this asserted the composed value, so it agreed with the bug rather than
catching it. It now asserts the editable list is the operator's own.
Every template drew the same box icon, so a dozen of them were a dozen identical rows and the eye had
nothing to land on. A card now carries the drawing the coworker will actually have — the same seed,
so the card previews what importing produces — over a wash taken from that seed, which makes one
template distinguishable from another before either name is legible.

A grid and a wider shell, which is the one place this feature leaves the layout every other screen
uses, and the reason is the same one the audit log has: this is the only browse surface in the
product. Somebody is comparing coworkers they have never seen against each other, and at prose width
that comparison is a scroll.

WHAT WAS NOT TAKEN from the reference. No install count, no downloads, no stars, no rating, no
featured badge, no price. They are most of what gives a marketplace its density and this one cannot
draw a single one honestly: nothing here counts anything, there is no service to count on, and a
number supplied by the author of the thing being judged is worse than no number. The space they would
have taken goes to the claim, the summary, and what the template asks for.

The card also survives a server that has not rolled yet: an absent avatar seed falls back to the slug
rather than taking the page down, which is what reading .length off undefined did.
The only way to see what a template would tell a model was to open the import flow, which put 'let me
see what this is' and 'I am installing this' behind one button — on the one screen in the product
whose contents were written by somebody else. A card now leads to the template's own page, and the
button that installs lives there.

The two surfaces answer two different questions and neither answers the other's. The page shows the
template AS WRITTEN: the prose, every skill's instructions, the asks with the author's own reasons,
the ceiling, the provenance, and the file itself. It resolves nothing against this deployment and it
holds no state. The consent screen still shows what would happen HERE — which connector exists, which
slug is taken, whether an address is needed — and still carries the only control that writes.

Verbatim, unabridged and unformatted is a security property rather than a style, so the two screens
now render a stranger's prose through one component and head it with one sentence. A second copy of
that treatment is a second place for one of the three to be quietly lost, on whichever screen nobody
looked at recently.

The test asserts the properties rather than the markup: the whole of a long instruction is present,
no prose box carries a truncating class, no address is an anchor, and the page's only control
navigates. Breaking the first one was checked by adding line-clamp and watching it go red.
The panel opened on a wall of YAML, which answers 'what are the bytes' when the question somebody has
at that moment is 'what did I just hand over'. It also pushed the half that reassures — what did NOT
travel — below a fold nobody scrolled, on the one screen where a person is about to send their
coworker to somebody else.

An inventory first: the instructions, the skills by name, what it asks for, and the ceiling. Then the
seven things that stayed behind, counted. The file is one press away rather than gone, because
somebody about to send this must be able to read every byte, and it opens itself the moment there are
unsaved edits so nobody hunts for the box they are being told to save.

The inventory earns its place immediately: it shows that re-exporting an imported coworker carries no
connector asks, because the packer derives them from what this deployment actually granted and an
imported Bot arrives holding nothing. That was always true and the YAML never said it.

Setup notes move above the skills on a template's page. For anything beyond a simple coworker that
note decides whether the thing works on arrival — which folder, which connector, what has to exist
first — and at the foot of a long page it was read after the decision it was meant to inform.
A registered template source lived in a Map and nowhere else, so every restart forgot it. The
settings screen came back saying no sources, the gallery quietly narrowed to whatever the image
ships, and a template somebody had imported an hour earlier was no longer offered by anything. The
only cure was for an administrator to notice and register the repository again, which is a poor way
to learn that a deploy has changed what a deployment offers.

The registration is a row now, in `template_sources`, keyed by the handle and carrying the pin. It
is written before the Map is touched, so a source that survives the request survives the process,
and moving a pin rewrites the one row rather than leaving two registrations of the same repository
disagreeing about which sha is current.

The environment still decides, and that is the part worth being careful about. `load()` at boot
reads the rows and passes each one back through the same allowlist the register route applies, so a
repository that has left `OPENBOT_TEMPLATE_SOURCES` is not fetched from merely because a row about
it outlived the decision to trust it. The row stays — the operator may be mid-rotation, and deleting
their registration on their behalf would be a surprise — but nothing is loaded from it and the skip
is logged with the sentence that says how to undo it. The database can widen what a deployment
offers only inside what the environment already allows; it can never widen the environment.

`registerSource` and `forgetSource` return promises now, and the two admin routes await them.
Pressing Export on a coworker that had been exported before dead-ended. The draft slug is unique per
person, the second pack collided with the first, and the panel said "You already have a template
draft called X. Rename one of them." — on a screen with no rename control and no way to reach the
draft it was talking about. The advice could not be taken on the screen that gave it, and the honest
reading of the gesture is not "make me a second file": it is "show me the template for this
coworker", which already existed.

So the seam asks which Bot took the name. A draft for this same coworker is this same coworker being
packed again, and the person gets it back with their edits intact; a draft for a different Bot, or
one somebody pasted, is two files fighting over one name and still refuses, because only a person
can decide which of them keeps it. The insert still goes first and the question is only asked when
the index refuses — a read that "checked first" would let two exports a second apart both see a free
slug, and it would put a query on the ordinary path to answer a question the ordinary path does not
have.

The fresh pack is not thrown away and it is not applied either. It rides back as `repack`, and the
panel offers it as a press of its own that says what it costs: re-packing replaces the document,
edits included. That press goes through the existing draft route, so the bytes are re-parsed and
scanned for secrets on the way in rather than trusted because this server produced them.

On the reuse, `yaml` and `digest` are the stored document's, so Download hands over the file that is
on screen. `stripped` stays the fresh pack's, which is not an oversight: it names the fields of the
COWORKER that no template can carry, and that is true of the Bot in front of the person whichever
document they are reading. No `template.exported` row is written, because nothing was exported — a
second row would count one export twice.
The doc described the format, the routes and the refusals, but never the shape a person moves
through — and that shape is where this design's central claim is actually enforced rather than
promised. Browsing, reading and installing are three separate URLs on purpose: the middle one exists
so that "let me see what this is" and "I am installing this" are not the same gesture. A reader had
to infer that from two route tables.

So: a map, in the character set configuration.md already uses for its tree, whose branches are the
controls a person presses rather than the files on disk. It marks the two things worth seeing at a
glance — which screens write nothing at all, and which single controls write — because the reading
surfaces writing nothing is the property the rest of the design leans on.

Three sentences the screens had outgrown. The export section still said a second export of the same
coworker answers 409 rather than throwing away the first draft's edits; it now hands that draft back
and offers the fresh pack as a separate press, and only a name held by a DIFFERENT Bot still
refuses. The import section said "paste or drop the file", and there has never been a drop target or
a file picker anywhere in the flow — a doc promising a gesture the product does not have sends
somebody hunting for it. And `template.summary` was described as one line shown in a list, from
before the gallery was a list: it is three clamped lines on a card, the description heading the
template's own page, and unabridged on the consent screen.

Five further candidate corrections were raised against this doc and dropped as wrong: each described
a line as stale that the code still honours.
…d one

Three templates was a proof that the format works. It is not a catalogue, and it let the gallery get
away with being a list: with three cards there is nothing to search and nothing to filter, so the
screen never had to answer the question a person actually arrives with, which is not "what is here"
but "is there one that does my job".

Twenty-four more, so the screen has to earn its keep. They are spread across nine kinds of work and
each one carries a rule about judgement rather than a job description, because that is the part of a
template that is worth copying: Account Health gives no score and keeps "they asked whether we could"
apart from "we agreed to"; Repro Desk answers REPRODUCED, NOT REPRODUCED or BLOCKED and never a
suspected cause; Talent Scout does not rank people and drops protected characteristics without
repeating them; Inbox First Pass takes dates from the message rather than from an impression of
urgency, and reports text inside a message that tells it to act instead of obeying it. The shapes
vary on purpose — some ask for no connectors at all, one runs remote, one buys exactly one write —
so the set is a demonstration of the boundary vocabulary and not twenty-four of the same file.

**`template.category`, from a closed list of nine.** Closed rather than free text, and that is the
whole of the design: a category decides where a stranger's file appears and what it appears beside,
so free text there would let whoever wrote the file invent a grouping, put a sentence in a chip, or
name itself something that sorts above everything else. The slug travels; the words are this app's,
so changing a label changes no file and invalidates no import. A value that is not one of the nine is
refused by name, with the nine listed in the refusal. The packer does not guess one when exporting a
coworker, because nothing on a deployment records what job a Bot does, and a guess would file
somebody's template under a group they did not choose.

**The gallery gains a search field and category chips with counts.** Both are computed in the browser
from the list it already holds — no request, and no count of installs, downloads or stars, which this
feature still has nothing honest to draw. Search covers name, role, summary and category, and
deliberately not author or source: the two fields nothing has verified get no say in what surfaces
first. Filtering happens inside the two provenance sections rather than replacing them, because "in
the box" and "from a pinned source" answer where the code came from while a category answers what it
does, and an empty section disappears instead of standing as a heading over nothing.

A chip whose category the current search has emptied keeps its place, reads zero and is disabled,
rather than vanishing — a row that rearranges under the cursor on every keystroke is how somebody
presses the wrong filter.

The closed list is written down twice, in the parser and in the app that draws the labels, and
neither can import the other. `app/tests/template-categories.test.ts` binds them: without it, adding
a tenth category to the parser alone compiles, passes every gate, and ships a template the server
accepts and the gallery silently files under nothing, with no chip and no way to reach it.
…e it says

A review of these screens against their own screenshots raised forty-six things; eight were checked
against the code by somebody trying to refute them, and five survived. These are those five.

**"Use this template" did not reach the consent screen.** It opened the paste box with the file
already in it, above a button repeating the words the person had just pressed on the card — three
presses to reach a decision, the middle one buying nothing. Worse than redundant: an install from the
catalogue posts the slug and the server re-reads its own copy, so anybody who edited a character in
that box got a 409 saying the template had changed since they read it, blaming the file for their own
edit. The file is now read on arrival, the box is read-only when it came from the catalogue, and the
heading says which of the two situations you are in. The guard is a ref keyed on the document rather
than a dependency on the verdict, because the latter re-reads the moment somebody presses "Read a
different file" and bounces them back, silently discarding the address and key they had typed.

**The file was shown twelve lines at a time.** `max-h-64` at this deployment's 15px root is a 240px
box, about twelve lines, and the templates it has to show are 120 to 155 lines long. That cap was
chosen for the 560px consent panel and came along when the component was extracted, turning the page
whose whole argument is that "read it before you install it" is only honest advice if there is
somewhere to read it into six portholes, each with a nested scroller. The height is now the caller's
to decide: the panel that is a decision surface keeps it, the page that is a document does not. The
three properties that are the security control — verbatim, unabridged, unformatted — are untouched
and still share one implementation.

**A stranger's prose could be scrolled by mouse only.** Where the box does scroll it is now a named,
focusable region, so the second half of somebody else's instructions is reachable from the keyboard.

**"Not granted by this install." failed contrast.** `text-amber-600` on a white card is 3.20:1 at
11.25px, under the 4.50 required. It is 5.03:1 now. The sentence that says a capability was asked for
and not given is a poor one to make hard to read.

**A failed read said the template did not exist.** A 500, an expired session or a dropped connection
all rendered the page that says this deployment offers no template by that name, sending somebody to
check a slug that was never the problem. `client` now carries the status it was answered with, and
the page separates "not here" from "could not read it just now".

Two of the eight verified findings were refused rather than fixed, and are worth naming: the repeated
"Nothing is granted by reading it." on every card stays, because it sits on the one control a person
is about to press on the one screen whose contents a stranger wrote; and "Read this template" labels
both the card and the panel's parse button on purpose, because both do the same thing and neither
installs anything.
The template format lives in `shared/`, which the runtime image places at `/app/shared`. Module
resolution from there walks up to `/app/node_modules`, which `bun install --production` builds from
the ROOT manifest — and `yaml` appeared there only under devDependencies, so it was dropped. The
container crash-looped on `Cannot find package 'yaml' from '/app/shared/bot-template.ts'` before the
server ever listened.

The previous importer never showed this. `server/src/tenant-package.ts` sits at `/app/server/src` and
resolves into `/app/server/node_modules`, where `server`'s own dependency on `yaml` has always put
it. Moving the format into `shared/` moved it into a different tree.

A development install hides the difference, because it keeps devDependencies and hoists one copy
that satisfies every path — which is why every gate here was green. The check that reproduces it is
the image's own command run against the manifests alone: after
`bun install --frozen-lockfile --production`, `node_modules/yaml` must exist at the root.

`server` still declares it too, for its own resolution.
The README described three seed templates and said the set stays small enough to hold in your head.
It holds twenty-seven across nine kinds of work, and its table named three of them — so the file a
reviewer reads before adding one was describing a directory that no longer exists.

The table now carries every file with the category it files itself under, which is also the quickest
way to see that the closed vocabulary is populated rather than aspirational.
Every screen built on PageShell was cut off at the fold with no scrollbar to say so. The templates
gallery is 5763px of content in a 900px viewport; the template's own reading page is 6546px, of which
four of fourteen sections could be reached. Skills was doing it too, quietly, at 1376px.

The two layouts that host these screens disagree about who scrolls, and PageShell answered for
neither. `_app.tsx` is `h-svh overflow-hidden` and says in its own comment that panes scroll inside
it, so a pane providing no scroller is simply clipped. `admin` is ordinary flow, where the document
scrolls and a pane needs to do nothing. Both worked until a page got tall, and the feature that made
pages tall was this one: a catalogue of twenty-seven, and a reading page that shows a stranger's
prose in full rather than through a twelve-line porthole.

PageShell now owns the pane scroller, which satisfies both hosts. Under `_app` it is the flex child
that fills the viewport and scrolls inside it. Under `admin` nothing constrains its height, so
`overflow-y-auto` never engages and the document scrolls as it always did.

`min-h-0` is the load-bearing half and the easiest to drop: a flex child defaults to
`min-height: auto`, refuses to shrink below its content, and hands the overflow straight back to the
parent that has already hidden it.

Measured on every PageShell screen rather than eyeballed on one.
The template page was seven screens of monospace. Everything on it was rendered the same way — the
role description, every skill's instructions, each connector's reason, the ceiling, the whole file —
so the parts that are cheap to answer were buried in the parts that need reading. Somebody deciding
whether this is even the right template had to scroll past four screens of a stranger's prose to
learn that it asks for two connectors and cannot touch a file.

The prose is untouched and still verbatim, unabridged and unformatted, because that is a security
property rather than a style. What changed is everything that is NOT prose:

**A row of three facts under the name** — skills, connectors, kind of work — taken from the format's
own structured fields. It is the answer to "is this the one I want", and it now costs a glance.

**The ceiling is a grid.** Four capabilities, each with its level, so a reader sees which of them
are open in one pass instead of assembling it from four sentences. The sentences stay underneath and
remain the authority: `describeBoundary` is shared with the consent screen and the Boundaries screen
exactly so one ceiling is never worded two ways, and the grid is an index over the same object, not
a paraphrase. It repeats that helper's own warning too — an empty host list is the absence of a
limit, so the compact view says "any site" rather than implying none.

**Connectors and skills are cards with chips.** A tool ref is an identifier somebody scans for, and
it was being rendered as a bulleted sentence per ref. Each connector still carries the author's
reason word for word; the per-tool reasons fold, because they matter when granting, which happens on
another screen.

**A skill's instructions fold; the role description does not.** The role description is one block
every run carries, so it is the substance and stays open. Skill instructions are N blocks of the
same length invoked one at a time, and on a template with three they were most of the page. Folded
is not hidden: the text is in the document, unabridged, one press away, with the slug, the summary
and the tools already visible to say whether you want it. The raw file folds for the same reason.

The page went from 6546px to 2803px — seven screens to three — with nothing truncated. The tests
that hold the security properties down are unchanged and still pass: every character of every
model-visible string is on the page, no `<pre>` carries a clipping class, and no control here writes
anything.
Four things, from reading the page on a real deployment rather than in a test.

**The blocks were touching.** `PageShell`'s header carries no bottom margin and `PageSection` supplies
its own `mt-12`, so anything added as a raw child of the shell inherits no spacing at all — the
identity strip sat flush against the summary above it and the three tiles sat flush against the strip.
They are one card now, with the tiles inside it under a hairline rule, and the card owns the gap
between itself and the header.

**The description of the coworker carries the weight it should.** It was a thin strip indistinguishable
from the eight sections beneath it, on the page whose first job is to say what this thing is. A larger
drawing, the job title set as the heading it functionally is, and the same seed-derived wash the
gallery card uses, so a template looks like itself in both places. `hueFor` moved into the shared
anatomy module for that reason: two surfaces tinting from one seed must not each own a copy of the
hash.

**The ceiling comes first and the prose comes last.** This reverses what the page used to argue, and
the old reasoning is worth stating before overturning it: what a Bot will be TOLD is a larger fact
than what it may reach, so the instructions led. That holds for somebody who has already chosen a
template and is reading it closely. It does not hold for the commoner arrival — deciding whether this
is the right template at all — where four screens of a stranger's prose stood between the reader and
the two short, structured sections that answer it. Capabilities are now the top of the page and the
prose follows, whole and verbatim, with nothing hidden and nothing skimmed past.

**A glance value wraps instead of truncating.** "Customer Success & …" hid exactly the half that
distinguishes it, to save a single row of height.
Importing a template on the recommended one-container image asked the person to type an address for
a coworker the template had just told them runs on this deployment. The template's own page said
"Runs on this deployment itself." while the consent screen said "An address is needed before this
coworker can be imported." Both read `runtime: managed`; they disagreed about what it means.

The chain was: OpenBot has two agent types, `built_in` — in-process, driven by
`configuration.systemPrompt`, which is what General Assistant and Knowledge are — and
`remote_ag_ui`, which needs an address. The format refuses `built_in`, so an import could only ever
produce a remote Bot, and `runtime: managed` meant "point at MANAGED_AGENT_AG_UI_URL". The image the
README recommends runs no such process, so `resolve.ts` fell back to demanding an address.

Now `endpointRequired` is `runtime === "remote"` and nothing else, and the plan carries `runsOn`, so
the consent screen can say which of the three actually applies rather than inferring it. Where there
is no Bot in the box, `create` writes a `built_in` row whose system prompt is the template's
`role_description`.

THIS DOES NOT WIDEN WHAT TRAVELS, and the distinction is worth being exact about. A template still
cannot carry a `system_prompt`, and `type: built_in` is still not expressible in the format —
exporting a shipped built-in Bot is still not a faithful round trip. What changed is only what a
DEPLOYMENT does with `runtime: managed` when it has nothing to bind to. The text it runs on is
`role_description`, which already reached a model as the standing message for every remote import,
and which the consent screen already renders verbatim under "This text is given to a model as
instructions. It was written by a stranger."

It is also the safer of the two behaviours. The old one pushed somebody wanting to try a template
into registering a third-party endpoint, after which every message to that coworker left their
network. This one keeps the conversation on the deployment. No grant, credential, MCP server or
policy rule travels; the ceiling compiles exactly as before; and a key is deliberately not written on
this branch, because a key authenticates to an address and this coworker has none.

Creating a Bot with no address, no Bot in the box and no instruction to run on still throws
`ManagedAgentUnavailableError`: a `built_in` row with an empty prompt is one `registeredAgentFromRow`
drops, which would put a coworker on every screen that answers nobody.

Verified against a running server rather than only in tests: preview reports
`runsOn: "in_process"` and `endpoint.required: false`, install returns 201 with no endpoint in the
body, and the row is `built_in` whose `systemPrompt` is md5-identical to the template's
`role_description`, with `systemPrompt` its only configuration key and no credential written.
2467 tests, 0 fail.
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.

1 participant