Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ The project structure is:
* packages/workshop-backend: The Gadgets Workshop server.
* Runs on Cloudflare Workers.
* This is the **kernel**: it defines the architecture and is held to a higher bar than UI/gatekeeper code. Reviewers read *every line* of `workshop-backend` and of API changes in `workshop-shared`, so keep diffs here small and elegant. Concretely: doc-comment **every** exported member of the `workshop-shared` public API (types, consts, and functions — not just interfaces); never introduce a hand-written interface that mirrors an RPC interface plus an `as unknown as` cast (derive from the real type instead, or rethink the design); and prefer reusing existing mechanisms over adding parallel ones. Capability-based security note: a resource becomes "ambient" (auto-injected) only by user/admin configuration — a gatekeeper must never assert its own ambience. When a change to this package is large, split it by concern into separate PRs (and at minimum group commits so `workshop-backend`/`workshop-shared` can be reviewed apart from UI), since fewer kernel lines = easier review.
* `format-blueprints/` holds the **output format** blueprints the deployment ships with, committed as data: a `<name>.gadget` archive plus a `<name>.json` sidecar giving its `blueprintId`, prose, and `output` presentation. `scripts/build-format-blueprints.mjs` globs that directory (override with `FORMAT_BLUEPRINTS_DIR`, which lets a fork ship its own set without touching this submodule) into the gitignored `src/generated/format-blueprints.ts`, so `build` and `test` both run the generator first (it rewrites the module only when the content changes, so the repeat invocations don't invalidate the downstream task cache). Replace one with `pnpm import:format-blueprint <export.gadget> <blueprintId>`, or add one with `pnpm import:format-blueprint <export.gadget> --new <name>`; never edit a `blueprintId` after deploy, since the install and promotion are keyed on it and a rename orphans the old entry. See `format-blueprints/README.md`.
* `format-blueprints/` holds the **output format** blueprints the deployment ships with, committed as reviewable source: each `<name>/` contains `blueprint.json` plus the gadget code under `files/`. `scripts/build-format-blueprints.ts` reconstructs their archive representation (override the source directory with `FORMAT_BLUEPRINTS_DIR`) into the gitignored `src/generated/format-blueprints.ts`, so `build` and `test` both run the generator first. Replace one with `pnpm import:format-blueprint <export.gadget> <blueprintId>`, or add one with `pnpm import:format-blueprint <export.gadget> --new <name>`; never edit a `blueprintId` after deploy, since the install and promotion are keyed on it and a rename orphans the old entry. See `format-blueprints/README.md`.
* packages/workshop-shared: Shared API definitions between client and server.
* This defines the application's RPC interface.
* The RPC protocol is Cap'n Web, which has similar semantics to Cloudflare's Worker-to-Worker RPC system, while being able to run in a browser over WebSocket. Read the readme for details.
Expand Down
4 changes: 2 additions & 2 deletions docs/blueprints.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,11 @@ A **format** is an ordinary blueprint the deployment has promoted, so that "New

What a blueprint may declare is `BlueprintMetadata.output`: a grouping `id`, a `noun` and `plural` ("Doc"/"Docs"), and an `icon` from the closed `OUTPUT_ICONS` set. A gadget instantiated from the blueprint inherits it, and that is what the workspace tab, chat cards and the Outputs page draw. Declaring it is presentation only and grants nothing -- any user can publish a blueprint calling itself a Document. Being *offered* as one of the deployment's standard formats is the separate, admin-curated decision. An admin can override any of these fields (`FormatCuration.overrides`), and the override is applied on every instantiation path, so a rename reaches gadgets the agent builds as well as ones made from the menu.

A deployment can also ship blueprints as data. `packages/workshop-backend/format-blueprints/` holds a `<name>.gadget` archive plus a `<name>.json` sidecar for each, and `scripts/build-format-blueprints.mjs` bundles that directory (overridable with `FORMAT_BLUEPRINTS_DIR`, so a fork can ship its own set) into a generated module. These differ from published blueprints in three ways:
A deployment can also ship blueprints as data. `packages/workshop-backend/format-blueprints/` holds a directory for each blueprint with a `blueprint.json` manifest and reviewable files under `files/`. `scripts/build-format-blueprints.ts` reconstructs their ordinary archive representation and bundles it into a generated module (overridable with `FORMAT_BLUEPRINTS_DIR`, so a fork can ship its own set). These differ from published blueprints in three ways:

- Their IDs are **stable and readable** (`format.document`, not a random hex ID), because both installation and promotion are keyed on them. Renaming one after deploy orphans the old entry rather than moving it.
- They have **no owning User DO**. `AdminSettings` writes them straight into the featured mirror, because there is no publishing user whose `featured` bit could be authoritative.
- Their `output` lives in the sidecar rather than the archive, so the deployment's presentation has a single source of truth.
- Their `output` lives in `blueprint.json`, so the deployment's presentation has a single source of truth.

The first `/api` request a deployment serves installs any whose manifest fingerprint has changed. The fingerprint covers its title, description, author, revision, and output presentation; `revision` represents changes to the archive bytes. Each bundled blueprint is promoted only once ever -- an upgrade never undoes an admin's later removal or overrides.

Expand Down
64 changes: 64 additions & 0 deletions packages/workshop-backend/__tests__/format-blueprint-files.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
buildContent,
extractFiles,
parseArchive,
readSourceFiles,
serializeArchive,
} from "../scripts/format-blueprint-files.ts";

const temporaryDirectories: string[] = [];

afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map(path =>
rm(path, {recursive: true, force: true})));
});

describe("format blueprint source", () => {
it("reconstructs files deterministically", () => {
let files = new Map([
["server.js", "export default {};\n"],
["empty.txt", ""],
["client.js", "console.log('hello');\n"],
]);
let metadata = {
title: "Example",
description: "Example blueprint",
author: {type: "user", name: "Test", id: "test@example.com"},
created: "2026-01-01T00:00:00.000Z",
version: 1,
lastUpdated: "2026-01-01T00:00:00.000Z",
bindings: {},
};

let first = serializeArchive(metadata, buildContent(files, "example"), "example");
let second = serializeArchive(metadata, buildContent(files, "example"), "example");

expect(second).toEqual(first);
let parsed = parseArchive(first, "example");
expect(parsed.metadata).toEqual(metadata);
expect(extractFiles(parsed.content, "example")).toEqual(files);
});

it("rejects non-UTF-8 source", async () => {
let directory = await mkdtemp(join(tmpdir(), "format-blueprint-"));
temporaryDirectories.push(directory);
await writeFile(join(directory, "client.js"), Uint8Array.of(0xff));

await expect(readSourceFiles(directory, "example/files"))
.rejects.toThrow("client.js is not valid UTF-8");
});

it("rejects symlinks", async () => {
let directory = await mkdtemp(join(tmpdir(), "format-blueprint-"));
temporaryDirectories.push(directory);
await writeFile(join(directory, "source.js"), "source");
await symlink(join(directory, "source.js"), join(directory, "client.js"));

await expect(readSourceFiles(directory, "example/files"))
.rejects.toThrow("client.js must not be a symlink");
});
});
17 changes: 15 additions & 2 deletions packages/workshop-backend/__tests__/format-blueprints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,12 @@ describe("bundled format blueprints", () => {
// No owning user: these belong to the deployment, so the owner-anchored featured toggle
// must not apply to them.
expect(record.ownerId).toBeUndefined();
// Presentation comes from the sidecar, not from whatever the archive was called in the
// Presentation comes from the source manifest, not from whatever the archive was called in the
// workspace it was exported from.
expect(record.metadata.title).toBe(entry.title);
expect(record.metadata.description).toBe(entry.description);
expect(record.metadata.author).toEqual(entry.author);
// The sidecar's declaration is written into the installed blueprint, so from here on the
// The manifest's declaration is written into the installed blueprint, so from here on the
// blueprint declares its own format like any other.
expect(record.metadata.output).toEqual(entry.output);
// ...and it survives the same validation an uploaded archive's would.
Expand Down Expand Up @@ -140,6 +140,19 @@ describe("bundled format blueprints", () => {
}
});

it.skipIf(FORMAT_BLUEPRINTS.length === 0)(
"changes the manifest version when bundled source changes", () => {
let entry = FORMAT_BLUEPRINTS[0];
let before = formatBlueprintsManifestVersion();
let original = entry.contentHash;
try {
entry.contentHash = `${original}-changed`;
expect(formatBlueprintsManifestVersion()).not.toBe(before);
} finally {
entry.contentHash = original;
}
});

// Curated text is the input most likely to be edited -- it is the whole point of keeping it in a
// text file -- and an edit that doesn't reach deployments which already installed would be
// invisible: the build succeeds and the old wording stays put.
Expand Down
119 changes: 39 additions & 80 deletions packages/workshop-backend/format-blueprints/README.md
Original file line number Diff line number Diff line change
@@ -1,111 +1,70 @@
# Bundled format blueprints

This directory contains the output-format blueprints that ship with this repo, so a fresh deployment
can write a doc or build a deck without anyone having to build one first. A *format* is an ordinary
blueprint the deployment has promoted (see `AdminConfig.formats`); these are the ones it promotes out
of the box.
This directory contains the output-format blueprints that ship with this repo. A fresh deployment
installs them into BLUEPRINTS KV and BLUEPRINT_CONTENT R2 on its first `/api` request, after which
they are ordinary blueprints.

Each `.gadget` file here is committed **as data**. The first `/api` request a deployment serves
installs them into the BLUEPRINTS KV namespace and BLUEPRINT_CONTENT R2 bucket (see
`src/format-blueprints.ts`), after which they are ordinary blueprints. Nothing wakes on deploy, so a
fresh deployment is provisioned by its first visitor.
## Layout

## Who owns what
Each blueprint is committed as reviewable source:

A blueprint is two files with the same stem:
```
workspace-docs/
blueprint.json
files/
README.md
client.js
server.js
```

| | lives in | why |
| --- | --- | --- |
| the code, and the `bindings` it needs | `<name>.gadget` | what the blueprint *does* |
| `blueprintId`, title, description, `output` (noun/plural/icon), author, `revision` | `<name>.json` | what a human *curates*, so it's text in a reviewable file rather than fields inside a binary |
`files/` is the gadget's code. `blueprint.json` contains its install ID, presentation, provenance,
bindings, blueprint `version`, and bundled `revision`. The build converts these files into the same
gzip-compressed Yjs `.gadget` representation used by uploaded blueprints and embeds it in the
generated Worker module. No binary archive is committed.

The installer writes the sidecar's values over whatever the archive happens to carry,
so an archive's own title and author are inert. The import script normalizes them anyway,
so the committed bytes don't contradict the sidecar.
`blueprintId` is the install key. Never change it after deployment: the new ID would install a
second format while the old one remained. `version` is the blueprint's published content version
and R2 key. The build fingerprints the generated archive, so direct edits under `files/` reinstall
automatically. `revision` remains an explicit reinstall trigger and is bumped by the importer.

## Changing a title, description or author
## Editing presentation

Edit the sidecar and rebuild. That's all — no archive rewrite, no `revision` bump: everything that
ends up in the installed metadata is part of the installed-version fingerprint (see
`formatBlueprintsManifestVersion`), so a reinstall follows on the next deploy.
Edit `blueprint.json` and rebuild. Changes to title, description, output, or author are included in
the install fingerprint and do not need a `revision` bump.

## Updating a blueprint's code
## Updating code

Build it in a real Workshop, export it, and import the export:
Build the blueprint in a Workshop, export it, then import the export:

```
pnpm import:format-blueprint ~/Downloads/Gadgets-Doc-v4.gadget format.document
```

That rewrites the archive, bumps `revision` in the sidecar, rebuilds
`src/generated/format-blueprints.ts`, and reports what it did:

```
Updated workspace-docs.gadget (format.document)
code 23668 -> 24489 bytes (7c5413e5a482)
bindings (none)
version 3 -> 4
revision 2 -> 3 (workspace-docs.json)

presented as "Workspace Docs" by Cloudflare, from workspace-docs.json
[export called it "Gadgets Doc"]
```

The line worth reading is **`bindings`**, flagged `[CHANGED]` when the export needs something the old
copy didn't — an instantiating user will now be asked for it. `revision` is the reinstall trigger for
the one input the fingerprint can't see, the archive bytes; it's automated because forgetting it is
invisible (everything builds and deploys, and the old blueprint quietly stays put).

The script also round-trips the bytes it just wrote and checks the metadata and a content hash,
because these are committed as data and a corrupt archive would otherwise first surface when a
deployment tried to install it.
The importer replaces `files/`, updates archive-owned metadata (`created`, `version`, `lastUpdated`,
and `bindings`), bumps `revision`, rebuilds `src/generated/format-blueprints.ts`, and reports changed
files and bindings. Review the resulting source diff normally.

## Adding a new format

`--new` writes the sidecar for you, filling in what it can from the export:
## Adding a format

```
pnpm import:format-blueprint ~/Downloads/Brief.gadget --new acme-brief
```

It then prints the handful of fields worth editing before you deploy — chiefly `output`, the noun,
plural and icon the sidecar owns rather than the archive, and `output.id`, the grouping key on the
Outputs page.
Make that one **generic** (`document`, not `acme-brief`): the Outputs page groups by it, so a
"Contract" blueprint declaring `document` is listed with the rest of the documents instead of
adding a filter chip of its own.

`blueprintId` defaults to the name you passed. It is the install key: reimporting the same id
updates that blueprint in place, and **changing it after a deployment has installed it** promotes
the new id as a *second* format while the old one stays in the New menu, updated by nothing.
Rename files freely; the id is the load-bearing part.

This extracts the files and writes a valid scaffolded `blueprint.json`. Before deploying, replace
the scaffold description and review `output`. Prefer a generic `output.id` such as `document`; the
Outputs page uses it to group related formats.

## Shipping your own formats

This directory is only the **default**. `FORMAT_BLUEPRINTS_DIR` points the build somewhere else:
`FORMAT_BLUEPRINTS_DIR` points the build at another directory in this same extracted layout:

```
FORMAT_BLUEPRINTS_DIR=../../acme-formats pnpm exec vp run build
```

`vp run`, not `pnpm build`: this package's `build` is a Vite+ task rather than a package.json
script, so pnpm cannot see it. `../vite.config.ts` explains why it has to be one.

Whatever directory it names *is* the deployment's format set — it replaces this one rather than
adding to it. Keep it in your own tree, in the same `<name>.gadget` + `<name>.json` layout; nothing
in it refers back to this repo. If you want one of ours too, copy the pair across once and own it
from then on.

This matters because this repo is usually a submodule: adding or deleting files *here* would
conflict on every update. Pointing the build at your own directory touches nothing, so the submodule
stays pristine forever. The import script honours the same variable, so you get the same workflow.

Two lighter options need no build change at all:
The named directory replaces this set rather than extending it. It can be empty to ship no bundled
formats. The import command honors the same variable. Keeping deployment-owned formats outside this
repo avoids modifying it when it is consumed as a submodule.

1. **Promote your own blueprints.** These are ordinary blueprints, and the standard set is admin
curation (`AdminConfig.formats`). Publish a blueprint in your deployment and promote it in the
admin Formats panel; disable the bundled ones you don't want. Nothing needs rebuilding, and this
is the mechanism the bundled set is a convenience on top of, not a special case beside.
2. **Ship no formats.** Point `FORMAT_BLUEPRINTS_DIR` at an empty directory, and the deployment
simply has none until an admin promotes something.
Administrators can also publish and promote ordinary blueprints at runtime instead of rebuilding a
deployment.
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@
"plural": "Docs",
"icon": "fileText"
},
"author": { "type": "user", "name": "Cloudflare", "id": "agent@cloudflare.com" },
"revision": 8
"author": {
"type": "user",
"name": "Cloudflare",
"id": "agent@cloudflare.com"
},
"revision": 9,
"created": "2026-06-24T04:19:41.881Z",
"version": 6,
"lastUpdated": "2026-08-12T18:09:36.250Z",
"bindings": {}
}
Loading
Loading