Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d975052
Give a Bot template a format that refuses what it must not carry
jerelvelarde Aug 30, 2026
b2f0e8b
Keep a template's draft, its provenance and the asks it made in their…
jerelvelarde Aug 30, 2026
0b74717
Let a skill be installed inside a transaction, and name what a templa…
jerelvelarde Aug 30, 2026
823c520
Pack a coworker into a template, resolve one against this deployment,…
jerelvelarde Aug 30, 2026
cdf945c
Show a person every word a stranger wrote before any of it reaches a …
jerelvelarde Aug 30, 2026
94dff75
Ship three templates in the box, and a check that keeps them installable
jerelvelarde Aug 30, 2026
a82f5c4
Build the template store where every other store is built, or the rou…
jerelvelarde Aug 30, 2026
af89084
Hold a connector id to the rule an MCP server id is actually held to
jerelvelarde Aug 30, 2026
6dbdfc8
Refuse to grant an ask this deployment was told was inert
jerelvelarde Aug 30, 2026
be61c28
Cover the refusals the import path was trusting rather than testing
jerelvelarde Aug 30, 2026
3c69c04
Write a skill's trail on the connection its caller already holds
jerelvelarde Aug 30, 2026
9397257
Show a stranger's words in full, and stop offering a grant that will …
jerelvelarde Aug 30, 2026
3c4993f
Give the connection back when an integration test is done with it
jerelvelarde Aug 30, 2026
3b48185
Give the test DOM an address, so the auth client can be imported first
jerelvelarde Aug 30, 2026
b41156d
Stop the window stub replacing a DOM another file installed
jerelvelarde Aug 30, 2026
c353a86
Compile a template's ceiling into rules the policy engine already und…
jerelvelarde Aug 31, 2026
5d5cb64
Enforce an imported Bot's ceiling beside the deployment's own rules
jerelvelarde Aug 31, 2026
df12607
Say the ceiling is enforced, now that it is
jerelvelarde Aug 31, 2026
e13508c
Offer the templates this image ships, and the ones a repository is pi…
jerelvelarde Aug 31, 2026
e35dc31
Serve the gallery, and read the file from it rather than from the bro…
jerelvelarde Aug 31, 2026
97fb84e
Give the catalogue a screen, and the imports a place to be read
jerelvelarde Aug 31, 2026
8b68f1a
Install a DOM before this file loads the app, not after
jerelvelarde Aug 31, 2026
d8ac8cf
Write down the catalogue, the pins and the ceiling
jerelvelarde Aug 31, 2026
b2f892a
Emit the template directory only when a release sets one
jerelvelarde Aug 31, 2026
531c188
Serve the screen what an administrator wrote, not what is in force
jerelvelarde Aug 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,37 @@ KEY_ENCRYPTION_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
PORT=3001
SERVER_PORT=3001
TENANT_PACKAGE_DIR=../examples/fintech
# Where the Bot templates shipped in the box are, resolved from server/ as the line above is. The
# image carries examples/, so a deployment with no network at all still opens a populated gallery.
# Unset, it is the line below.
#
# A directory that is not there is a gallery with nothing in it rather than a deployment that will
# not start, and one file in it that does not parse is named in the log and passed over rather than
# taking the others with it. These are many authors' files, and one person's typo must not stop
# somebody else booting -- which is the opposite of how the tenant package above is read.
# OPENBOT_TEMPLATE_DIR=../examples/templates
#
# The repositories an administrator may register as a template source, comma separated as
# owner/repo, and there are none unless this names one. Nothing is fetched from the network until
# somebody has both named a repository here and registered a pinned commit in the product: a
# self-hosted product that reaches a third party on first boot because its vendor shipped a default
# has made that decision on its operator's behalf. The admin screen renders this list and cannot
# widen it, the way it renders INITIAL_ADMIN_EMAILS.
#
# owner/repo only. A URL, a branch or a pin here is refused at startup with the entry named: the pin
# is registered in the product, and it must be a commit sha rather than a branch somebody else can
# repoint after you have read the files. The reference catalogue is the line below.
# OPENBOT_TEMPLATE_SOURCES=jerelvelarde/awesome-openbot-templates
#
# Who may install a template: anyone, or admin. Unset means anyone, because everything an install
# writes -- the Bot, its skills, and the grants that pair the two -- is what the same person can
# already do one act at a time on /agents, /skills and the grant screens. There is no fourth call to
# put behind an administrator.
#
# What is set here is a floor. The product may raise it to admin and may never lower it back, so a
# deployment that restricts installs can rely on it holding rather than on nobody clicking it away.
# Anything other than these two words is refused at startup rather than quietly read as anyone.
# OPENBOT_TEMPLATE_INSTALLERS=anyone
# What this deployment calls itself, when more than one shares an Intelligence project. A copy of a
# deployment made for development uses the same project key, and threads are listed per Bot with
# nothing to say which deployment a conversation came from. The name goes into every thread id this
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ jobs:
- run: bun run format:check
- run: bun run lint
- run: bun run typecheck
# The templates that ship in the box, read with the same parser the server runs at preview and
# again at install. Nothing else in the build reads them: they are data copied into the image,
# so one that does not parse compiles, ships, and is refused for the first time on somebody
# else's deployment at the moment they try to install it. This is the only place that failure
# is ours rather than theirs.
- run: bun scripts/check-bot-templates.ts

# The two packages that are deployables in their own right rather than root workspaces. Root
# `typecheck` is `bun run --filter '*' typecheck`, and `--filter '*'` enumerates `workspaces`, which
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ Leave `EMBEDDED_POSTGRES` off and set `DATABASE_URL` to point at a database you
| -------------------- | ------------------------------------------------------------------ |
| `/` | Start and browse channels. |
| `/agents` | Create, edit, duplicate, hide, delete, and launch coworkers. |
| `/agents/gallery` | Browse the templates this deployment carries and import one. |
| `/channel/:id` | Converse with one coworker, watch its screen, and see what it ran. |
| `/bot` | Direct chat with a Bot; `?agent=<id>` selects one. |
| `/skills` | Create and enable personal skills. |
Expand All @@ -133,6 +134,7 @@ Leave `EMBEDDED_POSTGRES` off and set `DATABASE_URL` to point at a database you
| `/admin/components` | Publish components and govern which Bots may use them. |
| `/admin/playground` | Draft and publish sandboxed components in the browser. |
| `/admin/plugins` | Configure MCP servers, MCP grants, and deployment skills. |
| `/admin/templates` | Register a pinned template source and decide who may install. |
| `/admin/audit` | Review permitted, refused, and failed actions. |

## Features
Expand All @@ -148,6 +150,8 @@ Leave `EMBEDDED_POSTGRES` off and set `DATABASE_URL` to point at a database you
- **Components instead of prose**: compiled React components live in `app/src/components/gallery/`, sandboxed ones are authored in `/admin/playground` and published with no deployment. Every call asks the server whether the component exists, is published, and is not withheld from that Bot. Data functions are granted per component.
- **Governed MCP**: Google Drive and Notion ship in the catalogue, reached as the person asking. The catalogue carries only vendors this deployment stands behind, so adding one is a review of that vendor. Custom servers must pass URL checks; unknown tools and custom-server tools are treated as writes, and a catalogue tool the server advertises but does not name as a write classifies as a read. A Bot is told which connectors exist here and which it holds, so it says it has not been granted one rather than browsing to the vendor's website.
- **Skills are instructions, not capabilities**: personal skills attach only to Bots their author owns, deployment skills are admin-owned, and both are invoked with `/` in the composer.
- **A coworker as a portable file**: export a Bot to one YAML file — its role, its skills, and the connectors it asks for — and import it on another deployment. Configuration travels; capability does not: a template carries no id, no endpoint, no credential and no grant, and a document containing one fails to parse rather than being quietly stripped. What it wanted lands as a request an administrator decides on the screens that already decide it, so an imported Bot arrives cold and says so. The importer is shown every word a stranger wrote, verbatim, before any of it reaches a model. See [docs/bot-templates.md](docs/bot-templates.md).
- **A gallery in the box, and a catalogue that grows by a push**: `examples/templates/` is copied into the image, so a deployment with no network at all opens `/agents/gallery` and finds something in it. Each file is parsed on its own, and one that does not parse is named and passed over rather than stopping a boot. Beyond that seed an administrator registers a public repository as a source, and only one the deployment's own `OPENBOT_TEMPLATE_SOURCES` names, pinned to a commit sha and fetched server-side: nothing reaches the network until somebody asks for it, and moving the pin is the only update there is.
- **Sign in with what your company already has**: Google, Microsoft or Okta from the environment, or a company's own SAML or OpenID Connect provider registered while the deployment runs and routed by email domain. Any one turns sign-in on; several may be configured at once.
- **Decide who gets in**: `/admin/people` lists everybody who has signed in, promotes and demotes them, and removes access, which ends the session they are using and stops the next sign-in. Every change is on the audit trail.
- **An audit trail you can read**: `/admin/audit` lists what was permitted, what was refused and what failed, and every refusal carries the rule that caused it.
Expand Down
6 changes: 6 additions & 0 deletions app/src/components/admin/admin-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
IconKey,
IconLayoutGrid,
IconListDetails,
IconPackageImport,
IconPuzzle,
IconShieldCheck,
IconUsers,
Expand Down Expand Up @@ -61,6 +62,11 @@ const GROUPS: {
icon: IconDeviceDesktop,
linkOptions: { to: "/admin/computers" },
},
{
title: "Templates",
icon: IconPackageImport,
linkOptions: { to: "/admin/templates" },
},
],
},
{
Expand Down
26 changes: 26 additions & 0 deletions app/src/components/agents/agent-profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { type ReactNode, useState } from "react";
import { AbstractAvatar } from "@/components/agents/abstract-avatar";
import { AgentFields } from "@/components/agents/agent-fields";
import { CallbackTokenPanel } from "@/components/agents/callback-token-panel";
import { ExportTemplate } from "@/components/agents/export-template";
import { HandoffPanel } from "@/components/agents/handoff-panel";
import { TemplateRequests } from "@/components/agents/template-requests";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
Expand All @@ -16,6 +18,7 @@ import {
updateAgentMutationOptions,
} from "@/lib/agents/mutations";
import { agentQueryOptions } from "@/lib/agents/queries";
import { templateImportQueryOptions } from "@/lib/templates/queries";

function Tag({ children }: { children: ReactNode }) {
return (
Expand Down Expand Up @@ -65,6 +68,13 @@ export function AgentProfile({ agentId }: { agentId: string }) {
const isConfirmingDelete = confirmingDeleteId === agentId;

const agent = useQuery(agentQueryOptions(agentId));
/*
* Where this coworker came from, or nothing — most were made by hand and this answers null for
* them without an error. Read here as well as inside the panel below because the tag belongs in
* the header beside the other things that are true of the Bot rather than in a section further
* down; both reads are the same cache entry.
*/
const imported = useQuery(templateImportQueryOptions(agentId));
const updateAgent = useMutation(updateAgentMutationOptions(queryClient));
const duplicateAgent = useMutation(
duplicateAgentMutationOptions(queryClient),
Expand Down Expand Up @@ -107,6 +117,12 @@ export function AgentProfile({ agentId }: { agentId: string }) {
<div className="flex flex-wrap justify-center gap-1.5">
<Tag>{profile.visibility === "private" ? "Private" : "Public"}</Tag>
{profile.systemOwned ? <Tag>System owned</Tag> : null}
{/*
* Said out loud, permanently. An imported coworker is an ordinary Bot in every other
* respect — owned, editable, deletable — and the one fact that does not follow from
* looking at it is that its instructions were written somewhere else by somebody else.
*/}
{imported.data ? <Tag>Imported</Tag> : null}
</div>
</header>

Expand Down Expand Up @@ -162,6 +178,8 @@ export function AgentProfile({ agentId }: { agentId: string }) {
*/}
{isEditing ? null : <HandoffPanel agentId={agentId} />}

{isEditing ? null : <TemplateRequests agentId={agentId} />}

{actionError ? (
<p className="text-sm text-destructive" role="alert">
{actionError.message}
Expand Down Expand Up @@ -194,6 +212,14 @@ export function AgentProfile({ agentId }: { agentId: string }) {
{duplicateAgent.isPending ? "Duplicating…" : "Duplicate"}
</Button>

{/*
* Beside Duplicate, because it is the same verb pointed somewhere else: Duplicate makes
* another copy here, Export makes one that can leave. A system-owned Bot is offered it as
* well as an owned one — those are the most template-worthy things in the product, and
* Duplicate already lets anybody fork them.
*/}
<ExportTemplate agentId={agentId} />

<Button
className="w-full text-sm!"
disabled={setHidden.isPending}
Expand Down
180 changes: 180 additions & 0 deletions app/src/components/agents/export-template.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
type ExportedTemplate,
exportAgentTemplateMutationOptions,
updateTemplateDraftMutationOptions,
} from "@/lib/templates/mutations";
import { queryClient } from "@/query-client";

/**
* Packing a coworker into a file somebody else can read.
*
* A DRAFT RATHER THAN A DOWNLOAD, which is the whole reason this is a panel and not a button that
* saves a file. Two things about a packed coworker are wrong until an author fixes them by hand:
* the `requests` block is derived from what this Bot happens to have been granted here, which is
* not the same as what it needs, and `boundary:` is written out at its strictest so that the author
* widens exactly what the coworker uses rather than exporting a stock deployment's "allow
* everything" as a requirement. Neither is something the packer can know.
*
* WHAT IS NOT IN THE FILE is the interesting half, and the server names every stripped field rather
* than leaving the absence to be noticed. A person about to hand this to somebody needs to be told
* that the address, the key and the callback token did not travel — and, for one of the Bots that
* ship in the box, that its behaviour did not either.
*/
export function ExportTemplate({ agentId }: { agentId: string }) {
const exportTemplate = useMutation(
exportAgentTemplateMutationOptions(queryClient),
);
const saveDraft = useMutation(
updateTemplateDraftMutationOptions(queryClient),
);

const [draft, setDraft] = useState<ExportedTemplate | null>(null);
/** What is in the box, which is the draft until somebody types in it. */
const [text, setText] = useState("");
/** What the server last accepted, so Download is never offered a file nobody has parsed. */
const [saved, setSaved] = useState("");
const [copied, setCopied] = useState(false);

const dirty = draft !== null && text !== saved;

if (!draft) {
return (
<>
<Button
className="w-full text-sm!"
disabled={exportTemplate.isPending}
onClick={async () => {
const packed = await exportTemplate.mutateAsync(agentId);
setDraft(packed);
setText(packed.yaml);
setSaved(packed.yaml);
}}
variant="outline"
>
{exportTemplate.isPending ? "Exporting…" : "Export template"}
</Button>
{/*
* The packer refuses rather than truncating, so this sentence is usually actionable: a
* skill slug the format will not admit, prose past a ceiling, or something in the Bot's own
* text shaped like a key. None of the three is a fault in the export; each is a thing to
* fix on the coworker.
*/}
{exportTemplate.error ? (
<p className="text-destructive text-sm" role="alert">
{exportTemplate.error.message}
</p>
) : null}
</>
);
}

return (
<section className="grid gap-2">
<h2 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Template draft
</h2>

<p className="text-muted-foreground text-sm">
Read it before you send it. Widen the boundary to what this coworker
actually needs, and cut anything in the requests it does not.
</p>

<Textarea
aria-label="Template file"
className="max-h-[50vh] min-h-48 overflow-y-auto font-mono text-xs"
onChange={(event) => {
setCopied(false);
setText(event.target.value);
}}
spellCheck={false}
value={text}
/>

{draft.stripped.length > 0 ? (
<div className="grid gap-1 rounded-lg border border-border bg-muted/40 p-3">
<p className="font-medium text-xs">What did not travel</p>
<ul className="grid gap-1">
{draft.stripped.map((line) => (
<li className="text-muted-foreground text-xs" key={line}>
{line}
</li>
))}
</ul>
</div>
) : null}

{saveDraft.error ? (
<p className="text-destructive text-sm" role="alert">
{saveDraft.error.message}
</p>
) : null}

{dirty ? (
<Button
className="w-full text-sm!"
disabled={saveDraft.isPending}
onClick={async () => {
const next = await saveDraft.mutateAsync({
templateId: draft.templateId,
source: text,
});
// The server's serialisation, not the text that was posted: the parser is what decides
// what this file says, and an author should be reading the form it will travel in.
setText(next.yaml);
setSaved(next.yaml);
}}
variant="outline"
>
{saveDraft.isPending ? "Saving…" : "Save changes"}
</Button>
) : null}

<div className="flex gap-2">
{/*
* A link to the file route rather than a Blob built here, so the bytes that are saved are
* the bytes the server holds and the filename is the one the slug already fixed. It is
* withheld while there are unsaved edits, because a download that quietly hands over the
* previous version is worse than one that is not offered.
*/}
<Button
className="flex-1 text-sm!"
disabled={dirty}
render={
dirty
? undefined
: (props) => (
<a
{...props}
download
href={`/api/templates/${draft.templateId}/file`}
/>
)
}
variant="outline"
>
Download
</Button>
<Button
className="flex-1 text-sm!"
onClick={async () => {
await navigator.clipboard.writeText(text);
setCopied(true);
}}
variant="outline"
>
{copied ? "Copied" : "Copy"}
</Button>
</div>

{dirty ? (
<p className="-mt-1 text-muted-foreground text-xs">
Save your changes and the file will be there to download.
</p>
) : null}
</section>
);
}
Loading