Skip to content

Commit 2b18e10

Browse files
authored
Merge pull request #5617 from nodetool-ai/claude/recipe-bundles-marketing-34yzol
feat(examples): ship the recipe chains in the app
2 parents ebaecf9 + 8de6e2d commit 2b18e10

32 files changed

Lines changed: 1782 additions & 291 deletions

docs/harnesses.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -891,6 +891,34 @@ and the `edit_timeline` op `insert_composition`; the pure
891891
`instantiateComposition`/`extractComposition` live in
892892
`packages/timeline/src/composition.ts`.
893893

894+
### Shipped recipes
895+
896+
A recipe is a named outcome plus the ordered example workflows that reach it —
897+
"one packshot per SKU becomes the whole channel set", four to six shipped
898+
workflows deep. The manifests are
899+
`packages/base-nodes/nodetool/examples/recipes/<slug>.recipe.json` (the
900+
`recipes` sibling of the example workflows, where `exampleRecipesDir` looks by
901+
default in the monorepo, the packaged backend, and the server image). They hold
902+
no graphs: each step names a shipped example, and
903+
`listExampleRecipes` (`packages/websocket/src/lib/example-recipes.ts`) resolves
904+
those names against the examples the install actually ships, reading each
905+
step's node count, thumbnail and models out of the graph. A recipe with a step
906+
that no longer resolves is dropped from the listing rather than half-offered.
907+
908+
`workflows.recipes` serves them; the web app shows them above the gallery on
909+
**Examples**, where a step opens that example as a workflow and "Add all"
910+
copies the whole chain into the library. The site builds its `/recipes` pages
911+
and the downloadable `.nodetool` bundles from the same manifests
912+
(`marketing/scripts/generate-recipes.mjs`, with the sample renders and page
913+
order in `marketing/scripts/recipes.mjs`), so the page and the product name one
914+
list of workflows. The `recipes` harness is the gate on that: its selfcheck runs
915+
the resolver's suite (`packages/websocket/tests/example-recipes.test.ts`) and
916+
the site generator in `--check` mode, and it fires on any diff touching a
917+
manifest, the resolver, the Examples listing, or the recipe pages.
918+
`npm run validate:examples` also checks every step, alternative and hero
919+
resolves, and `scripts/verify-backend-bundle.mjs` checks the manifests and the
920+
examples they name were staged into the packaged bundle.
921+
894922
### 3D scene tools (no editor, no browser)
895923

896924
An agent builds and fixes a 3D model without an editor open:

electron/src/__tests__/verifyBackendBundle.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ function writeValidBundle(dir: string): void {
3939
path.join(dir, "examples", "compositions", "hello.composition.json"),
4040
"{}"
4141
);
42+
fs.mkdirSync(path.join(dir, "examples", "recipes"), { recursive: true });
43+
fs.writeFileSync(
44+
path.join(dir, "examples", "recipes", "hello.recipe.json"),
45+
JSON.stringify(RECIPE_MANIFEST)
46+
);
4247
fs.mkdirSync(path.join(dir, "assets", "nodetool-base", "storyboards", "hello"), {
4348
recursive: true,
4449
});
@@ -81,6 +86,15 @@ function writeValidBundle(dir: string): void {
8186
);
8287
}
8388

89+
/** One shipped recipe, naming the example workflow staged alongside it. */
90+
const RECIPE_MANIFEST = {
91+
schemaVersion: 1,
92+
slug: "hello",
93+
name: "Hello",
94+
hero: "hello",
95+
steps: [{ example: "hello", role: "Say hello", handoff: "In: nothing." }],
96+
};
97+
8498
/** One shipped board, naming the two media files staged alongside it. */
8599
const STORYBOARD_BUNDLE = {
86100
name: "Hello",
@@ -265,6 +279,27 @@ describe("verify-backend-bundle", () => {
265279
expect(status).toBe(1);
266280
});
267281

282+
it("fails when a recipe names an example that was not staged", () => {
283+
fs.writeFileSync(
284+
path.join(tempDir, "examples", "recipes", "hello.recipe.json"),
285+
JSON.stringify({
286+
...RECIPE_MANIFEST,
287+
steps: [{ ...RECIPE_MANIFEST.steps[0], example: "renamed" }],
288+
})
289+
);
290+
const { status, output } = runVerify(tempDir);
291+
expect(output).toContain("recipe steps name examples that were not staged");
292+
expect(output).toContain("hello.recipe.json: renamed");
293+
expect(status).toBe(1);
294+
});
295+
296+
it("fails when no recipe manifest is staged at all", () => {
297+
fs.rmSync(path.join(tempDir, "examples", "recipes"), { recursive: true });
298+
const { status, output } = runVerify(tempDir);
299+
expect(output).toContain("examples/recipes/ is missing");
300+
expect(status).toBe(1);
301+
});
302+
268303
it("fails when no example composition is staged at all", () => {
269304
fs.rmSync(path.join(tempDir, "examples", "compositions"), {
270305
recursive: true,

marketing/NARRATIVE.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,12 +139,13 @@ ownership.
139139

140140
The homepage shows the four recipes (`/recipes`) under the heading "How teams
141141
are using NodeTool", because each is a job with a buyer, a real run against live
142-
models, and a `.nodetool` bundle to download. The four use cases on `/use-cases`
142+
models, and a chain that ships inside Studio (Examples → Recipes; the site also
143+
packs each as a `.nodetool` bundle). The four use cases on `/use-cases`
143144
(trailer, teaser, product video, poster) are demos of a surface; they stay on
144145
their own pages.
145146

146-
Each recipe card carries: what you end up holding, who it is for, the models
147-
the shipped chain calls, and the bundle. That is the proof the BYOK claim has
147+
Each recipe card carries: what you end up holding, who it is for, and the models
148+
the shipped chain calls. That is the proof the BYOK claim has
148149
today. The proof it still lacks is a real provider bill per recipe. Until a
149150
recorded run produces one, the card says "at provider list prices" and names
150151
the models. **Do not put an estimated dollar figure on a card.** A number that

marketing/scripts/generate-recipes.mjs

Lines changed: 78 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
// Generates marketing/src/data/recipeEntries.generated.ts and the downloadable
2-
// .nodetool bundles in marketing/public/recipes/ from the editorial specs in
3-
// scripts/recipes.mjs.
2+
// .nodetool bundles in marketing/public/recipes/ from the recipe manifests the
3+
// app ships (packages/base-nodes/nodetool/examples/recipes/*.recipe.json) plus
4+
// the site-only presentation in scripts/recipes.mjs.
45
//
56
// Regenerate: npm run gen:recipes Verify: npm run gen:recipes -- --check
67
//
78
// The generated module is checked in, so the site builds without this script.
89
// What the script adds is that a recipe cannot outlive its ingredients: every
9-
// step is resolved against the shipped example workflows, and a slug that stops
10+
// step is resolved against the shipped example workflows, and a name that stops
1011
// resolving throws here rather than shipping a page whose bundle is short a
1112
// workflow. The models and API keys each recipe lists are read out of the
1213
// graphs, never written by hand.
1314
//
15+
// The manifests are the app's: the same files the Examples page reads to offer
16+
// each chain, so the page and the product describe one list of workflows.
17+
//
1418
// The .nodetool bundles are NOT deterministic — packWorkflowsBundle stamps a
1519
// created_at and the running NodeTool version into the manifest — so --check
1620
// asserts that each bundle exists and holds the right workflows, and compares
@@ -25,7 +29,7 @@ import { fileURLToPath } from "node:url";
2529
// Fastify server, and tsx would resolve the specifier to a stale `dist/`.
2630
// The codec itself only needs fflate and node:crypto.
2731
import { packWorkflowsBundle } from "../../packages/websocket/src/lib/workflow-bundle.ts";
28-
import { recipes } from "./recipes.mjs";
32+
import { recipePresentation } from "./recipes.mjs";
2933

3034
const __dirname = path.dirname(fileURLToPath(import.meta.url));
3135
const MARKETING = path.resolve(__dirname, "..");
@@ -34,6 +38,10 @@ const EXAMPLES_DIR = path.join(
3438
REPO_ROOT,
3539
"packages/base-nodes/nodetool/examples/nodetool-base",
3640
);
41+
const RECIPES_DIR = path.join(
42+
REPO_ROOT,
43+
"packages/base-nodes/nodetool/examples/recipes",
44+
);
3745
const TEMPLATE_ENTRIES = path.join(
3846
MARKETING,
3947
"src/data/templateEntries.generated.ts",
@@ -45,6 +53,8 @@ const OUT_FILE = path.join(MARKETING, "src/data/recipeEntries.generated.ts");
4553

4654
const CHECK = process.argv.includes("--check");
4755

56+
const RECIPE_SUFFIX = ".recipe.json";
57+
4858
/**
4959
* Stamped into each bundle manifest, matching what the CLI exporter writes.
5060
* The root package.json carries no version, so read the CLI's own — the same
@@ -162,17 +172,17 @@ function buildSample(spec) {
162172
}
163173

164174
/**
165-
* A step's swap-in variant: another shipped template that reaches a different
175+
* A step's swap-in variant: another shipped example that reaches a different
166176
* ending from the same inputs. Resolved the same way a step is, so a variant
167-
* cannot name a template that stopped shipping.
177+
* cannot name an example that stopped shipping.
168178
*/
169-
function buildAlternative(spec, step, byTemplateSlug) {
179+
function buildAlternative(spec, step, byExampleName) {
170180
if (!step.alternative) return null;
171-
const entry = byTemplateSlug.get(step.alternative.template);
181+
const entry = byExampleName.get(step.alternative.example);
172182
if (!entry) {
173183
fail(
174-
`recipe "${spec.slug}" step "${step.template}" offers alternative ` +
175-
`"${step.alternative.template}", which resolves to no shipped template.`,
184+
`recipe "${spec.slug}" step "${step.example}" offers alternative ` +
185+
`"${step.alternative.example}", which resolves to no shipped example.`,
176186
);
177187
}
178188
return {
@@ -184,15 +194,15 @@ function buildAlternative(spec, step, byTemplateSlug) {
184194
};
185195
}
186196

187-
/** Resolve one recipe spec into the record the page renders. */
188-
function buildRecipe(spec, byTemplateSlug) {
197+
/** Resolve one recipe manifest into the record the page renders. */
198+
function buildRecipe(spec, byExampleName) {
189199
const steps = spec.steps.map((step) => {
190-
const entry = byTemplateSlug.get(step.template);
200+
const entry = byExampleName.get(step.example);
191201
if (!entry) {
192202
fail(
193-
`recipe "${spec.slug}" step "${step.template}" resolves to no shipped ` +
194-
"template. Run `npm run gen:templates` first; if the example was " +
195-
"renamed, update scripts/recipes.mjs.",
203+
`recipe "${spec.slug}" step "${step.example}" resolves to no shipped ` +
204+
"example. Run `npm run gen:templates` first; if the example was " +
205+
`renamed, update ${path.relative(REPO_ROOT, RECIPES_DIR)}/${spec.slug}.recipe.json.`,
196206
);
197207
}
198208
const file = path.join(EXAMPLES_DIR, `${entry.name}.json`);
@@ -212,15 +222,13 @@ function buildRecipe(spec, byTemplateSlug) {
212222
thumbnail: entry.thumbnail,
213223
nodeCount: entry.nodeCount,
214224
models: modelRefs(graph),
215-
alternative: buildAlternative(spec, step, byTemplateSlug),
225+
alternative: buildAlternative(spec, step, byExampleName),
216226
},
217227
};
218228
});
219229

220-
if (!steps.some((s) => s.entry.slug === spec.heroStep)) {
221-
fail(
222-
`recipe "${spec.slug}": heroStep "${spec.heroStep}" is not one of its steps`,
223-
);
230+
if (!steps.some((s) => s.entry.name === spec.hero)) {
231+
fail(`recipe "${spec.slug}": hero "${spec.hero}" is not one of its steps`);
224232
}
225233

226234
const providers = [
@@ -250,7 +258,7 @@ function buildRecipe(spec, byTemplateSlug) {
250258
audience: spec.audience,
251259
summary: spec.summary,
252260
caveats: spec.caveats,
253-
heroThumbnail: steps.find((s) => s.entry.slug === spec.heroStep).step
261+
heroThumbnail: steps.find((s) => s.entry.name === spec.hero).step
254262
.thumbnail,
255263
bundle: `/recipes/${spec.slug}.nodetool`,
256264
workflowCount: steps.length,
@@ -329,15 +337,56 @@ export const recipeEntries: RecipeEntry[] = ${JSON.stringify(records, null, 2)};
329337
`;
330338
}
331339

332-
async function main() {
333-
const byTemplateSlug = new Map(readTemplateEntries().map((t) => [t.slug, t]));
334-
const slugs = new Set();
335-
for (const spec of recipes) {
336-
if (slugs.has(spec.slug)) fail(`duplicate recipe slug "${spec.slug}"`);
337-
slugs.add(spec.slug);
340+
/**
341+
* The shipped manifests, in page order: the order scripts/recipes.mjs lists,
342+
* then anything else on disk by slug. Each carries the site-only sample block
343+
* for its slug, so a manifest that ships without one still gets a page.
344+
*/
345+
function readManifests() {
346+
let files;
347+
try {
348+
files = fs
349+
.readdirSync(RECIPES_DIR)
350+
.filter((file) => file.endsWith(RECIPE_SUFFIX))
351+
.sort((a, b) => a.localeCompare(b));
352+
} catch {
353+
fail(`no recipe manifests at ${path.relative(REPO_ROOT, RECIPES_DIR)}`);
354+
}
355+
const bySlug = new Map();
356+
for (const file of files) {
357+
const manifest = JSON.parse(
358+
fs.readFileSync(path.join(RECIPES_DIR, file), "utf8"),
359+
);
360+
const slug = file.slice(0, -RECIPE_SUFFIX.length);
361+
if (manifest.slug !== slug) {
362+
fail(`${file} declares slug "${manifest.slug}" — rename one or the other`);
363+
}
364+
bySlug.set(slug, manifest);
338365
}
339366

340-
const built = recipes.map((spec) => buildRecipe(spec, byTemplateSlug));
367+
const ordered = [];
368+
for (const { slug, sample } of recipePresentation) {
369+
const manifest = bySlug.get(slug);
370+
if (!manifest) {
371+
fail(
372+
`scripts/recipes.mjs lists "${slug}", which has no manifest in ` +
373+
`${path.relative(REPO_ROOT, RECIPES_DIR)}`,
374+
);
375+
}
376+
ordered.push({ ...manifest, sample: sample ?? null });
377+
bySlug.delete(slug);
378+
}
379+
for (const manifest of bySlug.values()) {
380+
ordered.push({ ...manifest, sample: null });
381+
}
382+
return ordered;
383+
}
384+
385+
async function main() {
386+
const byExampleName = new Map(readTemplateEntries().map((t) => [t.name, t]));
387+
const specs = readManifests();
388+
389+
const built = specs.map((spec) => buildRecipe(spec, byExampleName));
341390
const source = render(built.map((b) => b.record));
342391

343392
if (CHECK) {

0 commit comments

Comments
 (0)