diff --git a/.env.example b/.env.example index cb3c4ac6..a0aafd04 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7de4b2c0..24467594 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index 9bb0801c..3611cbda 100644 --- a/README.md +++ b/README.md @@ -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=` selects one. | | `/skills` | Create and enable personal skills. | @@ -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 @@ -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. diff --git a/app/src/components/admin/admin-sidebar.tsx b/app/src/components/admin/admin-sidebar.tsx index 47aa5b46..39e4db74 100644 --- a/app/src/components/admin/admin-sidebar.tsx +++ b/app/src/components/admin/admin-sidebar.tsx @@ -7,6 +7,7 @@ import { IconKey, IconLayoutGrid, IconListDetails, + IconPackageImport, IconPuzzle, IconShieldCheck, IconUsers, @@ -61,6 +62,11 @@ const GROUPS: { icon: IconDeviceDesktop, linkOptions: { to: "/admin/computers" }, }, + { + title: "Templates", + icon: IconPackageImport, + linkOptions: { to: "/admin/templates" }, + }, ], }, { diff --git a/app/src/components/agents/agent-profile.tsx b/app/src/components/agents/agent-profile.tsx index f74555d6..0c52d89e 100644 --- a/app/src/components/agents/agent-profile.tsx +++ b/app/src/components/agents/agent-profile.tsx @@ -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"; @@ -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 ( @@ -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), @@ -107,6 +117,12 @@ export function AgentProfile({ agentId }: { agentId: string }) {
{profile.visibility === "private" ? "Private" : "Public"} {profile.systemOwned ? System owned : 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 ? Imported : null}
@@ -162,6 +178,8 @@ export function AgentProfile({ agentId }: { agentId: string }) { */} {isEditing ? null : } + {isEditing ? null : } + {actionError ? (

{actionError.message} @@ -194,6 +212,14 @@ export function AgentProfile({ agentId }: { agentId: string }) { {duplicateAgent.isPending ? "Duplicating…" : "Duplicate"} + {/* + * 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. + */} + + + {/* + * 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 ? ( +

+ {exportTemplate.error.message} +

+ ) : null} + + ); + } + + const packed = draft.template; + + return ( +
+

+ Template draft +

+ + {/* + * SAID PLAINLY, because the alternative is somebody wondering why their edits are back. + * + * A person who presses Export twice is not asking for two files. They get the one they already + * have, and this says so rather than leaving them to notice that the draft is not what the + * packer would write today. The re-pack is offered right underneath, and it says what it + * costs: the fresh pack replaces the document, edits included. + */} + {repack !== null ? ( +
+

+ You had already exported this coworker. This is that draft, with the + changes you made to it — nothing was packed over them. +

+ +

+ Re-packing replaces this draft with the coworker as it is now. What + you wrote in it goes with it. +

+
+ ) : null} + +

+ Read it before you send it. Widen the boundary to what this coworker + actually needs, and cut anything in the requests it does not. +

+ + {/* + * AN INVENTORY BEFORE THE FILE, and the ordering is the change worth stating. + * + * This panel used to open with the YAML, which answered "what are the bytes" when the question + * somebody actually has at this moment is "what did I just hand over". A wall of configuration + * is a poor answer to that, and it pushed the half that reassures — what did NOT travel — + * below the fold on a panel nobody scrolled. The file is still here, one press away, because a + * person about to send this to somebody must be able to read every byte of it. + */} +
+

What travels

+
    +
  • Its name, its role, and the instructions you wrote for it.
  • +
  • + {packed.skills.length === 0 + ? "No skills." + : `${packed.skills.length === 1 ? "1 skill" : `${packed.skills.length} skills`}: ${packed.skills + .map((skill) => skill.slug) + .join(", ")}. They become the importer's own.`} +
  • +
  • + {packed.requests.connectors.length === 0 + ? "It asks for no connectors." + : `It ASKS for ${packed.requests.connectors + .map((connector) => connector.id) + .join(", ")}. An ask is not a grant.`} +
  • +
  • + A ceiling: shell {packed.boundary.shell}, files{" "} + {packed.boundary.files}, browser {packed.boundary.browser}, mcp{" "} + {packed.boundary.mcp}. +
  • +
+
+ + {draft.stripped.length > 0 ? ( +
+

+ What did not travel ({draft.stripped.length}) +

+
    + {draft.stripped.map((line) => ( +
  • + {line} +
  • + ))} +
+
+ ) : null} + + {/* + * The file, behind one press rather than gone. Open by default the moment there are unsaved + * edits, so a person never has to hunt for the box they are being told to save. + */} + + + {showFile || dirty ? ( +