Send somebody a coworker, and grant it nothing on arrival - #299
Draft
jerelvelarde wants to merge 39 commits into
Draft
Send somebody a coworker, and grant it nothing on arrival#299jerelvelarde wants to merge 39 commits into
jerelvelarde wants to merge 39 commits into
Conversation
…te does on the trail
… and install it granting nothing
…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.
jerelvelarde
force-pushed
the
feat/bot-templates
branch
from
August 30, 2026 20:16
67a0d55 to
3c4993f
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The same walkthrough as video, at full size: openbot-templates-catalogue.mp4
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
expandEnvironmentsubstitutes${NAME}out of the server's own environment before the YAML is parsed.duplicatecopies fourpresentation fields, drops the endpoint and every grant, and throws
ManagedAgentUnavailableErroroutright 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:137concludes askill 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_grantskind'skill', markedtemplate:<digest12>so a retraction takes back whatthe 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
xandfind-— both installcleanly 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:
grantedToolGuidancebuilds its self-description from the tools it wasactually 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 clausesscoped 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
matchesthrows on a bad pattern and a throwing denycounts 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 intoaction_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.categoryis 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.mdcarries an ASCII map of these screens, marking which write nothing at alland which single controls write.
What is not covered
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.
authoris a claim,rendered as one. Pin a commit you have read.
template.versionis a string nothing reads. Security fixes do notpropagate either — that is the trade, and auto-update has no safe form without publisher identity.
type: built_incannot travel, so exporting a shipped fintech Bot is not a faithful round trip.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,lintandtypecheckclean.bun run test:cireports 2435 pass, 0 fail againsta baseline of 2149 on
main— 286 new tests. All 27 templates parse through the realparseBotTemplate, 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/templatesanswered 404 on a real server while every route test passed; the packer's ownsecret-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 testrunsevery 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", sothe first file to reach
@/lib/auth/clientthrew while being imported; andauth-client.test.tsassigns a window with a
locationand nothing else, which leftwindow.historyundefined forapp/src/router.tsx. Two of those fixes touch files this feature does not otherwise go near; they arehere because these tests are what made them reachable.
shared/bot-template.test.tsserver/tests/template-pack.test.tsserver/tests/template-install.integration.test.tsserver/tests/template-routes.integration.test.tsserver/tests/template-boundary.test.tsserver/tests/template-boundary-enforcement.integration.test.tsserver/tests/template-catalogue.test.tsserver/tests/template-gallery-routes.integration.test.tsapp/tests/import-template.test.tsxapp/tests/template-gallery.test.tsxapp/tests/template-categories.test.tsapp/tests/template-detail.test.tsxapp/tests/export-template.test.tsxserver/tests/template-sources.integration.test.tsA 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.