Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 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
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
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ 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).
- **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
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