create-skybridge 0.0.0-dev.356dbec → 0.0.0-dev.35a7b38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/dist/index.js +353 -157
  2. package/package.json +9 -7
  3. package/templates/blank/.dockerignore +4 -0
  4. package/templates/blank/AGENTS.md +1 -0
  5. package/templates/blank/Dockerfile +53 -0
  6. package/templates/blank/README.md +92 -0
  7. package/templates/blank/_gitignore +7 -0
  8. package/templates/blank/alpic.json +3 -0
  9. package/templates/blank/node_modules/.bin/alpic +21 -0
  10. package/templates/blank/node_modules/.bin/sb +21 -0
  11. package/templates/blank/node_modules/.bin/skybridge +21 -0
  12. package/templates/blank/node_modules/.bin/tsc +21 -0
  13. package/templates/blank/node_modules/.bin/tsserver +21 -0
  14. package/templates/blank/node_modules/.bin/tsx +21 -0
  15. package/templates/blank/node_modules/.bin/vite +21 -0
  16. package/templates/blank/package.json +29 -0
  17. package/templates/blank/src/helpers.ts +4 -0
  18. package/templates/blank/src/server.ts +16 -0
  19. package/templates/blank/tsconfig.json +5 -0
  20. package/templates/blank/vite.config.ts +6 -0
  21. package/templates/demo/.dockerignore +4 -0
  22. package/templates/demo/AGENTS.md +1 -0
  23. package/templates/demo/Dockerfile +53 -0
  24. package/templates/demo/README.md +95 -0
  25. package/templates/demo/_gitignore +7 -0
  26. package/templates/demo/alpic.json +3 -0
  27. package/templates/demo/node_modules/.bin/alpic +21 -0
  28. package/templates/demo/node_modules/.bin/sb +21 -0
  29. package/templates/demo/node_modules/.bin/skybridge +21 -0
  30. package/templates/demo/node_modules/.bin/tsc +21 -0
  31. package/templates/demo/node_modules/.bin/tsserver +21 -0
  32. package/templates/demo/node_modules/.bin/tsx +21 -0
  33. package/templates/demo/node_modules/.bin/vite +21 -0
  34. package/templates/demo/package.json +41 -0
  35. package/templates/demo/src/helpers.ts +4 -0
  36. package/templates/demo/src/index.css +59 -0
  37. package/templates/demo/src/server.ts +94 -0
  38. package/templates/demo/src/views/components/doc-link.tsx +22 -0
  39. package/templates/demo/src/views/components/doc.tsx +21 -0
  40. package/templates/demo/src/views/components/nav.tsx +31 -0
  41. package/templates/demo/src/views/components/progress.tsx +35 -0
  42. package/templates/demo/src/views/components/steps/outro.tsx +68 -0
  43. package/templates/demo/src/views/components/steps/state.tsx +47 -0
  44. package/templates/demo/src/views/components/steps/tool-call.tsx +53 -0
  45. package/templates/demo/src/views/components/steps/tool-output.tsx +40 -0
  46. package/templates/demo/src/views/images/mascot/beret.png +0 -0
  47. package/templates/demo/src/views/images/mascot/chapka.png +0 -0
  48. package/templates/demo/src/views/images/mascot/cowboy-hat.png +0 -0
  49. package/templates/demo/src/views/images/mascot/fez.png +0 -0
  50. package/templates/demo/src/views/images/mascot/jester-hat.png +0 -0
  51. package/templates/demo/src/views/images/mascot/mitre.png +0 -0
  52. package/templates/demo/src/views/images/mascot/non-la.png +0 -0
  53. package/templates/demo/src/views/images/mascot/original.png +0 -0
  54. package/templates/demo/src/views/images/mascot/propeller-beanie.png +0 -0
  55. package/templates/demo/src/views/images/mascot/ski-mask.png +0 -0
  56. package/templates/demo/src/views/images/mascot/sombrero.png +0 -0
  57. package/templates/demo/src/views/images/mascot/top-hat.png +0 -0
  58. package/templates/demo/src/views/images/mascot/viking-helmet.png +0 -0
  59. package/templates/demo/src/views/onboarding.tsx +63 -0
  60. package/templates/demo/src/views/use-mascot.ts +60 -0
  61. package/templates/demo/tsconfig.json +11 -0
  62. package/{template/web → templates/demo}/vite.config.ts +3 -4
  63. package/templates/ecom/.dockerignore +4 -0
  64. package/templates/ecom/.env.template +2 -0
  65. package/templates/ecom/.ladle/components.tsx +26 -0
  66. package/templates/ecom/.ladle/config.mjs +11 -0
  67. package/templates/ecom/.ladle/vite.config.ts +11 -0
  68. package/templates/ecom/AGENTS.md +2 -0
  69. package/templates/ecom/Dockerfile +53 -0
  70. package/templates/ecom/README.md +92 -0
  71. package/templates/ecom/_gitignore +9 -0
  72. package/templates/ecom/alpic.json +3 -0
  73. package/templates/ecom/node_modules/.bin/alpic +21 -0
  74. package/templates/ecom/node_modules/.bin/ladle +21 -0
  75. package/templates/ecom/node_modules/.bin/sb +21 -0
  76. package/templates/ecom/node_modules/.bin/skybridge +21 -0
  77. package/templates/ecom/node_modules/.bin/tsc +21 -0
  78. package/templates/ecom/node_modules/.bin/tsserver +21 -0
  79. package/templates/ecom/node_modules/.bin/tsx +21 -0
  80. package/templates/ecom/node_modules/.bin/vite +21 -0
  81. package/templates/ecom/package.json +41 -0
  82. package/templates/ecom/src/components/chip.css.ts +56 -0
  83. package/templates/ecom/src/components/chip.stories.tsx +26 -0
  84. package/templates/ecom/src/components/chip.tsx +41 -0
  85. package/templates/ecom/src/components/empty-state.stories.tsx +3 -0
  86. package/templates/ecom/src/components/empty-state.tsx +12 -0
  87. package/templates/ecom/src/components/expandable-text.css.ts +49 -0
  88. package/templates/ecom/src/components/expandable-text.stories.tsx +20 -0
  89. package/templates/ecom/src/components/expandable-text.tsx +53 -0
  90. package/templates/ecom/src/components/image-gallery.css.ts +172 -0
  91. package/templates/ecom/src/components/image-gallery.stories.tsx +30 -0
  92. package/templates/ecom/src/components/image-gallery.tsx +162 -0
  93. package/templates/ecom/src/components/product-card.css.ts +155 -0
  94. package/templates/ecom/src/components/product-card.stories.tsx +58 -0
  95. package/templates/ecom/src/components/product-card.tsx +101 -0
  96. package/templates/ecom/src/components/product-carousel.css.ts +134 -0
  97. package/templates/ecom/src/components/product-carousel.stories.tsx +64 -0
  98. package/templates/ecom/src/components/product-carousel.tsx +202 -0
  99. package/templates/ecom/src/components/variant-picker.css.ts +27 -0
  100. package/templates/ecom/src/components/variant-picker.stories.tsx +64 -0
  101. package/templates/ecom/src/components/variant-picker.tsx +82 -0
  102. package/templates/ecom/src/components/view-frame.css.ts +22 -0
  103. package/templates/ecom/src/components/view-frame.tsx +27 -0
  104. package/templates/ecom/src/config.ts +12 -0
  105. package/templates/ecom/src/design/contract.css.ts +39 -0
  106. package/templates/ecom/src/design/fonts.css +15 -0
  107. package/templates/ecom/src/design/primitives.css.ts +101 -0
  108. package/templates/ecom/src/design/recipes/typography.css.ts +75 -0
  109. package/templates/ecom/src/design/sprinkles.css.ts +128 -0
  110. package/templates/ecom/src/design/themes/dark.css.ts +36 -0
  111. package/templates/ecom/src/design/themes/light.css.ts +36 -0
  112. package/templates/ecom/src/design/tokens.ts +8 -0
  113. package/templates/ecom/src/helpers.ts +4 -0
  114. package/templates/ecom/src/i18n.ts +34 -0
  115. package/templates/ecom/src/index.css +9 -0
  116. package/templates/ecom/src/lib/cx.ts +9 -0
  117. package/templates/ecom/src/lib/format.ts +9 -0
  118. package/templates/ecom/src/lib/variants.ts +79 -0
  119. package/templates/ecom/src/server.ts +43 -0
  120. package/templates/ecom/src/tools/render-carousel.ts +242 -0
  121. package/templates/ecom/src/tools/search-products.ts +182 -0
  122. package/templates/ecom/src/types.ts +13 -0
  123. package/templates/ecom/src/views/carousel/detail/detail.css.ts +100 -0
  124. package/templates/ecom/src/views/carousel/detail/detail.stories.tsx +114 -0
  125. package/templates/ecom/src/views/carousel/detail/index.tsx +215 -0
  126. package/templates/ecom/src/views/carousel/index.tsx +234 -0
  127. package/templates/ecom/tsconfig.json +11 -0
  128. package/templates/ecom/vite.config.ts +8 -0
  129. package/dist/index.test.d.ts +0 -1
  130. package/dist/index.test.js +0 -23
  131. package/template/README.md +0 -77
  132. package/template/_gitignore +0 -4
  133. package/template/alpic.json +0 -4
  134. package/template/node_modules/.bin/mcp-inspector +0 -21
  135. package/template/node_modules/.bin/nodemon +0 -21
  136. package/template/node_modules/.bin/shx +0 -21
  137. package/template/node_modules/.bin/tsc +0 -21
  138. package/template/node_modules/.bin/tsserver +0 -21
  139. package/template/node_modules/.bin/tsx +0 -21
  140. package/template/node_modules/.bin/vite +0 -21
  141. package/template/nodemon.json +0 -5
  142. package/template/package.json +0 -42
  143. package/template/server/src/index.ts +0 -39
  144. package/template/server/src/middleware.ts +0 -54
  145. package/template/server/src/server.ts +0 -61
  146. package/template/tsconfig.json +0 -23
  147. package/template/tsconfig.server.json +0 -11
  148. package/template/web/src/helpers.ts +0 -4
  149. package/template/web/src/index.css +0 -31
  150. package/template/web/src/widgets/magic-8-ball.tsx +0 -24
@@ -0,0 +1,128 @@
1
+ import { createSprinkles, defineProperties } from "@vanilla-extract/sprinkles";
2
+ import { colors } from "./contract.css";
3
+ import { primitives } from "./primitives.css";
4
+
5
+ /**
6
+ * Atomic style props built on the primitives + color contract. Use sprinkles
7
+ * for one-off layout/spacing/color on an element, e.g.
8
+ * sprinkles({ display: "flex", gap: "s", color: "intense" })
9
+ *
10
+ * Structural component styling belongs in a co-located `.css.ts` `style()`
11
+ * block; sprinkles is the thin glue layer on top.
12
+ */
13
+
14
+ const spaceProperties = defineProperties({
15
+ properties: {
16
+ padding: primitives.space,
17
+ paddingTop: primitives.space,
18
+ paddingRight: primitives.space,
19
+ paddingBottom: primitives.space,
20
+ paddingLeft: primitives.space,
21
+ margin: primitives.space,
22
+ marginTop: primitives.space,
23
+ marginRight: primitives.space,
24
+ marginBottom: primitives.space,
25
+ marginLeft: primitives.space,
26
+ gap: primitives.space,
27
+ rowGap: primitives.space,
28
+ columnGap: primitives.space,
29
+ },
30
+ shorthands: {
31
+ p: ["padding"],
32
+ pt: ["paddingTop"],
33
+ pr: ["paddingRight"],
34
+ pb: ["paddingBottom"],
35
+ pl: ["paddingLeft"],
36
+ px: ["paddingLeft", "paddingRight"],
37
+ py: ["paddingTop", "paddingBottom"],
38
+ m: ["margin"],
39
+ mt: ["marginTop"],
40
+ mr: ["marginRight"],
41
+ mb: ["marginBottom"],
42
+ ml: ["marginLeft"],
43
+ mx: ["marginLeft", "marginRight"],
44
+ my: ["marginTop", "marginBottom"],
45
+ },
46
+ });
47
+
48
+ const colorProperties = defineProperties({
49
+ properties: {
50
+ backgroundColor: {
51
+ ...colors.surface,
52
+ accent: colors.common.accent,
53
+ invertAccent: colors.common.invertAccent,
54
+ highlight: colors.common.highlight,
55
+ transparent: "transparent",
56
+ },
57
+ color: {
58
+ ...colors.content,
59
+ accent: colors.common.accent,
60
+ invertAccent: colors.common.invertAccent,
61
+ highlight: colors.common.highlight,
62
+ error: colors.common.error,
63
+ success: colors.common.success,
64
+ },
65
+ borderColor: {
66
+ ...colors.border,
67
+ accent: colors.common.accent,
68
+ invertAccent: colors.common.invertAccent,
69
+ highlight: colors.common.highlight,
70
+ transparent: "transparent",
71
+ },
72
+ },
73
+ });
74
+
75
+ const radiusProperties = defineProperties({
76
+ properties: {
77
+ borderRadius: primitives.radius,
78
+ borderTopLeftRadius: primitives.radius,
79
+ borderTopRightRadius: primitives.radius,
80
+ borderBottomLeftRadius: primitives.radius,
81
+ borderBottomRightRadius: primitives.radius,
82
+ },
83
+ });
84
+
85
+ const typographyProperties = defineProperties({
86
+ properties: {
87
+ fontFamily: primitives.font.family,
88
+ fontWeight: primitives.font.weight,
89
+ fontSize: primitives.font.size,
90
+ lineHeight: primitives.font.lineHeight,
91
+ letterSpacing: primitives.font.letterSpacing,
92
+ },
93
+ });
94
+
95
+ const strokeProperties = defineProperties({
96
+ properties: {
97
+ borderWidth: primitives.stroke,
98
+ },
99
+ });
100
+
101
+ const layoutProperties = defineProperties({
102
+ properties: {
103
+ display: ["none", "flex", "inline-flex", "block", "inline-block", "grid"],
104
+ flexDirection: ["row", "column", "row-reverse", "column-reverse"],
105
+ alignItems: ["flex-start", "center", "flex-end", "stretch", "baseline"],
106
+ justifyContent: [
107
+ "flex-start",
108
+ "center",
109
+ "flex-end",
110
+ "space-between",
111
+ "space-around",
112
+ "space-evenly",
113
+ ],
114
+ flexWrap: ["wrap", "nowrap", "wrap-reverse"],
115
+ textAlign: ["left", "center", "right"],
116
+ },
117
+ });
118
+
119
+ export const sprinkles = createSprinkles(
120
+ spaceProperties,
121
+ colorProperties,
122
+ radiusProperties,
123
+ typographyProperties,
124
+ strokeProperties,
125
+ layoutProperties,
126
+ );
127
+
128
+ export type Sprinkles = Parameters<typeof sprinkles>[0];
@@ -0,0 +1,36 @@
1
+ import { createTheme } from "@vanilla-extract/css";
2
+ import { colors } from "../contract.css";
3
+ import { primitives } from "../primitives.css";
4
+
5
+ /**
6
+ * Dark palette. Fills the same contract slots as light.css.ts (surface and
7
+ * content swap ends of the grey ramp).
8
+ *
9
+ * @todo: tune these mappings to your brand.
10
+ */
11
+ export const darkTheme = createTheme(colors, {
12
+ surface: {
13
+ extraLight: primitives.grey.black,
14
+ light: primitives.grey["900"],
15
+ subtle: primitives.grey["700"],
16
+ intense: primitives.grey["400"],
17
+ },
18
+ content: {
19
+ intense: primitives.grey["50"],
20
+ subtle: primitives.grey["300"],
21
+ invertIntense: primitives.grey["900"],
22
+ invertSubtle: primitives.grey["500"],
23
+ },
24
+ border: {
25
+ thin: primitives.grey["700"],
26
+ subtle: primitives.grey["600"],
27
+ intense: primitives.grey["400"],
28
+ },
29
+ common: {
30
+ accent: primitives.accent["400"],
31
+ invertAccent: primitives.grey.black,
32
+ highlight: primitives.accent["400"],
33
+ error: primitives.status.error,
34
+ success: primitives.status.success,
35
+ },
36
+ });
@@ -0,0 +1,36 @@
1
+ import { createTheme } from "@vanilla-extract/css";
2
+ import { colors } from "../contract.css";
3
+ import { primitives } from "../primitives.css";
4
+
5
+ /**
6
+ * Light palette: maps each semantic slot to a primitive.
7
+ *
8
+ * @todo: tune these mappings to your brand. dark.css.ts must fill the same
9
+ * slots (the contract enforces it).
10
+ */
11
+ export const lightTheme = createTheme(colors, {
12
+ surface: {
13
+ extraLight: primitives.grey.white,
14
+ light: primitives.grey["50"],
15
+ subtle: primitives.grey["200"],
16
+ intense: primitives.grey["400"],
17
+ },
18
+ content: {
19
+ intense: primitives.grey["900"],
20
+ subtle: primitives.grey["500"],
21
+ invertIntense: primitives.grey["50"],
22
+ invertSubtle: primitives.grey["300"],
23
+ },
24
+ border: {
25
+ thin: primitives.grey["200"],
26
+ subtle: primitives.grey["300"],
27
+ intense: primitives.grey["400"],
28
+ },
29
+ common: {
30
+ accent: primitives.accent["600"],
31
+ invertAccent: primitives.grey.white,
32
+ highlight: primitives.accent["600"],
33
+ error: primitives.status.error,
34
+ success: primitives.status.success,
35
+ },
36
+ });
@@ -0,0 +1,8 @@
1
+ // Barrel for the design system. Import tokens from here rather than reaching
2
+ // into individual files.
3
+ export { colors } from "./contract.css";
4
+ export { primitives } from "./primitives.css";
5
+ export { text } from "./recipes/typography.css";
6
+ export { type Sprinkles, sprinkles } from "./sprinkles.css";
7
+ export { darkTheme } from "./themes/dark.css";
8
+ export { lightTheme } from "./themes/light.css";
@@ -0,0 +1,4 @@
1
+ import { generateHelpers } from "skybridge/web";
2
+ import type { AppType } from "./server.js";
3
+
4
+ export const { useToolInfo, useCallTool } = generateHelpers<AppType>();
@@ -0,0 +1,34 @@
1
+ import { useUser } from "skybridge/web";
2
+
3
+ // Centralized UI labels. The active locale comes from the host via useUser();
4
+ // useLabels matches on the language subtag ("en-US" -> "en") and falls back to
5
+ // English for anything unlisted.
6
+ // @todo: adapt the English copy to your brand voice, and add a locale key (e.g.
7
+ // `fr`) with the same shape for each language you want to support.
8
+ const LABELS = {
9
+ en: {
10
+ outOfStock: "Out of stock",
11
+ noProducts: "No products to show.",
12
+ carousel: "carousel",
13
+ products: "Products",
14
+ previous: "Previous",
15
+ next: "Next",
16
+ // Detail view.
17
+ viewOnSite: "View on site",
18
+ selectOptions: "Select options",
19
+ priceOnRequest: "Price on request",
20
+ specifications: "Specifications",
21
+ readMore: "Read more",
22
+ readLess: "Read less",
23
+ },
24
+ } as const;
25
+
26
+ const DEFAULT_LOCALE = "en";
27
+
28
+ export type Labels = (typeof LABELS)[typeof DEFAULT_LOCALE];
29
+
30
+ export function useLabels(): Labels {
31
+ const { locale } = useUser();
32
+ const lang = locale.split("-")[0] ?? DEFAULT_LOCALE;
33
+ return lang in LABELS ? LABELS[lang as keyof typeof LABELS] : LABELS.en;
34
+ }
@@ -0,0 +1,9 @@
1
+ @import "./design/fonts.css";
2
+
3
+ /* The view mounts in a host iframe whose <body> keeps the user-agent default
4
+ margin (~8px). Zero it out so the surface paints edge to edge. */
5
+ html,
6
+ body {
7
+ margin: 0;
8
+ padding: 0;
9
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Zero-dep class-name joiner. vanilla-extract's `recipe`/`style` return plain
3
+ * strings, so merging a recipe class with an optional consumer `className`
4
+ * just needs string concatenation that tolerates `undefined`/`false`/`null`.
5
+ *
6
+ * className={cx(text({ style: "bodyM" }), sprinkles({ color: "accent" }))}
7
+ */
8
+ export const cx = (...classes: (string | false | null | undefined)[]) =>
9
+ classes.filter(Boolean).join(" ");
@@ -0,0 +1,9 @@
1
+ import type { Price } from "../types.js";
2
+
3
+ // Pass useUser().locale from the view; omit for the runtime default.
4
+ export function formatPrice(price: Price, locale?: string): string {
5
+ return new Intl.NumberFormat(locale, {
6
+ style: "currency",
7
+ currency: price.currency,
8
+ }).format(price.amount);
9
+ }
@@ -0,0 +1,79 @@
1
+ import type { Product, Variant } from "../tools/render-carousel.js";
2
+
3
+ // Pure helpers that turn the client's option choices into a concrete variant.
4
+ // The `variants` list is SPARSE: only combinations that exist are present, and
5
+ // that is the whole contingency model. These helpers never encode rules; they
6
+ // filter the list.
7
+
8
+ // A selection is one chosen value per axis, keyed by Option.id -> OptionValue.id.
9
+ export type Selection = Record<string, string>;
10
+
11
+ /**
12
+ * The variant matching a full selection (one value per axis), or undefined if
13
+ * the selection is partial or the combination does not exist. A product with no
14
+ * options resolves to its single variant on an empty selection.
15
+ */
16
+ export function resolveVariant(
17
+ product: Product,
18
+ selection: Selection,
19
+ ): Variant | undefined {
20
+ for (const option of product.options) {
21
+ if (selection[option.id] == null) {
22
+ return undefined; // partial selection: nothing resolved yet
23
+ }
24
+ }
25
+ return product.variants.find((variant) =>
26
+ product.options.every(
27
+ (option) => variant.selection[option.id] === selection[option.id],
28
+ ),
29
+ );
30
+ }
31
+
32
+ /**
33
+ * The values of `axisId` still reachable given the choices already made on the
34
+ * OTHER axes. A value is reachable if at least one variant carries it while
35
+ * matching every other current choice. Values not in this set have no surviving
36
+ * variant, so the picker disables them ("Red only in M").
37
+ */
38
+ export function selectableValues(
39
+ product: Product,
40
+ axisId: string,
41
+ selection: Selection,
42
+ ): Set<string> {
43
+ const reachable = new Set<string>();
44
+ for (const variant of product.variants) {
45
+ let matchesOthers = true;
46
+ for (const option of product.options) {
47
+ if (option.id === axisId) {
48
+ continue;
49
+ }
50
+ const chosen = selection[option.id];
51
+ if (chosen != null && variant.selection[option.id] !== chosen) {
52
+ matchesOthers = false;
53
+ break;
54
+ }
55
+ }
56
+ if (matchesOthers) {
57
+ reachable.add(variant.selection[axisId]);
58
+ }
59
+ }
60
+ return reachable;
61
+ }
62
+
63
+ /**
64
+ * The selection to preselect when a product opens: the variant the client
65
+ * tapped (its id equals the opened product id), else the first variant. A
66
+ * variant is always preselected when the product has any, so the buy CTA is
67
+ * live on open rather than starting disabled.
68
+ */
69
+ export function initialSelection(product: Product): Selection {
70
+ let base: Variant | undefined;
71
+ for (const variant of product.variants) {
72
+ if (variant.id === product.id) {
73
+ base = variant;
74
+ break;
75
+ }
76
+ }
77
+ base ??= product.variants[0];
78
+ return base ? { ...base.selection } : {};
79
+ }
@@ -0,0 +1,43 @@
1
+ import { existsSync } from "node:fs";
2
+ import { McpServer } from "skybridge/server";
3
+ import { CAROUSEL_RANGE, MIN_SEARCH_ITERATIONS } from "./config.js";
4
+ import {
5
+ renderCarouselDefinition,
6
+ renderCarouselHandler,
7
+ } from "./tools/render-carousel.js";
8
+ import {
9
+ searchProductsDefinition,
10
+ searchProductsHandler,
11
+ } from "./tools/search-products.js";
12
+
13
+ // Load .env into process.env when present (native to Node, no dependency).
14
+ if (existsSync(".env")) {
15
+ process.loadEnvFile();
16
+ }
17
+
18
+ const server = new McpServer(
19
+ {
20
+ // @todo: name and version your app.
21
+ name: "skybridge-ecom",
22
+ version: "0.0.1",
23
+ },
24
+ {
25
+ // @todo: adapt this server-wide prompt to your catalog.
26
+ instructions: `\
27
+ Two phases:
28
+
29
+ SEARCH: Call search-products ${MIN_SEARCH_ITERATIONS}+ times before presenting—never off one call. \
30
+ Vary the keyword, apply filters from a prior response, or page deeper. \
31
+ Stay silent while searching: emit NO text between calls. Speak only \
32
+ once the carousel renders. Never call a category unavailable before searching.
33
+
34
+ RENDER: After curating, call render-carousel with the chosen product IDs (aim for ${CAROUSEL_RANGE}). \
35
+ Speak only once it renders, then recommend in carousel order.`,
36
+ },
37
+ )
38
+ .registerTool(searchProductsDefinition, searchProductsHandler)
39
+ .registerTool(renderCarouselDefinition, renderCarouselHandler);
40
+
41
+ export default await server.run();
42
+
43
+ export type AppType = typeof server;
@@ -0,0 +1,242 @@
1
+ import { z } from "zod";
2
+ import { CAROUSEL_MAX_SIZE, CAROUSEL_RANGE } from "../config.js";
3
+ import {
4
+ type Attribute,
5
+ AttributeSchema,
6
+ type Price,
7
+ PriceSchema,
8
+ } from "../types.js";
9
+
10
+ // The `render-carousel` tool: takes the IDs the model curated and returns the
11
+ // matching products for the carousel view to render.
12
+ // Everything this tool needs lives in this file.
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Product model
16
+ // ---------------------------------------------------------------------------
17
+ // Model: variant-as-full-product. Each `Variant` is a complete, buyable product
18
+ // (its own title, price, media). A `Product` ties sibling variants together and
19
+ // declares the axes (`Option`s) they vary on. A product with no variations is
20
+ // just a product with a single variant and no options.
21
+
22
+ // One selectable value on an axis, e.g. the "Black" choice on the "Color" axis.
23
+ type OptionValue = {
24
+ id: string; // stable key referenced by Variant.selection, e.g. "black"
25
+ label: string; // shown to the user, e.g. "Black"
26
+ media?: string; // optional swatch / image representing this value
27
+ };
28
+
29
+ // A variation axis the variants differ on, e.g. Color or Size.
30
+ type Option = {
31
+ id: string; // stable key, used as a key in Variant.selection, e.g. "color"
32
+ label: string; // shown to the user, e.g. "Color"
33
+ values: OptionValue[]; // in display order
34
+ };
35
+
36
+ // Display fields shared by a Variant and by a product's `card`.
37
+ type Meta = {
38
+ title: string;
39
+ description?: string;
40
+ price?: Price;
41
+ media: string[]; // images for this item; media[0] is the primary/cover
42
+ url?: string; // link to this item's external product page
43
+ outOfStock?: boolean; // true = not purchasable
44
+ // Facts to display: spec rows, promo labels, etc.
45
+ attributes: Attribute[];
46
+ };
47
+
48
+ // One buyable product: full display Meta plus which value it takes on each axis.
49
+ export type Variant = Meta & {
50
+ id: string; // SKU / article number; unique within the catalog
51
+ // The chosen value per axis: keys are Option.id, values are OptionValue.id.
52
+ // e.g. { color: "black", size: "40" }
53
+ selection: Record<string, string>;
54
+ };
55
+
56
+ // A product: one carousel card backed by one or more variants and the axes they
57
+ // vary on (none for a single-variant product).
58
+ export type Product = {
59
+ id: string; // stable product key
60
+ options: Option[]; // the axes the variants vary on
61
+ // Only the variants that actually exist. A missing combination (e.g. no
62
+ // { color: "black", size: "40" }) is simply absent from this list — that is how
63
+ // contingent variations are expressed. Derive the selectable values for an axis
64
+ // by filtering this list on the choices already made.
65
+ variants: Variant[];
66
+ // The product's carousel card. Surfaced both in the carousel (the view
67
+ // renders it) and to the model (structuredContent is projected from it).
68
+ // How you build it depends on your mapping strategy: see getProducts.
69
+ card: Meta;
70
+ };
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Input
74
+ // ---------------------------------------------------------------------------
75
+
76
+ const inputSchema = {
77
+ ids: z
78
+ .array(z.string())
79
+ .min(1)
80
+ .max(CAROUSEL_MAX_SIZE)
81
+ .describe("Product IDs to present, in display order."),
82
+ };
83
+
84
+ type RenderInput = z.infer<z.ZodObject<typeof inputSchema>>;
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Output — model-facing grounding, for the LLM ONLY. The carousel view is NOT
88
+ // built from this; it renders from the full data in `_meta`. Keep it to what the
89
+ // model needs to reference and compare the displayed products afterward.
90
+ // ---------------------------------------------------------------------------
91
+
92
+ const outputSchema = {
93
+ products: z
94
+ .array(
95
+ z.object({
96
+ id: z.string().describe("Product SKU or reference."),
97
+ title: z.string(),
98
+ options: z
99
+ .array(z.object({ label: z.string(), values: z.array(z.string()) }))
100
+ .describe("Variations available (e.g. colors, sizes)."),
101
+ description: z.string().optional(),
102
+ price: PriceSchema.optional(),
103
+ outOfStock: z.boolean(),
104
+ attributes: z
105
+ .array(AttributeSchema)
106
+ .describe("Product facts (specs, promo labels…)."),
107
+ }),
108
+ )
109
+ .describe(
110
+ "The products shown in the carousel, in display order. For your reference only — to curate, compare, and answer follow-ups. Ground every claim in this data; never invent attributes.",
111
+ ),
112
+ };
113
+
114
+ type RenderOutput = z.infer<z.ZodObject<typeof outputSchema>>;
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Data access
118
+ // ---------------------------------------------------------------------------
119
+
120
+ async function getProducts(_ids: string[]): Promise<Product[]> {
121
+ // @todo: fetch each id from your product API / DB and map the results into
122
+ // `Product`s (rename `_ids` -> `ids` once you use it).
123
+ //
124
+ // First decide your mapping strategy — it depends on your catalog:
125
+ // - no variants (simple products): one `Product`, a single variant, card = that variant, options: [],
126
+ // - grouped: one `Product` per product; `card` = union of its variants, one picture per requested variant
127
+ // - one card per requested variant: `card` = that variant
128
+ // Either way, set `variants` to ALL variants the source returns for the product;
129
+ // the detail view reads them so the client can switch variant.
130
+ //
131
+ // Returns [] for now, so the carousel is empty.
132
+ return [];
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Mapping: trim each product's `card` and `options` into the model-facing
137
+ // grounding (outputSchema), dropping presentational fields (media, url). The
138
+ // full data stays in `_meta` for the view.
139
+ // @todo: choose what the model sees per product. Grounding only: no
140
+ // presentational data (media, styling); that rides in `_meta` for the view.
141
+ // ---------------------------------------------------------------------------
142
+
143
+ function toStructuredContent(products: Product[]): RenderOutput {
144
+ const groundingProducts: RenderOutput["products"] = [];
145
+
146
+ for (const product of products) {
147
+ const { card } = product;
148
+
149
+ const options: { label: string; values: string[] }[] = [];
150
+ for (const option of product.options) {
151
+ const values: string[] = [];
152
+ for (const value of option.values) {
153
+ values.push(value.label);
154
+ }
155
+ options.push({ label: option.label, values });
156
+ }
157
+
158
+ groundingProducts.push({
159
+ id: product.id,
160
+ title: card.title,
161
+ description: card.description,
162
+ price: card.price,
163
+ outOfStock: card.outOfStock ?? false,
164
+ options,
165
+ attributes: card.attributes,
166
+ });
167
+ }
168
+
169
+ return { products: groundingProducts };
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Tool (registered from server.ts to keep the typed tool chain intact)
174
+ // ---------------------------------------------------------------------------
175
+
176
+ export const renderCarouselDefinition = {
177
+ name: "render-carousel" as const,
178
+
179
+ // @todo: adapt the wording to your catalog and brand voice (tone, vocabulary,
180
+ // how to present products). The behavioral rules below apply to any catalog.
181
+ description: `\
182
+ Display the products you curated as an inline carousel for the client.
183
+
184
+ ## When to call
185
+ Call this AFTER searching and curating, and BEFORE writing your recommendation. Do not describe the products in text first; the carousel shows them. Stay silent until it has rendered, then speak.
186
+
187
+ ## What to pass
188
+ Pass the IDs of the ${CAROUSEL_RANGE} products you chose, in display order (most relevant first). Order is significant: the carousel shows them in this exact order and your recommendation must follow the same sequence. Pass distinct products, not several variants of the same one; the detail view lets the client explore a product's variants (colors, sizes, and so on).
189
+
190
+ ## After the carousel
191
+ Recommend in carousel order so the client can follow along. The cards already show image, title, price, and key facts, so do not repeat them: add useful analysis tied to the client's need. Suggest a refinement the client has not addressed yet (from the available filters), never one they already used.
192
+
193
+ ## Accuracy
194
+ Use only the data returned for each product. Never invent attributes, materials, or availability. If the client asks about something not present, open that product's detail or search again before answering.`,
195
+ annotations: {
196
+ readOnlyHint: true,
197
+ openWorldHint: false,
198
+ destructiveHint: false,
199
+ },
200
+
201
+ // @todo: customize the status messages shown in ChatGPT while the tool runs.
202
+ _meta: {
203
+ "openai/toolInvocation/invoking": "Loading product carousel",
204
+ "openai/toolInvocation/invoked": "Loaded product carousel",
205
+ },
206
+
207
+ // The carousel and product details UI rendered inline in the conversation.
208
+ view: {
209
+ // `as const` keeps this a literal (like `name` above) so it matches the
210
+ // generated ViewNameRegistry; a bare string widens and fails the build.
211
+ component: "carousel" as const,
212
+ description: "Browse the curated products.",
213
+ // @todo: declare the CSP domains this view needs. Add your image origins to
214
+ // `resourceDomains` so product images load, and the product site to
215
+ // `redirectDomains` so the detail view's "View on site" link and the host's
216
+ // "Open in app" URL (useOpenExternal / setOpenInAppUrl) are allowed.
217
+ // csp: {
218
+ // resourceDomains: ["https://images.example.com"],
219
+ // redirectDomains: ["https://www.example.com"],
220
+ // },
221
+ },
222
+
223
+ inputSchema,
224
+ outputSchema,
225
+ };
226
+
227
+ export async function renderCarouselHandler({ ids }: RenderInput) {
228
+ const products = await getProducts(ids);
229
+
230
+ return {
231
+ // Full products (incl. variants) for the view; not in model context.
232
+ _meta: { products },
233
+ structuredContent: toStructuredContent(products),
234
+ content: [
235
+ {
236
+ type: "text" as const,
237
+ text: `Rendered ${products.length} product(s) in the carousel.`,
238
+ },
239
+ ],
240
+ isError: false,
241
+ };
242
+ }