@noodleseed/one 0.75.1 → 0.76.0

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 (264) hide show
  1. package/dist/commands/catalog-data-account.d.ts +4 -0
  2. package/dist/commands/catalog-data-account.d.ts.map +1 -0
  3. package/dist/commands/catalog-data-account.js +285 -0
  4. package/dist/commands/catalog-data-account.js.map +1 -0
  5. package/dist/commands/catalog-data-assistant.d.ts +4 -0
  6. package/dist/commands/catalog-data-assistant.d.ts.map +1 -0
  7. package/dist/commands/catalog-data-assistant.js +203 -0
  8. package/dist/commands/catalog-data-assistant.js.map +1 -0
  9. package/dist/commands/catalog-data-auth-discovery.d.ts +4 -0
  10. package/dist/commands/catalog-data-auth-discovery.d.ts.map +1 -0
  11. package/dist/commands/catalog-data-auth-discovery.js +174 -0
  12. package/dist/commands/catalog-data-auth-discovery.js.map +1 -0
  13. package/dist/commands/catalog-data-billing.d.ts +4 -0
  14. package/dist/commands/catalog-data-billing.d.ts.map +1 -0
  15. package/dist/commands/catalog-data-billing.js +312 -0
  16. package/dist/commands/catalog-data-billing.js.map +1 -0
  17. package/dist/commands/catalog-data-bootstrap-authoring.d.ts +4 -0
  18. package/dist/commands/catalog-data-bootstrap-authoring.d.ts.map +1 -0
  19. package/dist/commands/catalog-data-bootstrap-authoring.js +412 -0
  20. package/dist/commands/catalog-data-bootstrap-authoring.js.map +1 -0
  21. package/dist/commands/catalog-data-bootstrap-pre-auth.d.ts +4 -0
  22. package/dist/commands/catalog-data-bootstrap-pre-auth.d.ts.map +1 -0
  23. package/dist/commands/catalog-data-bootstrap-pre-auth.js +372 -0
  24. package/dist/commands/catalog-data-bootstrap-pre-auth.js.map +1 -0
  25. package/dist/commands/catalog-data-config.d.ts +1 -3
  26. package/dist/commands/catalog-data-config.d.ts.map +1 -1
  27. package/dist/commands/catalog-data-config.js +218 -33
  28. package/dist/commands/catalog-data-config.js.map +1 -1
  29. package/dist/commands/catalog-data-core.d.ts +3 -10
  30. package/dist/commands/catalog-data-core.d.ts.map +1 -1
  31. package/dist/commands/catalog-data-core.js +43 -418
  32. package/dist/commands/catalog-data-core.js.map +1 -1
  33. package/dist/commands/catalog-data-hosted-deployment.d.ts +4 -0
  34. package/dist/commands/catalog-data-hosted-deployment.d.ts.map +1 -0
  35. package/dist/commands/catalog-data-hosted-deployment.js +330 -0
  36. package/dist/commands/catalog-data-hosted-deployment.js.map +1 -0
  37. package/dist/commands/catalog-data-hosted-governance.d.ts +4 -0
  38. package/dist/commands/catalog-data-hosted-governance.d.ts.map +1 -0
  39. package/dist/commands/catalog-data-hosted-governance.js +4 -0
  40. package/dist/commands/catalog-data-hosted-governance.js.map +1 -0
  41. package/dist/commands/catalog-data-hosted-observability.d.ts +4 -0
  42. package/dist/commands/catalog-data-hosted-observability.d.ts.map +1 -0
  43. package/dist/commands/catalog-data-hosted-observability.js +421 -0
  44. package/dist/commands/catalog-data-hosted-observability.js.map +1 -0
  45. package/dist/commands/catalog-data-local-authoring.d.ts +4 -0
  46. package/dist/commands/catalog-data-local-authoring.d.ts.map +1 -0
  47. package/dist/commands/catalog-data-local-authoring.js +267 -0
  48. package/dist/commands/catalog-data-local-authoring.js.map +1 -0
  49. package/dist/commands/catalog-data-platform-auth.d.ts.map +1 -1
  50. package/dist/commands/catalog-data-platform-auth.js +246 -38
  51. package/dist/commands/catalog-data-platform-auth.js.map +1 -1
  52. package/dist/commands/catalog-data-policy.d.ts +4 -0
  53. package/dist/commands/catalog-data-policy.d.ts.map +1 -0
  54. package/dist/commands/catalog-data-policy.js +328 -0
  55. package/dist/commands/catalog-data-policy.js.map +1 -0
  56. package/dist/commands/catalog-data-tenant-admin.d.ts +4 -0
  57. package/dist/commands/catalog-data-tenant-admin.d.ts.map +1 -0
  58. package/dist/commands/catalog-data-tenant-admin.js +444 -0
  59. package/dist/commands/catalog-data-tenant-admin.js.map +1 -0
  60. package/dist/commands/catalog-data-tenant-resources.d.ts +4 -0
  61. package/dist/commands/catalog-data-tenant-resources.d.ts.map +1 -0
  62. package/dist/commands/catalog-data-tenant-resources.js +186 -0
  63. package/dist/commands/catalog-data-tenant-resources.js.map +1 -0
  64. package/dist/commands/catalog-dispatch.d.ts.map +1 -1
  65. package/dist/commands/catalog-dispatch.js +10 -2
  66. package/dist/commands/catalog-dispatch.js.map +1 -1
  67. package/dist/commands/catalog-render.d.ts +0 -7
  68. package/dist/commands/catalog-render.d.ts.map +1 -1
  69. package/dist/commands/catalog-render.js +87 -14
  70. package/dist/commands/catalog-render.js.map +1 -1
  71. package/dist/commands/catalog-types.d.ts +62 -14
  72. package/dist/commands/catalog-types.d.ts.map +1 -1
  73. package/dist/commands/catalog.d.ts +1 -1
  74. package/dist/commands/catalog.d.ts.map +1 -1
  75. package/dist/commands/catalog.js +27 -5
  76. package/dist/commands/catalog.js.map +1 -1
  77. package/dist/commands/config-values.d.ts +1 -0
  78. package/dist/commands/config-values.d.ts.map +1 -1
  79. package/dist/commands/config-values.js +26 -1
  80. package/dist/commands/config-values.js.map +1 -1
  81. package/dist/commands/deploy-ops.d.ts.map +1 -1
  82. package/dist/commands/deploy-ops.js +12 -1
  83. package/dist/commands/deploy-ops.js.map +1 -1
  84. package/dist/commands/deploy-target.d.ts +13 -0
  85. package/dist/commands/deploy-target.d.ts.map +1 -1
  86. package/dist/commands/deploy-target.js +15 -0
  87. package/dist/commands/deploy-target.js.map +1 -1
  88. package/dist/commands/deploy-version-resolution.d.ts +17 -0
  89. package/dist/commands/deploy-version-resolution.d.ts.map +1 -1
  90. package/dist/commands/deploy-version-resolution.js +60 -0
  91. package/dist/commands/deploy-version-resolution.js.map +1 -1
  92. package/dist/commands/features.d.ts.map +1 -1
  93. package/dist/commands/features.js +9 -4
  94. package/dist/commands/features.js.map +1 -1
  95. package/dist/commands/feedback-ops.d.ts +14 -1
  96. package/dist/commands/feedback-ops.d.ts.map +1 -1
  97. package/dist/commands/feedback-ops.js +323 -50
  98. package/dist/commands/feedback-ops.js.map +1 -1
  99. package/dist/commands/mcp-apps.d.ts.map +1 -1
  100. package/dist/commands/mcp-apps.js +4 -6
  101. package/dist/commands/mcp-apps.js.map +1 -1
  102. package/dist/commands/org-admin.d.ts.map +1 -1
  103. package/dist/commands/org-admin.js +5 -2
  104. package/dist/commands/org-admin.js.map +1 -1
  105. package/dist/commands/org-domains.d.ts +3 -0
  106. package/dist/commands/org-domains.d.ts.map +1 -0
  107. package/dist/commands/org-domains.js +136 -0
  108. package/dist/commands/org-domains.js.map +1 -0
  109. package/dist/commands/shared.d.ts.map +1 -1
  110. package/dist/commands/shared.js +20 -0
  111. package/dist/commands/shared.js.map +1 -1
  112. package/dist/commands/tool-design-audit.d.ts +27 -0
  113. package/dist/commands/tool-design-audit.d.ts.map +1 -0
  114. package/dist/commands/tool-design-audit.js +154 -0
  115. package/dist/commands/tool-design-audit.js.map +1 -0
  116. package/dist/dev.d.ts.map +1 -1
  117. package/dist/dev.js +4 -1
  118. package/dist/dev.js.map +1 -1
  119. package/node_modules/@noodle-borg/agent-kit/dist/behavior-skills.js +5 -5
  120. package/node_modules/@noodle-borg/agent-kit/dist/behavior-skills.js.map +1 -1
  121. package/node_modules/@noodle-borg/agent-kit/dist/curated/command-groups.d.ts.map +1 -1
  122. package/node_modules/@noodle-borg/agent-kit/dist/curated/command-groups.js +1 -1
  123. package/node_modules/@noodle-borg/agent-kit/dist/curated/command-groups.js.map +1 -1
  124. package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +9 -9
  125. package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js.map +1 -1
  126. package/node_modules/@noodle-borg/agent-kit/dist/skill-authoring-refs.d.ts.map +1 -1
  127. package/node_modules/@noodle-borg/agent-kit/dist/skill-authoring-refs.js +1 -60
  128. package/node_modules/@noodle-borg/agent-kit/dist/skill-authoring-refs.js.map +1 -1
  129. package/node_modules/@noodle-borg/agent-kit/dist/skill-connect-refs.d.ts.map +1 -1
  130. package/node_modules/@noodle-borg/agent-kit/dist/skill-connect-refs.js +4 -0
  131. package/node_modules/@noodle-borg/agent-kit/dist/skill-connect-refs.js.map +1 -1
  132. package/node_modules/@noodle-borg/agent-kit/dist/skill-content.d.ts.map +1 -1
  133. package/node_modules/@noodle-borg/agent-kit/dist/skill-content.js +2 -0
  134. package/node_modules/@noodle-borg/agent-kit/dist/skill-content.js.map +1 -1
  135. package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-ref.js +3 -3
  136. package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-ref.js.map +1 -1
  137. package/node_modules/@noodle-borg/agent-kit/dist/skill-feedback-ref.d.ts.map +1 -1
  138. package/node_modules/@noodle-borg/agent-kit/dist/skill-feedback-ref.js +11 -9
  139. package/node_modules/@noodle-borg/agent-kit/dist/skill-feedback-ref.js.map +1 -1
  140. package/node_modules/@noodle-borg/agent-kit/dist/skill-router.d.ts.map +1 -1
  141. package/node_modules/@noodle-borg/agent-kit/dist/skill-router.js +4 -3
  142. package/node_modules/@noodle-borg/agent-kit/dist/skill-router.js.map +1 -1
  143. package/node_modules/@noodle-borg/agent-kit/dist/skill-server-playbook-ref.js +1 -1
  144. package/node_modules/@noodle-borg/agent-kit/dist/skill-server-playbook-ref.js.map +1 -1
  145. package/node_modules/@noodle-borg/agent-kit/dist/skill-tool-design-ref.d.ts +2 -0
  146. package/node_modules/@noodle-borg/agent-kit/dist/skill-tool-design-ref.d.ts.map +1 -0
  147. package/node_modules/@noodle-borg/agent-kit/dist/skill-tool-design-ref.js +115 -0
  148. package/node_modules/@noodle-borg/agent-kit/dist/skill-tool-design-ref.js.map +1 -0
  149. package/node_modules/@noodle-borg/developer-mcp/dist/capabilities.d.ts +1 -1
  150. package/node_modules/@noodle-borg/developer-mcp/dist/capabilities.d.ts.map +1 -1
  151. package/node_modules/@noodle-borg/developer-mcp/dist/capabilities.js +5 -0
  152. package/node_modules/@noodle-borg/developer-mcp/dist/capabilities.js.map +1 -1
  153. package/node_modules/@noodle-borg/developer-mcp/dist/contracts.d.ts +2 -0
  154. package/node_modules/@noodle-borg/developer-mcp/dist/contracts.d.ts.map +1 -1
  155. package/node_modules/@noodle-borg/module/dist/contract.d.ts +9 -1
  156. package/node_modules/@noodle-borg/module/dist/contract.d.ts.map +1 -1
  157. package/node_modules/@noodle-borg/module/dist/contract.js.map +1 -1
  158. package/node_modules/@noodle-borg/service/dist/data-plane-membership.d.ts +22 -0
  159. package/node_modules/@noodle-borg/service/dist/data-plane-membership.d.ts.map +1 -0
  160. package/node_modules/@noodle-borg/service/dist/data-plane-membership.js +64 -0
  161. package/node_modules/@noodle-borg/service/dist/data-plane-membership.js.map +1 -0
  162. package/node_modules/@noodle-borg/service/dist/oauth/developer-grant-page.d.ts.map +1 -1
  163. package/node_modules/@noodle-borg/service/dist/oauth/developer-grant-page.js +3 -0
  164. package/node_modules/@noodle-borg/service/dist/oauth/developer-grant-page.js.map +1 -1
  165. package/node_modules/@noodle-borg/service/dist/platform-identity-postgres.d.ts +7 -1
  166. package/node_modules/@noodle-borg/service/dist/platform-identity-postgres.d.ts.map +1 -1
  167. package/node_modules/@noodle-borg/service/dist/platform-identity-postgres.js +19 -0
  168. package/node_modules/@noodle-borg/service/dist/platform-identity-postgres.js.map +1 -1
  169. package/node_modules/@noodle-borg/service/dist/platform-identity.d.ts +19 -2
  170. package/node_modules/@noodle-borg/service/dist/platform-identity.d.ts.map +1 -1
  171. package/node_modules/@noodle-borg/service/dist/platform-identity.js +7 -2
  172. package/node_modules/@noodle-borg/service/dist/platform-identity.js.map +1 -1
  173. package/node_modules/@noodle-borg/service/dist/registry-helpers.d.ts +10 -0
  174. package/node_modules/@noodle-borg/service/dist/registry-helpers.d.ts.map +1 -1
  175. package/node_modules/@noodle-borg/service/dist/registry-helpers.js +26 -0
  176. package/node_modules/@noodle-borg/service/dist/registry-helpers.js.map +1 -1
  177. package/node_modules/@noodle-borg/service/dist/registry-state.d.ts +6 -0
  178. package/node_modules/@noodle-borg/service/dist/registry-state.d.ts.map +1 -1
  179. package/node_modules/@noodle-borg/service/dist/registry-state.js +28 -0
  180. package/node_modules/@noodle-borg/service/dist/registry-state.js.map +1 -1
  181. package/node_modules/@noodle-borg/service/dist/registry-types.d.ts +20 -1
  182. package/node_modules/@noodle-borg/service/dist/registry-types.d.ts.map +1 -1
  183. package/node_modules/@noodle-borg/service/dist/registry.d.ts +4 -9
  184. package/node_modules/@noodle-borg/service/dist/registry.d.ts.map +1 -1
  185. package/node_modules/@noodle-borg/service/dist/registry.js +9 -46
  186. package/node_modules/@noodle-borg/service/dist/registry.js.map +1 -1
  187. package/node_modules/@noodle-borg/service/dist/routes/config-values-dispatch.d.ts +2 -0
  188. package/node_modules/@noodle-borg/service/dist/routes/config-values-dispatch.d.ts.map +1 -1
  189. package/node_modules/@noodle-borg/service/dist/routes/config-values-dispatch.js +1 -1
  190. package/node_modules/@noodle-borg/service/dist/routes/config-values-dispatch.js.map +1 -1
  191. package/node_modules/@noodle-borg/service/dist/routes/config-values.d.ts +2 -1
  192. package/node_modules/@noodle-borg/service/dist/routes/config-values.d.ts.map +1 -1
  193. package/node_modules/@noodle-borg/service/dist/routes/config-values.js +32 -2
  194. package/node_modules/@noodle-borg/service/dist/routes/config-values.js.map +1 -1
  195. package/node_modules/@noodle-borg/service/dist/routes/contracts.d.ts +1 -1
  196. package/node_modules/@noodle-borg/service/dist/routes/contracts.d.ts.map +1 -1
  197. package/node_modules/@noodle-borg/service/dist/routes/contracts.js +1 -1
  198. package/node_modules/@noodle-borg/service/dist/routes/contracts.js.map +1 -1
  199. package/node_modules/@noodle-borg/service/dist/routes/control-plane.d.ts +13 -2
  200. package/node_modules/@noodle-borg/service/dist/routes/control-plane.d.ts.map +1 -1
  201. package/node_modules/@noodle-borg/service/dist/routes/control-plane.js +16 -3
  202. package/node_modules/@noodle-borg/service/dist/routes/control-plane.js.map +1 -1
  203. package/node_modules/@noodle-borg/service/dist/routes/feedback.d.ts +6 -1
  204. package/node_modules/@noodle-borg/service/dist/routes/feedback.d.ts.map +1 -1
  205. package/node_modules/@noodle-borg/service/dist/routes/feedback.js +24 -13
  206. package/node_modules/@noodle-borg/service/dist/routes/feedback.js.map +1 -1
  207. package/node_modules/@noodle-borg/service/dist/routes/org-admin-dispatch.d.ts.map +1 -1
  208. package/node_modules/@noodle-borg/service/dist/routes/org-admin-dispatch.js +14 -1
  209. package/node_modules/@noodle-borg/service/dist/routes/org-admin-dispatch.js.map +1 -1
  210. package/node_modules/@noodle-borg/service/dist/routes/org-admin.d.ts +6 -0
  211. package/node_modules/@noodle-borg/service/dist/routes/org-admin.d.ts.map +1 -1
  212. package/node_modules/@noodle-borg/service/dist/routes/org-admin.js +2 -2
  213. package/node_modules/@noodle-borg/service/dist/routes/org-admin.js.map +1 -1
  214. package/node_modules/@noodle-borg/service/dist/routes/org-domains.d.ts +17 -0
  215. package/node_modules/@noodle-borg/service/dist/routes/org-domains.d.ts.map +1 -0
  216. package/node_modules/@noodle-borg/service/dist/routes/org-domains.js +85 -0
  217. package/node_modules/@noodle-borg/service/dist/routes/org-domains.js.map +1 -0
  218. package/node_modules/@noodle-borg/service/dist/routes/paths.d.ts +5 -0
  219. package/node_modules/@noodle-borg/service/dist/routes/paths.d.ts.map +1 -1
  220. package/node_modules/@noodle-borg/service/dist/routes/paths.js +25 -1
  221. package/node_modules/@noodle-borg/service/dist/routes/paths.js.map +1 -1
  222. package/node_modules/@noodle-borg/service/dist/service.d.ts.map +1 -1
  223. package/node_modules/@noodle-borg/service/dist/service.js +14 -15
  224. package/node_modules/@noodle-borg/service/dist/service.js.map +1 -1
  225. package/node_modules/@noodle-borg/service/dist/store/control-plane.d.ts +5 -1
  226. package/node_modules/@noodle-borg/service/dist/store/control-plane.d.ts.map +1 -1
  227. package/node_modules/@noodle-borg/service/dist/store/control-plane.js +9 -4
  228. package/node_modules/@noodle-borg/service/dist/store/control-plane.js.map +1 -1
  229. package/node_modules/@noodle-borg/service/dist/store/postgres-control-plane.d.ts +6 -2
  230. package/node_modules/@noodle-borg/service/dist/store/postgres-control-plane.d.ts.map +1 -1
  231. package/node_modules/@noodle-borg/service/dist/store/postgres-control-plane.js +9 -5
  232. package/node_modules/@noodle-borg/service/dist/store/postgres-control-plane.js.map +1 -1
  233. package/node_modules/@noodle-borg/service/dist/store/postgres-deploy-records.d.ts +9 -0
  234. package/node_modules/@noodle-borg/service/dist/store/postgres-deploy-records.d.ts.map +1 -0
  235. package/node_modules/@noodle-borg/service/dist/store/postgres-deploy-records.js +85 -0
  236. package/node_modules/@noodle-borg/service/dist/store/postgres-deploy-records.js.map +1 -0
  237. package/node_modules/@noodle-borg/service/dist/store/postgres.d.ts +5 -1
  238. package/node_modules/@noodle-borg/service/dist/store/postgres.d.ts.map +1 -1
  239. package/node_modules/@noodle-borg/service/dist/store/postgres.js +13 -80
  240. package/node_modules/@noodle-borg/service/dist/store/postgres.js.map +1 -1
  241. package/node_modules/@noodle-borg/service/dist/store/validate.d.ts +2 -0
  242. package/node_modules/@noodle-borg/service/dist/store/validate.d.ts.map +1 -1
  243. package/node_modules/@noodle-borg/service/dist/store/validate.js +31 -0
  244. package/node_modules/@noodle-borg/service/dist/store/validate.js.map +1 -1
  245. package/node_modules/@noodle-borg/service/dist/store.d.ts +8 -2
  246. package/node_modules/@noodle-borg/service/dist/store.d.ts.map +1 -1
  247. package/node_modules/@noodle-borg/service/dist/store.js +1 -1
  248. package/node_modules/@noodle-borg/service/dist/store.js.map +1 -1
  249. package/node_modules/@noodle-borg/transport-http/dist/handler.d.ts +1 -1
  250. package/node_modules/@noodle-borg/transport-http/dist/handler.d.ts.map +1 -1
  251. package/node_modules/@noodle-borg/transport-http/dist/handler.js +9 -7
  252. package/node_modules/@noodle-borg/transport-http/dist/handler.js.map +1 -1
  253. package/node_modules/@noodle-borg/transport-http/dist/index.d.ts +1 -1
  254. package/node_modules/@noodle-borg/transport-http/dist/index.d.ts.map +1 -1
  255. package/node_modules/@noodle-borg/transport-http/dist/index.js.map +1 -1
  256. package/node_modules/@noodle-borg/wire-contracts/dist/feedback.d.ts +17 -0
  257. package/node_modules/@noodle-borg/wire-contracts/dist/feedback.d.ts.map +1 -1
  258. package/node_modules/@noodle-borg/wire-contracts/dist/feedback.js +22 -4
  259. package/node_modules/@noodle-borg/wire-contracts/dist/feedback.js.map +1 -1
  260. package/package.json +1 -1
  261. package/dist/commands/catalog-data-resources.d.ts +0 -8
  262. package/dist/commands/catalog-data-resources.d.ts.map +0 -1
  263. package/dist/commands/catalog-data-resources.js +0 -742
  264. package/dist/commands/catalog-data-resources.js.map +0 -1
@@ -22,7 +22,7 @@ export const BUNDLED_EXAMPLE_FILES = [
22
22
  { relPath: "examples/acme-bistro/noodle.json", content: "{\n \"entrypoint\": \"src/server.ts\",\n \"name\": \"acme-bistro\",\n \"template\": \"widget\"\n}\n" },
23
23
  { relPath: "examples/acme-bistro/package.json", content: "{\n \"name\": \"acme-bistro\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {\n \"test\": \"vitest run\",\n \"validate\": \"noodle validate\",\n \"dev\": \"noodle dev\",\n \"deploy\": \"noodle deploy\"\n },\n \"devDependencies\": {\n \"@vitejs/plugin-react\": \"latest\",\n \"@noodleseed/one\": \"latest\",\n \"react\": \"latest\",\n \"react-dom\": \"latest\",\n \"vite\": \"latest\",\n \"vitest\": \"latest\"\n }\n}\n" },
24
24
  { relPath: "examples/acme-bistro/src/helpers.ts", content: "import type { ServerDefinition } from '@noodleseed/one';\nimport { generateHelpers } from '@noodleseed/one/react';\n\nexport type AppType = ServerDefinition;\n\nexport const { useCallTool, useLayout, useOpenExternal, useToolInfo, useViewState } =\n generateHelpers<AppType>();\n" },
25
- { relPath: "examples/acme-bistro/src/server.ts", content: "import { annotations, server, tool, z } from '@noodleseed/one';\n\n// Acme Bistro is a fictional restaurant. This app is deliberately END-TO-END: the customer browses the\n// menu and builds the order in chat, and the order completes in chat — only *payment* hands off, via a\n// checkout deep link (the card never touches the app). It is the flagship for the end-to-end pattern.\n//\n// Authoring notes:\n// - A tool `fulfil` is *recorded*, not run as live JS. Inline inputs directly as `${input.x}` into\n// output strings; do not transform them (no arithmetic/encode/filter) or substitution breaks. The\n// cart total is summed in the widget (live React), not in a `fulfil`. The menu below is static data.\n\nconst menu = [\n { id: 'stone_pizza', name: 'Stone-baked Margherita', price: 14, kind: 'Mains' },\n { id: 'roast_bowl', name: 'Harvest Roast Bowl', price: 13, kind: 'Mains' },\n { id: 'house_salad', name: 'House Garden Salad', price: 11, kind: 'Starters' },\n { id: 'lemon_tart', name: 'Lemon Tart', price: 8, kind: 'Desserts' },\n { id: 'sparkling', name: 'Sparkling Water', price: 4, kind: 'Drinks' },\n] as const;\n\nconst itemId = z.enum(['stone_pizza', 'roast_bowl', 'house_salad', 'lemon_tart', 'sparkling']);\n\nconst menuItemOutput = z.object({\n id: z.string(),\n name: z.string(),\n price: z.number(),\n kind: z.string(),\n});\n\n// Tool annotations for host planners: the menu read is read-only; cart edits are local writes; checkout\n// opens an external (payment) link.\nconst readOnly = annotations.readOnly();\nconst localWrite = annotations.localAction({ destructive: false, confirm: false });\nconst openLink = annotations.openAction();\n\nexport default server(\n 'acme_bistro',\n {\n title: 'Acme Bistro',\n version: '1.0.0',\n branding: {\n name: 'Acme Bistro',\n accent: '#B91C1C',\n surface: '#FEF3F2',\n surfaceDark: '#1A1211',\n radius: 'lg',\n density: 'comfortable',\n },\n // Payment is the only off-app step; the compiler derives ChatGPT redirect_domains from this so the\n // signed checkout link opens without a safe-link warning. The card never reaches this app.\n handoff: {\n allowedDomains: ['https://pay.acme.example', 'https://acme.example'],\n },\n },\n [\n tool('show_menu', {\n description: 'Show the Acme Bistro menu and render the ordering widget.',\n annotations: readOnly,\n input: z.object({ customer: z.string().default('Guest') }),\n output: z.object({\n status: z.string(),\n customer: z.string(),\n items: z.array(menuItemOutput),\n }),\n fulfil: ({ input }) => ({\n status: `Acme Bistro menu is ready for ${input.customer}. Build the order here; pay at checkout.`,\n customer: input.customer,\n items: menu,\n }),\n viewTitle: 'Order at Acme Bistro',\n viewDescription: 'Browse the menu, build an order in chat, and hand off to pay.',\n invoking: 'Loading the menu…',\n invoked: 'Menu ready',\n domain: 'https://order.acme.example',\n view: {\n component: 'menu-cart',\n entry: './views/menu-cart.tsx',\n },\n csp: {\n connectDomains: ['https://acme.example'],\n resourceDomains: ['https://acme.example'],\n frameDomains: ['https://acme.example'],\n },\n }),\n // Widget-only cart edits — the model fills the item from natural language (\"add two margheritas\").\n tool('add_to_cart', {\n visibility: ['app'],\n description: 'Add a menu item to the Acme Bistro order from the widget.',\n annotations: localWrite,\n input: z.object({\n customer: z.string().default('Guest'),\n item: itemId.default('stone_pizza'),\n quantity: z.number().int().min(1).default(1),\n notes: z.string().default(''),\n }),\n output: z.object({\n status: z.string(),\n item: z.string(),\n quantity: z.number(),\n notes: z.string(),\n }),\n fulfil: ({ input }) => ({\n status: `Added ${input.quantity} × ${input.item} for ${input.customer}.`,\n item: input.item,\n quantity: input.quantity,\n notes: input.notes,\n }),\n }),\n tool('remove_from_cart', {\n visibility: ['app'],\n description: 'Remove a menu item from the Acme Bistro order.',\n annotations: localWrite,\n input: z.object({\n customer: z.string().default('Guest'),\n item: itemId.default('stone_pizza'),\n }),\n output: z.object({\n status: z.string(),\n item: z.string(),\n }),\n fulfil: ({ input }) => ({\n status: `Removed ${input.item} for ${input.customer}.`,\n item: input.item,\n }),\n }),\n // The only handoff: payment. The widget computes the total (live React) and passes a url-safe cart\n // token + total; the card is entered on Acme's PCI-scoped checkout, never in chat.\n tool('create_checkout', {\n description:\n 'Create the Acme Bistro payment checkout link for the current order. Pass a url-safe cart token ' +\n 'and the numeric total computed in the widget. Payment happens off-app; the card never reaches this app.',\n annotations: openLink,\n input: z.object({\n customer: z.string().default('Guest'),\n cartToken: z.string().default('cart'),\n total: z.number().min(0).default(0),\n }),\n output: z.object({\n status: z.string(),\n summary: z.string(),\n checkoutUrl: z.string(),\n }),\n // Do not place a literal `$` immediately before a token (`$${input.total}`) — it collides with the\n // `${…}` substitution syntax and leaves the token unresolved. Keep the amount token standalone.\n fulfil: ({ input }) => ({\n status: `Ready to pay for ${input.customer}'s order.`,\n summary: `${input.customer}'s Acme Bistro order · ${input.total} USD`,\n checkoutUrl: `https://pay.acme.example/checkout?cart=${input.cartToken}&total=${input.total}&src=chatgpt`,\n }),\n }),\n ],\n);\n" },
25
+ { relPath: "examples/acme-bistro/src/server.ts", content: "import { annotations, server, tool, z } from '@noodleseed/one';\n\n// Acme Bistro is a fictional restaurant. This app is deliberately END-TO-END: the customer browses the\n// menu and builds the order in chat, and the order completes in chat — only *payment* hands off, via a\n// checkout deep link (the card never touches the app). It is the flagship for the end-to-end pattern.\n//\n// Authoring notes:\n// - A tool `fulfil` is *recorded*, not run as live JS. Inline inputs directly as `${input.x}` into\n// output strings; do not transform them (no arithmetic/encode/filter) or substitution breaks. The\n// cart total is summed in the widget (live React), not in a `fulfil`. The menu below is static data.\n\nconst menu = [\n { id: 'stone_pizza', name: 'Stone-baked Margherita', price: 14, kind: 'Mains' },\n { id: 'roast_bowl', name: 'Harvest Roast Bowl', price: 13, kind: 'Mains' },\n { id: 'house_salad', name: 'House Garden Salad', price: 11, kind: 'Starters' },\n { id: 'lemon_tart', name: 'Lemon Tart', price: 8, kind: 'Desserts' },\n { id: 'sparkling', name: 'Sparkling Water', price: 4, kind: 'Drinks' },\n] as const;\n\nconst itemId = z.enum(['stone_pizza', 'roast_bowl', 'house_salad', 'lemon_tart', 'sparkling']);\n\nconst menuItemOutput = z.object({\n id: z.string(),\n name: z.string(),\n price: z.number(),\n kind: z.string(),\n});\n\n// Tool annotations for host planners: the menu read is read-only; cart edits are local writes; checkout\n// opens an external (payment) link.\nconst readOnly = annotations.readOnly();\nconst localWrite = annotations.localAction({ destructive: false, confirm: false });\nconst openLink = annotations.openAction();\n\nexport default server(\n 'acme_bistro',\n {\n title: 'Acme Bistro',\n version: '1.0.0',\n branding: {\n name: 'Acme Bistro',\n accent: '#B91C1C',\n surface: '#FEF3F2',\n surfaceDark: '#1A1211',\n radius: 'lg',\n density: 'comfortable',\n },\n // Payment is the only off-app step; the compiler derives ChatGPT redirect_domains from this so the\n // signed checkout link opens without a safe-link warning. The card never reaches this app.\n handoff: {\n allowedDomains: ['https://pay.acme.example', 'https://acme.example'],\n },\n },\n [\n tool('show_menu', {\n title: 'Show the menu',\n description: 'Show the Acme Bistro menu and render the ordering widget.',\n annotations: readOnly,\n input: z.object({ customer: z.string().default('Guest') }),\n output: z.object({\n status: z.string(),\n customer: z.string(),\n // Bounded list: the menu is a fixed catalog, so the ceiling is declared on the shape rather\n // than taken as a pagination input. `noodle check` reports `tool_design_output_bounds`.\n items: z.array(menuItemOutput).max(50),\n }),\n fulfil: ({ input }) => ({\n status: `Acme Bistro menu is ready for ${input.customer}. Build the order here; pay at checkout.`,\n customer: input.customer,\n items: menu,\n }),\n viewTitle: 'Order at Acme Bistro',\n viewDescription: 'Browse the menu, build an order in chat, and hand off to pay.',\n invoking: 'Loading the menu…',\n invoked: 'Menu ready',\n domain: 'https://order.acme.example',\n view: {\n component: 'menu-cart',\n entry: './views/menu-cart.tsx',\n },\n csp: {\n connectDomains: ['https://acme.example'],\n resourceDomains: ['https://acme.example'],\n frameDomains: ['https://acme.example'],\n },\n }),\n // Widget-only cart edits — the model fills the item from natural language (\"add two margheritas\").\n tool('add_to_cart', {\n visibility: ['app'],\n description: 'Add a menu item to the Acme Bistro order from the widget.',\n annotations: localWrite,\n input: z.object({\n customer: z.string().default('Guest'),\n item: itemId.default('stone_pizza'),\n quantity: z.number().int().min(1).default(1),\n notes: z.string().default(''),\n }),\n output: z.object({\n status: z.string(),\n item: z.string(),\n quantity: z.number(),\n notes: z.string(),\n }),\n fulfil: ({ input }) => ({\n status: `Added ${input.quantity} × ${input.item} for ${input.customer}.`,\n item: input.item,\n quantity: input.quantity,\n notes: input.notes,\n }),\n }),\n tool('remove_from_cart', {\n visibility: ['app'],\n description: 'Remove a menu item from the Acme Bistro order.',\n annotations: localWrite,\n input: z.object({\n customer: z.string().default('Guest'),\n item: itemId.default('stone_pizza'),\n }),\n output: z.object({\n status: z.string(),\n item: z.string(),\n }),\n fulfil: ({ input }) => ({\n status: `Removed ${input.item} for ${input.customer}.`,\n item: input.item,\n }),\n }),\n // The only handoff: payment. The widget computes the total (live React) and passes a url-safe cart\n // token + total; the card is entered on Acme's PCI-scoped checkout, never in chat.\n tool('create_checkout', {\n title: 'Create checkout',\n description:\n 'Create the Acme Bistro payment checkout link for the current order. Pass a url-safe cart token ' +\n 'and the numeric total computed in the widget. Payment happens off-app; the card never reaches this app.',\n annotations: openLink,\n input: z.object({\n customer: z.string().default('Guest'),\n cartToken: z.string().default('cart'),\n total: z.number().min(0).default(0),\n }),\n output: z.object({\n status: z.string(),\n summary: z.string(),\n checkoutUrl: z.string(),\n }),\n // Do not place a literal `$` immediately before a token (`$${input.total}`) — it collides with the\n // `${…}` substitution syntax and leaves the token unresolved. Keep the amount token standalone.\n fulfil: ({ input }) => ({\n status: `Ready to pay for ${input.customer}'s order.`,\n summary: `${input.customer}'s Acme Bistro order · ${input.total} USD`,\n checkoutUrl: `https://pay.acme.example/checkout?cart=${input.cartToken}&total=${input.total}&src=chatgpt`,\n }),\n }),\n ],\n);\n" },
26
26
  { relPath: "examples/acme-bistro/src/views/menu-cart.tsx", content: "import { useMemo, useState } from 'react';\nimport { useCallTool, useLayout, useOpenExternal, useToolInfo, useViewState } from '../helpers.js';\nimport './widget-style.css';\n\ntype MenuItem = {\n readonly id: string;\n readonly name: string;\n readonly price: number;\n readonly kind: string;\n};\n\nfunction asMenu(value: unknown) {\n return value as\n | { readonly status?: string; readonly customer?: string; readonly items?: readonly MenuItem[] }\n | undefined;\n}\n\nexport default function MenuCart() {\n const { displayMode, theme } = useLayout();\n const openExternal = useOpenExternal();\n const menuResult = asMenu(useToolInfo('show_menu').structuredContent);\n const addToCart = useCallTool('add_to_cart');\n const removeFromCart = useCallTool('remove_from_cart');\n const checkout = useCallTool('create_checkout');\n\n const items = menuResult?.items ?? [];\n const [customer] = useViewState('customer', menuResult?.customer ?? 'Guest');\n // Cart is session-local (id → quantity); the total is summed here in live React, not in a recorded fulfil.\n const [cart, setCart] = useState<Record<string, number>>({});\n const [status, setStatus] = useState(\n menuResult?.status ?? 'Build your order, then check out to pay.',\n );\n\n const total = useMemo(\n () => items.reduce((sum, item) => sum + item.price * (cart[item.id] ?? 0), 0),\n [items, cart],\n );\n const lineCount = Object.values(cart).reduce((n, q) => n + q, 0);\n\n async function add(item: MenuItem) {\n setCart((current) => ({ ...current, [item.id]: (current[item.id] ?? 0) + 1 }));\n const result = await addToCart.callTool({ customer, item: item.id, quantity: 1 });\n const structured = result.structuredContent as { readonly status?: string } | undefined;\n setStatus(structured?.status ?? `Added ${item.name}.`);\n }\n\n async function remove(item: MenuItem) {\n setCart((current) => {\n const next = { ...current };\n const q = (next[item.id] ?? 0) - 1;\n if (q <= 0) delete next[item.id];\n else next[item.id] = q;\n return next;\n });\n await removeFromCart.callTool({ customer, item: item.id });\n }\n\n async function payNow() {\n if (lineCount === 0) return;\n // The only handoff: payment. A url-safe cart token + the numeric total go to Acme's PCI checkout.\n const cartToken = items\n .filter((item) => cart[item.id])\n .map((item) => `${item.id}x${cart[item.id]}`)\n .join('-');\n const result = await checkout.callTool({ customer, cartToken, total });\n const structured = result.structuredContent as { readonly checkoutUrl?: string } | undefined;\n if (structured?.checkoutUrl) openExternal(structured.checkoutUrl);\n }\n\n return (\n <main\n className={`nw-shell${theme === 'dark' ? ' dark' : ''}`}\n data-llm={`Acme Bistro order for ${customer}: ${lineCount} item(s), total $${total}`}\n >\n <section className=\"nw-card\">\n <header className=\"nw-header\">\n <span className=\"nw-icon\" aria-hidden=\"true\">\n <PlateIcon />\n </span>\n <div className=\"nw-title-block\">\n <h1 className=\"nw-title\">Acme Bistro</h1>\n <p className=\"nw-subtitle\">{status}</p>\n </div>\n <span className=\"nw-chip\">\n {displayMode === 'fullscreen' ? 'Fullscreen' : `${lineCount} in cart`}\n </span>\n </header>\n\n <ul className=\"nw-menu\">\n {items.map((item) => (\n <li className=\"nw-row\" key={item.id}>\n <span className=\"nw-row-main\">\n <span className=\"nw-name\">{item.name}</span>\n <span className=\"nw-kind\">{item.kind}</span>\n </span>\n <span className=\"nw-price\">${item.price}</span>\n <span className=\"nw-qty\">\n <button\n aria-label={`Remove one ${item.name}`}\n className=\"nw-step\"\n type=\"button\"\n disabled={!cart[item.id]}\n onClick={() => remove(item)}\n >\n −\n </button>\n <span className=\"nw-count\">{cart[item.id] ?? 0}</span>\n <button\n aria-label={`Add one ${item.name}`}\n className=\"nw-step\"\n type=\"button\"\n onClick={() => add(item)}\n >\n +\n </button>\n </span>\n </li>\n ))}\n </ul>\n\n <footer className=\"nw-footer\">\n <span className=\"nw-total\">\n Total <strong>${total}</strong>\n </span>\n <button\n className=\"nw-button nw-button-primary\"\n type=\"button\"\n disabled={lineCount === 0 || checkout.isPending}\n onClick={payNow}\n >\n <CardIcon />\n {checkout.isPending ? 'Opening checkout…' : 'Check out & pay'}\n </button>\n </footer>\n <p className=\"nw-note\">\n Payment happens on acme.example — your card is never entered in chat.\n </p>\n </section>\n </main>\n );\n}\n\nfunction PlateIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <circle cx=\"12\" cy=\"12\" r=\"9\" />\n <circle cx=\"12\" cy=\"12\" r=\"4\" />\n </svg>\n );\n}\n\nfunction CardIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <rect x=\"3\" y=\"5\" width=\"18\" height=\"14\" rx=\"2\" />\n <path d=\"M3 10h18\" />\n </svg>\n );\n}\n" },
27
27
  { relPath: "examples/acme-bistro/src/views/widget-style.css", content: ":root {\n color-scheme: light dark;\n font-family:\n Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n --nw-bg: #ffffff;\n --nw-surface: #fdf4f3;\n --nw-text: #1f1413;\n --nw-muted: #7a5f5c;\n --nw-border: #efd9d6;\n --nw-accent: #b91c1c;\n --nw-accent-strong: #991b1b;\n --nw-accent-soft: #fdeae8;\n --nw-radius: 10px;\n --nw-shadow: 0 18px 50px rgb(60 20 20 / 12%);\n}\n\n.dark,\n[data-theme=\"dark\"] {\n --nw-bg: #1a1211;\n --nw-surface: #241816;\n --nw-text: #f8ecea;\n --nw-muted: #c3a29e;\n --nw-border: #43302d;\n --nw-accent: #f87171;\n --nw-accent-strong: #ef4444;\n --nw-accent-soft: #3a1f1d;\n --nw-shadow: 0 18px 50px rgb(0 0 0 / 32%);\n}\n\n* {\n box-sizing: border-box;\n}\n\nbody {\n margin: 0;\n background: var(--nw-bg);\n color: var(--nw-text);\n}\n\nbutton {\n font: inherit;\n}\n\n.nw-shell {\n min-height: 100vh;\n padding: 14px;\n background: var(--nw-bg);\n color: var(--nw-text);\n}\n\n.nw-card {\n max-width: 560px;\n margin: 0 auto;\n background: var(--nw-surface);\n border: 1px solid var(--nw-border);\n border-radius: var(--nw-radius);\n box-shadow: var(--nw-shadow);\n overflow: hidden;\n}\n\n.nw-header {\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 16px;\n border-bottom: 1px solid var(--nw-border);\n}\n\n.nw-icon svg {\n width: 24px;\n height: 24px;\n fill: none;\n stroke: var(--nw-accent);\n stroke-width: 1.7;\n stroke-linecap: round;\n stroke-linejoin: round;\n}\n\n.nw-title-block {\n flex: 1;\n min-width: 0;\n}\n\n.nw-title {\n margin: 0;\n font-size: 17px;\n font-weight: 700;\n}\n\n.nw-subtitle {\n margin: 2px 0 0;\n font-size: 13px;\n color: var(--nw-muted);\n}\n\n.nw-chip {\n padding: 4px 10px;\n border-radius: 999px;\n background: var(--nw-accent-soft);\n color: var(--nw-accent-strong);\n font-size: 12px;\n font-weight: 600;\n}\n\n.nw-menu {\n list-style: none;\n margin: 0;\n padding: 8px 16px;\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n\n.nw-row {\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 10px 0;\n border-bottom: 1px dashed var(--nw-border);\n}\n\n.nw-row-main {\n flex: 1;\n min-width: 0;\n display: flex;\n flex-direction: column;\n}\n\n.nw-name {\n font-weight: 600;\n}\n\n.nw-kind {\n font-size: 12px;\n color: var(--nw-muted);\n}\n\n.nw-price {\n color: var(--nw-accent-strong);\n font-weight: 600;\n font-size: 13px;\n}\n\n.nw-qty {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n}\n\n.nw-step {\n width: 26px;\n height: 26px;\n border: 1px solid var(--nw-border);\n border-radius: 8px;\n background: var(--nw-bg);\n color: var(--nw-text);\n cursor: pointer;\n}\n\n.nw-step:disabled {\n opacity: 0.4;\n cursor: default;\n}\n\n.nw-count {\n min-width: 16px;\n text-align: center;\n font-variant-numeric: tabular-nums;\n}\n\n.nw-footer {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n padding: 12px 16px 4px;\n}\n\n.nw-total {\n font-size: 14px;\n color: var(--nw-muted);\n}\n\n.nw-total strong {\n color: var(--nw-text);\n font-size: 16px;\n}\n\n.nw-button {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 9px 14px;\n border: 1px solid var(--nw-border);\n border-radius: 10px;\n background: var(--nw-bg);\n color: var(--nw-text);\n cursor: pointer;\n}\n\n.nw-button svg {\n width: 16px;\n height: 16px;\n fill: none;\n stroke: currentColor;\n stroke-width: 1.7;\n stroke-linecap: round;\n stroke-linejoin: round;\n}\n\n.nw-button-primary {\n background: var(--nw-accent);\n border-color: var(--nw-accent);\n color: #ffffff;\n font-weight: 600;\n}\n\n.nw-button-primary:disabled {\n opacity: 0.6;\n cursor: default;\n}\n\n.nw-note {\n margin: 0;\n padding: 8px 16px 16px;\n font-size: 12px;\n color: var(--nw-muted);\n}\n" },
28
28
  { relPath: "examples/acme-bistro/test/server.test.ts", content: "import { describe, expect, it } from 'vitest';\nimport app from '../src/server.js';\n\ndescribe('acme-bistro example', () => {\n it('exports a Noodle server definition', () => {\n expect(typeof app.toManifest).toBe('function');\n });\n\n it('declares the payment-only handoff domain', async () => {\n // End-to-end: the order completes in chat; only payment hands off to Acme's checkout.\n const text = JSON.stringify(await app.toManifest());\n expect(text).toContain('https://pay.acme.example');\n });\n\n it('exposes the menu widget, cart helpers, and checkout tool', async () => {\n const text = JSON.stringify(await app.toManifest());\n expect(text).toContain('show_menu');\n expect(text).toContain('add_to_cart');\n expect(text).toContain('create_checkout');\n });\n});\n" },
@@ -33,7 +33,7 @@ export const BUNDLED_EXAMPLE_FILES = [
33
33
  { relPath: "examples/acme-discovery/noodle.json", content: "{\n \"entrypoint\": \"src/server.ts\",\n \"name\": \"acme-discovery\",\n \"template\": \"widget\"\n}\n" },
34
34
  { relPath: "examples/acme-discovery/package.json", content: "{\n \"name\": \"acme-discovery\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {\n \"test\": \"vitest run\",\n \"validate\": \"noodle validate\",\n \"dev\": \"noodle dev\",\n \"deploy\": \"noodle deploy\"\n },\n \"devDependencies\": {\n \"@vitejs/plugin-react\": \"latest\",\n \"@noodleseed/one\": \"latest\",\n \"react\": \"latest\",\n \"react-dom\": \"latest\",\n \"vite\": \"latest\",\n \"vitest\": \"latest\"\n }\n}\n" },
35
35
  { relPath: "examples/acme-discovery/src/helpers.ts", content: "import type { ServerDefinition } from '@noodleseed/one';\nimport { generateHelpers } from '@noodleseed/one/react';\n\nexport type AppType = ServerDefinition;\n\nexport const { useCallTool, useLayout, useOpenExternal, useToolInfo, useViewState } =\n generateHelpers<AppType>();\n" },
36
- { relPath: "examples/acme-discovery/src/server.ts", content: "import { annotations, server, tool, z } from '@noodleseed/one';\n\n// Acme Getaways is a fictional travel brand. This app is deliberately top-of-funnel: discovery and\n// configuration happen inside ChatGPT; the booking/transaction happens off-app on Acme's own site,\n// reached through a signed, attributable handoff deep link. Destinations are the partner's own\n// catalog (grounding) — the app never invents a place, price, or best-month.\n//\n// Authoring note: a tool `fulfil` is *recorded*, not run as live JS. Inputs flow through as\n// `${input.x}` substitutions when placed directly into an output string; do not transform them\n// (no URL-encoding, arithmetic, or filtering on an input value — those break substitution). The\n// curated catalog below is static data the runtime returns verbatim.\n\nconst catalog = [\n {\n id: 'coral_bay',\n name: 'Coral Bay',\n region: 'Adriatic coast',\n vibe: 'beach',\n priceFrom: 890,\n bestMonths: 'May–Sep',\n why: 'Calm swimming coves and a walkable old town — easy for a relaxed first trip.',\n },\n {\n id: 'monte_alto',\n name: 'Monte Alto',\n region: 'Northern Alps',\n vibe: 'mountains',\n priceFrom: 1120,\n bestMonths: 'Dec–Mar',\n why: 'Ski-in village with beginner slopes and long groomed runs.',\n },\n {\n id: 'old_quarter',\n name: 'Old Quarter',\n region: 'Central Europe',\n vibe: 'culture',\n priceFrom: 640,\n bestMonths: 'Apr–Oct',\n why: 'Dense museum district and food halls, all reachable on foot.',\n },\n {\n id: 'harbor_city',\n name: 'Harbor City',\n region: 'Pacific rim',\n vibe: 'city',\n priceFrom: 980,\n bestMonths: 'Sep–Nov',\n why: 'Waterfront nightlife and day-trip islands a short ferry away.',\n },\n] as const;\n\n// A closed set of URL-safe month values. A `fulfil` cannot url-encode an input (recording would break\n// substitution), so the deep link carries `month` only if it is already safe — the model maps natural\n// phrasing (\"early June\") onto one of these when it fills the tool.\nconst monthEnum = z\n .enum([\n 'January',\n 'February',\n 'March',\n 'April',\n 'May',\n 'June',\n 'July',\n 'August',\n 'September',\n 'October',\n 'November',\n 'December',\n ])\n .default('June');\n\n// The catalog ids are already url-safe slugs. Constrain the handoff `destination` to this closed set so\n// only a real, url-safe id can reach the deep link.\nconst destinationId = z.enum(['coral_bay', 'monte_alto', 'old_quarter', 'harbor_city']);\n\nconst discoverInput = z.object({\n vibe: z.enum(['beach', 'mountains', 'culture', 'city']).default('beach'),\n month: monthEnum,\n travelers: z.number().int().min(1).default(2),\n});\n\n// Tool annotations for host planners: reads are read-only, the handoff opens an external link, and\n// shortlisting is a local non-destructive write.\nconst readOnly = annotations.readOnly();\nconst openLink = annotations.openAction();\nconst localWrite = annotations.localAction({ destructive: false, confirm: false });\n\nconst destinationOutput = z.object({\n id: z.string(),\n name: z.string(),\n region: z.string(),\n vibe: z.string(),\n priceFrom: z.number(),\n bestMonths: z.string(),\n why: z.string(),\n});\n\nexport default server(\n 'acme_discovery',\n {\n title: 'Acme Getaways',\n version: '1.0.0',\n branding: {\n name: 'Acme Getaways',\n accent: '#0EA5A4',\n surface: '#F0FDFA',\n surfaceDark: '#0B1B1B',\n radius: 'lg',\n density: 'comfortable',\n },\n // The only external destinations the app links out to — the compiler derives ChatGPT's\n // redirect_domains from this so the handoff opens without a safe-link warning.\n handoff: {\n allowedDomains: ['https://book.acme.example', 'https://acme.example'],\n },\n },\n [\n tool('discover_getaways', {\n description:\n 'Suggest Acme Getaways destinations for a vibe and month and render a discovery carousel.',\n annotations: readOnly,\n input: discoverInput,\n output: z.object({\n status: z.string(),\n vibe: z.string(),\n month: z.string(),\n travelers: z.number(),\n options: z.array(destinationOutput),\n }),\n // The carousel presents Acme's curated catalog; the model narrates which fit the stated vibe.\n // (A tool cannot filter on an input value — that is connector/flow work — so all are returned.)\n fulfil: ({ input }) => ({\n status: `Acme Getaways for a ${input.vibe} trip in ${input.month}, ${input.travelers} traveler(s).`,\n vibe: input.vibe,\n month: input.month,\n travelers: input.travelers,\n options: catalog,\n }),\n viewTitle: 'Discover getaways',\n // ChatGPT host status copy (openai/toolInvocation/*) — required for widget-opening tools.\n invoking: 'Finding getaways…',\n invoked: 'Getaways ready',\n domain: 'https://getaways.acme.example',\n view: {\n component: 'discovery-carousel',\n entry: './views/discovery-carousel.tsx',\n },\n viewDescription:\n 'A top-of-funnel discovery carousel: pick a destination, then hand off to Acme to book.',\n csp: {\n connectDomains: ['https://acme.example'],\n resourceDomains: ['https://acme.example'],\n frameDomains: ['https://acme.example'],\n },\n }),\n tool('create_handoff', {\n description:\n 'Create the Acme booking deep link for a chosen destination, carrying the configured trip. ' +\n 'Pass the destination id (url-safe slug, e.g. \"coral_bay\") and its display name.',\n annotations: openLink,\n input: z.object({\n destination: destinationId,\n destinationName: z.string().min(1),\n month: monthEnum,\n travelers: z.number().int().min(1).default(2),\n }),\n output: z.object({\n status: z.string(),\n destination: z.string(),\n summary: z.string(),\n handoffUrl: z.string(),\n }),\n // Inline the inputs directly so they substitute at runtime; every value is already url-safe\n // (id slug, month enum, integer), and `src=chatgpt` is the attribution the partner measures\n // ChatGPT-sourced conversions on.\n fulfil: ({ input }) => ({\n status: `Ready to continue on Acme for ${input.destinationName}.`,\n destination: input.destination,\n summary: `${input.destinationName} · ${input.month} · ${input.travelers} traveler(s)`,\n handoffUrl: `https://book.acme.example/plan?dest=${input.destination}&month=${input.month}&pax=${input.travelers}&src=chatgpt`,\n }),\n }),\n tool('shortlist_getaway', {\n visibility: ['app'],\n description: 'Record the traveler’s shortlisted destination from the discovery widget.',\n annotations: localWrite,\n input: z.object({\n destination: z.string(),\n note: z.string().default(''),\n }),\n output: z.object({\n status: z.string(),\n destination: z.string(),\n note: z.string(),\n }),\n fulfil: ({ input }) => ({\n status: `Shortlisted ${input.destination}.`,\n destination: input.destination,\n note: input.note,\n }),\n }),\n ],\n);\n" },
36
+ { relPath: "examples/acme-discovery/src/server.ts", content: "import { annotations, server, tool, z } from '@noodleseed/one';\n\n// Acme Getaways is a fictional travel brand. This app is deliberately top-of-funnel: discovery and\n// configuration happen inside ChatGPT; the booking/transaction happens off-app on Acme's own site,\n// reached through a signed, attributable handoff deep link. Destinations are the partner's own\n// catalog (grounding) — the app never invents a place, price, or best-month.\n//\n// Authoring note: a tool `fulfil` is *recorded*, not run as live JS. Inputs flow through as\n// `${input.x}` substitutions when placed directly into an output string; do not transform them\n// (no URL-encoding, arithmetic, or filtering on an input value — those break substitution). The\n// curated catalog below is static data the runtime returns verbatim.\n\nconst catalog = [\n {\n id: 'coral_bay',\n name: 'Coral Bay',\n region: 'Adriatic coast',\n vibe: 'beach',\n priceFrom: 890,\n bestMonths: 'May–Sep',\n why: 'Calm swimming coves and a walkable old town — easy for a relaxed first trip.',\n },\n {\n id: 'monte_alto',\n name: 'Monte Alto',\n region: 'Northern Alps',\n vibe: 'mountains',\n priceFrom: 1120,\n bestMonths: 'Dec–Mar',\n why: 'Ski-in village with beginner slopes and long groomed runs.',\n },\n {\n id: 'old_quarter',\n name: 'Old Quarter',\n region: 'Central Europe',\n vibe: 'culture',\n priceFrom: 640,\n bestMonths: 'Apr–Oct',\n why: 'Dense museum district and food halls, all reachable on foot.',\n },\n {\n id: 'harbor_city',\n name: 'Harbor City',\n region: 'Pacific rim',\n vibe: 'city',\n priceFrom: 980,\n bestMonths: 'Sep–Nov',\n why: 'Waterfront nightlife and day-trip islands a short ferry away.',\n },\n] as const;\n\n// A closed set of URL-safe month values. A `fulfil` cannot url-encode an input (recording would break\n// substitution), so the deep link carries `month` only if it is already safe — the model maps natural\n// phrasing (\"early June\") onto one of these when it fills the tool.\nconst monthEnum = z\n .enum([\n 'January',\n 'February',\n 'March',\n 'April',\n 'May',\n 'June',\n 'July',\n 'August',\n 'September',\n 'October',\n 'November',\n 'December',\n ])\n .default('June');\n\n// The catalog ids are already url-safe slugs. Constrain the handoff `destination` to this closed set so\n// only a real, url-safe id can reach the deep link.\nconst destinationId = z.enum(['coral_bay', 'monte_alto', 'old_quarter', 'harbor_city']);\n\nconst discoverInput = z.object({\n vibe: z.enum(['beach', 'mountains', 'culture', 'city']).default('beach'),\n month: monthEnum,\n travelers: z.number().int().min(1).default(2),\n});\n\n// Tool annotations for host planners: reads are read-only, the handoff opens an external link, and\n// shortlisting is a local non-destructive write.\nconst readOnly = annotations.readOnly();\nconst openLink = annotations.openAction();\nconst localWrite = annotations.localAction({ destructive: false, confirm: false });\n\nconst destinationOutput = z.object({\n id: z.string(),\n name: z.string(),\n region: z.string(),\n vibe: z.string(),\n priceFrom: z.number(),\n bestMonths: z.string(),\n why: z.string(),\n});\n\nexport default server(\n 'acme_discovery',\n {\n title: 'Acme Getaways',\n version: '1.0.0',\n branding: {\n name: 'Acme Getaways',\n accent: '#0EA5A4',\n surface: '#F0FDFA',\n surfaceDark: '#0B1B1B',\n radius: 'lg',\n density: 'comfortable',\n },\n // The only external destinations the app links out to — the compiler derives ChatGPT's\n // redirect_domains from this so the handoff opens without a safe-link warning.\n handoff: {\n allowedDomains: ['https://book.acme.example', 'https://acme.example'],\n },\n },\n [\n tool('discover_getaways', {\n title: 'Discover getaways',\n description:\n 'Suggest Acme Getaways destinations for a vibe and month and render a discovery carousel.',\n annotations: readOnly,\n input: discoverInput,\n output: z.object({\n status: z.string(),\n vibe: z.string(),\n month: z.string(),\n travelers: z.number(),\n // Bounded list: the curated catalog is fixed and small, and the declared ceiling tells the\n // model and host the payload cannot grow. `noodle check` reports `tool_design_output_bounds`.\n options: z.array(destinationOutput).max(20),\n }),\n // The carousel presents Acme's curated catalog; the model narrates which fit the stated vibe.\n // (A tool cannot filter on an input value — that is connector/flow work — so all are returned.)\n fulfil: ({ input }) => ({\n status: `Acme Getaways for a ${input.vibe} trip in ${input.month}, ${input.travelers} traveler(s).`,\n vibe: input.vibe,\n month: input.month,\n travelers: input.travelers,\n options: catalog,\n }),\n viewTitle: 'Discover getaways',\n // ChatGPT host status copy (openai/toolInvocation/*) — required for widget-opening tools.\n invoking: 'Finding getaways…',\n invoked: 'Getaways ready',\n domain: 'https://getaways.acme.example',\n view: {\n component: 'discovery-carousel',\n entry: './views/discovery-carousel.tsx',\n },\n viewDescription:\n 'A top-of-funnel discovery carousel: pick a destination, then hand off to Acme to book.',\n csp: {\n connectDomains: ['https://acme.example'],\n resourceDomains: ['https://acme.example'],\n frameDomains: ['https://acme.example'],\n },\n }),\n tool('create_handoff', {\n title: 'Create booking handoff',\n description:\n 'Create the Acme booking deep link for a chosen destination, carrying the configured trip. ' +\n 'Pass the destination id (url-safe slug, e.g. \"coral_bay\") and its display name.',\n annotations: openLink,\n input: z.object({\n destination: destinationId,\n destinationName: z.string().min(1),\n month: monthEnum,\n travelers: z.number().int().min(1).default(2),\n }),\n output: z.object({\n status: z.string(),\n destination: z.string(),\n summary: z.string(),\n handoffUrl: z.string(),\n }),\n // Inline the inputs directly so they substitute at runtime; every value is already url-safe\n // (id slug, month enum, integer), and `src=chatgpt` is the attribution the partner measures\n // ChatGPT-sourced conversions on.\n fulfil: ({ input }) => ({\n status: `Ready to continue on Acme for ${input.destinationName}.`,\n destination: input.destination,\n summary: `${input.destinationName} · ${input.month} · ${input.travelers} traveler(s)`,\n handoffUrl: `https://book.acme.example/plan?dest=${input.destination}&month=${input.month}&pax=${input.travelers}&src=chatgpt`,\n }),\n }),\n tool('shortlist_getaway', {\n visibility: ['app'],\n description: 'Record the traveler’s shortlisted destination from the discovery widget.',\n annotations: localWrite,\n input: z.object({\n destination: z.string(),\n note: z.string().default(''),\n }),\n output: z.object({\n status: z.string(),\n destination: z.string(),\n note: z.string(),\n }),\n fulfil: ({ input }) => ({\n status: `Shortlisted ${input.destination}.`,\n destination: input.destination,\n note: input.note,\n }),\n }),\n ],\n);\n" },
37
37
  { relPath: "examples/acme-discovery/src/views/discovery-carousel.tsx", content: "import { useState } from 'react';\nimport { useCallTool, useLayout, useOpenExternal, useToolInfo, useViewState } from '../helpers.js';\nimport './widget-style.css';\n\ntype Destination = {\n readonly id: string;\n readonly name: string;\n readonly region: string;\n readonly vibe: string;\n readonly priceFrom: number;\n readonly bestMonths: string;\n readonly why: string;\n};\n\nfunction asDiscovery(value: unknown) {\n return value as\n | {\n readonly status?: string;\n readonly month?: string;\n readonly travelers?: number;\n readonly options?: readonly Destination[];\n }\n | undefined;\n}\n\nexport default function DiscoveryCarousel() {\n const { displayMode, theme } = useLayout();\n const openExternal = useOpenExternal();\n const discovery = asDiscovery(useToolInfo('discover_getaways').structuredContent);\n const shortlist = useCallTool('shortlist_getaway');\n const handoff = useCallTool('create_handoff');\n\n const options = discovery?.options ?? [];\n const month = discovery?.month ?? 'June';\n const travelers = discovery?.travelers ?? 2;\n const [chosen, setChosen] = useViewState('chosen', options[0]?.id ?? '');\n const [status, setStatus] = useState(discovery?.status ?? 'Pick a getaway to continue.');\n const selected = options.find((entry) => entry.id === chosen) ?? options[0];\n const continueLabel = handoff.isPending\n ? 'Opening Acme…'\n : `Continue on Acme${selected ? ` · ${selected.name}` : ''}`;\n\n async function shortlistDestination(destination: Destination) {\n setChosen(destination.id);\n try {\n const result = await shortlist.callTool({ destination: destination.name });\n const structured = result.structuredContent as { readonly status?: string } | undefined;\n setStatus(structured?.status ?? `Shortlisted ${destination.name}.`);\n } catch {\n setStatus(`Couldn't shortlist ${destination.name} — try again.`);\n }\n }\n\n async function continueOnAcme() {\n if (selected === undefined) return;\n // The handoff is the product: configure here, transact off-app. The deep link carries the trip.\n // Only open the external target on a successful handoff; surface failures instead of failing silently.\n try {\n const result = await handoff.callTool({\n destination: selected.id,\n destinationName: selected.name,\n month,\n travelers,\n });\n const structured = result.structuredContent as { readonly handoffUrl?: string } | undefined;\n if (structured?.handoffUrl) openExternal(structured.handoffUrl);\n else setStatus('Continue on Acme is unavailable right now — try again.');\n } catch {\n setStatus('Continue on Acme failed — try again.');\n }\n }\n\n return (\n <main\n className={`nw-shell${theme === 'dark' ? ' dark' : ''}`}\n data-llm={`Acme Getaways discovery: ${options.length} options for ${month}, ${travelers} traveler(s); shortlisted ${selected?.name ?? 'none'}`}\n >\n <section className=\"nw-card\">\n <header className=\"nw-header\">\n <span className=\"nw-icon\" aria-hidden=\"true\">\n <CompassIcon />\n </span>\n <div className=\"nw-title-block\">\n <h1 className=\"nw-title\">Acme Getaways</h1>\n <p className=\"nw-subtitle\" aria-live=\"polite\">\n {status}\n </p>\n </div>\n <span className=\"nw-chip\">\n {displayMode === 'fullscreen' ? 'Fullscreen' : 'Discover'}\n </span>\n </header>\n\n <div className=\"nw-carousel\">\n {options.map((entry) => (\n <article\n className={`nw-dest${entry.id === chosen ? ' nw-dest-active' : ''}`}\n key={entry.id}\n >\n <div className=\"nw-dest-head\">\n <span className=\"nw-dest-name\">{entry.name}</span>\n <span className=\"nw-price\">from ${entry.priceFrom}</span>\n </div>\n <p className=\"nw-dest-region\">\n {entry.region} · best {entry.bestMonths}\n </p>\n {/* Grounded copy: the \"why\" comes from Acme's catalog, not invented at runtime. */}\n <p className=\"nw-dest-why\">{entry.why}</p>\n <button\n aria-pressed={entry.id === chosen}\n className=\"nw-button nw-button-ghost\"\n type=\"button\"\n onClick={() => shortlistDestination(entry)}\n >\n {entry.id === chosen ? 'Shortlisted' : 'Shortlist'}\n </button>\n </article>\n ))}\n </div>\n\n <div className=\"nw-actions\">\n <button\n className=\"nw-button nw-button-primary\"\n type=\"button\"\n disabled={selected === undefined || handoff.isPending}\n onClick={continueOnAcme}\n >\n <ExternalIcon />\n {continueLabel}\n </button>\n </div>\n <p className=\"nw-note\">Booking and payment happen on acme.example — never inside chat.</p>\n </section>\n </main>\n );\n}\n\nfunction CompassIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <circle cx=\"12\" cy=\"12\" r=\"9\" />\n <path d=\"m15.5 8.5-2 5-5 2 2-5 5-2Z\" />\n </svg>\n );\n}\n\nfunction ExternalIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M14 4h6v6\" />\n <path d=\"m20 4-9 9\" />\n <path d=\"M20 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5\" />\n </svg>\n );\n}\n" },
38
38
  { relPath: "examples/acme-discovery/src/views/widget-style.css", content: ":root {\n color-scheme: light dark;\n font-family:\n Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n --nw-bg: #ffffff;\n --nw-surface: #f4fbfa;\n --nw-text: #10201f;\n --nw-muted: #5b6b6a;\n --nw-border: #d4e6e4;\n --nw-accent: #0ea5a4;\n --nw-accent-strong: #0f766e;\n --nw-accent-soft: #e6faf8;\n --nw-radius: 10px;\n --nw-shadow: 0 18px 50px rgb(15 40 40 / 12%);\n}\n\n.dark,\n[data-theme=\"dark\"] {\n --nw-bg: #0b1b1b;\n --nw-surface: #102624;\n --nw-text: #eafaf8;\n --nw-muted: #9fb6b3;\n --nw-border: #244341;\n --nw-accent: #2dd4bf;\n --nw-accent-strong: #14b8a6;\n --nw-accent-soft: #0f3835;\n --nw-shadow: 0 18px 50px rgb(0 0 0 / 30%);\n}\n\n* {\n box-sizing: border-box;\n}\n\nbody {\n margin: 0;\n background: var(--nw-bg);\n color: var(--nw-text);\n}\n\nbutton {\n font: inherit;\n}\n\n.nw-shell {\n min-height: 100vh;\n padding: 14px;\n background: var(--nw-bg);\n color: var(--nw-text);\n}\n\n.nw-card {\n max-width: 720px;\n margin: 0 auto;\n background: var(--nw-surface);\n border: 1px solid var(--nw-border);\n border-radius: var(--nw-radius);\n box-shadow: var(--nw-shadow);\n overflow: hidden;\n}\n\n.nw-header {\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 16px;\n border-bottom: 1px solid var(--nw-border);\n}\n\n.nw-icon svg {\n width: 26px;\n height: 26px;\n fill: none;\n stroke: var(--nw-accent);\n stroke-width: 1.7;\n stroke-linecap: round;\n stroke-linejoin: round;\n}\n\n.nw-title-block {\n flex: 1;\n min-width: 0;\n}\n\n.nw-title {\n margin: 0;\n font-size: 17px;\n font-weight: 700;\n}\n\n.nw-subtitle {\n margin: 2px 0 0;\n font-size: 13px;\n color: var(--nw-muted);\n}\n\n.nw-chip {\n padding: 4px 10px;\n border-radius: 999px;\n background: var(--nw-accent-soft);\n color: var(--nw-accent-strong);\n font-size: 12px;\n font-weight: 600;\n}\n\n.nw-carousel {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));\n gap: 12px;\n padding: 16px;\n}\n\n.nw-dest {\n display: flex;\n flex-direction: column;\n gap: 6px;\n padding: 12px;\n border: 1px solid var(--nw-border);\n border-radius: 12px;\n background: var(--nw-bg);\n}\n\n.nw-dest-active {\n border-color: var(--nw-accent);\n box-shadow: 0 0 0 1px var(--nw-accent);\n}\n\n.nw-dest-head {\n display: flex;\n align-items: baseline;\n justify-content: space-between;\n gap: 8px;\n}\n\n.nw-dest-name {\n font-weight: 700;\n}\n\n.nw-price {\n color: var(--nw-accent-strong);\n font-size: 12px;\n font-weight: 600;\n}\n\n.nw-dest-region {\n margin: 0;\n font-size: 12px;\n color: var(--nw-muted);\n}\n\n.nw-dest-why {\n margin: 0;\n font-size: 13px;\n flex: 1;\n}\n\n.nw-actions {\n display: flex;\n gap: 8px;\n padding: 0 16px 12px;\n}\n\n.nw-button {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 9px 14px;\n border: 1px solid var(--nw-border);\n border-radius: 10px;\n background: var(--nw-bg);\n color: var(--nw-text);\n cursor: pointer;\n}\n\n.nw-button svg {\n width: 16px;\n height: 16px;\n fill: none;\n stroke: currentColor;\n stroke-width: 1.7;\n stroke-linecap: round;\n stroke-linejoin: round;\n}\n\n.nw-button-ghost {\n align-self: flex-start;\n padding: 6px 12px;\n font-size: 13px;\n}\n\n.nw-button-primary {\n background: var(--nw-accent);\n border-color: var(--nw-accent);\n color: #ffffff;\n font-weight: 600;\n}\n\n.nw-button-primary:disabled {\n opacity: 0.6;\n cursor: default;\n}\n\n.nw-note {\n margin: 0;\n padding: 0 16px 16px;\n font-size: 12px;\n color: var(--nw-muted);\n}\n" },
39
39
  { relPath: "examples/acme-discovery/test/server.test.ts", content: "import { describe, expect, it } from 'vitest';\nimport app from '../src/server.js';\n\ndescribe('acme-discovery example', () => {\n it('exports a Noodle server definition', () => {\n expect(typeof app.toManifest).toBe('function');\n });\n\n it('declares the off-app handoff domain the deep link lands on', async () => {\n // Top-of-funnel: the only external target is Acme's booking site, declared once at the server.\n const manifest = await app.toManifest();\n expect(JSON.stringify(manifest)).toContain('https://book.acme.example');\n });\n\n it('exposes the discovery tool and the handoff tool', async () => {\n const manifest = await app.toManifest();\n const text = JSON.stringify(manifest);\n // The discovery tool renders the carousel; the handoff tool emits the deep link; the widget-only\n // helper records a shortlist.\n expect(text).toContain('discover_getaways');\n expect(text).toContain('create_handoff');\n expect(text).toContain('shortlist_getaway');\n });\n});\n" },
@@ -44,7 +44,7 @@ export const BUNDLED_EXAMPLE_FILES = [
44
44
  { relPath: "examples/acme-tasks/noodle.json", content: "{\n \"entrypoint\": \"src/server.ts\",\n \"name\": \"acme-tasks\",\n \"template\": \"widget\"\n}\n" },
45
45
  { relPath: "examples/acme-tasks/package.json", content: "{\n \"name\": \"acme-tasks\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {\n \"test\": \"vitest run\",\n \"validate\": \"noodle validate\",\n \"dev\": \"noodle dev\",\n \"deploy\": \"noodle deploy\"\n },\n \"devDependencies\": {\n \"@vitejs/plugin-react\": \"latest\",\n \"@noodleseed/one\": \"latest\",\n \"react\": \"latest\",\n \"react-dom\": \"latest\",\n \"vite\": \"latest\",\n \"vitest\": \"latest\"\n }\n}\n" },
46
46
  { relPath: "examples/acme-tasks/src/helpers.ts", content: "import type { ServerDefinition } from '@noodleseed/one';\nimport { generateHelpers } from '@noodleseed/one/react';\n\nexport type AppType = ServerDefinition;\n\nexport const { useCallTool, useLayout, useToolInfo, useViewState } = generateHelpers<AppType>();\n" },
47
- { relPath: "examples/acme-tasks/src/server.ts", content: "import { annotations, server, tool, z } from '@noodleseed/one';\n\n// Acme Tasks is a fictional productivity app. It is a two-way (read + write) experience rather than a\n// top-of-funnel handoff: the top-3 prioritized user flows all complete in chat — Capture, Prioritize,\n// and Complete. It is the flagship for designing an app around its prioritized flows (see the README).\n//\n// Authoring notes:\n// - A tool `fulfil` is *recorded*, not run as live JS. Place inputs directly into an output string as\n// `${input.x}` so they substitute at runtime; do not transform them (no arithmetic/filter on inputs).\n// The seed list below is static data the runtime returns verbatim — the \"today\" view.\n// - A real deployment would connect the user's account with the end-user auth pattern (see the\n// `customer-auth` example); this example keeps a seeded list so the focus stays on the flows.\n\nconst today = [\n {\n id: 'email_vendor',\n title: 'Email the vendor about the Q3 quote',\n priority: 'high',\n done: false,\n },\n { id: 'review_pr', title: 'Review the analytics pull request', priority: 'medium', done: false },\n { id: 'book_offsite', title: 'Book flights for the team offsite', priority: 'low', done: false },\n] as const;\n\nconst priority = z.enum(['high', 'medium', 'low']);\n\n// Tool annotations for host planners: listing is read-only; capture/complete/re-prioritize are local\n// non-destructive writes.\nconst readOnly = annotations.readOnly();\nconst localWrite = annotations.localAction({ destructive: false });\nconst confirmedWrite = annotations.localAction({ destructive: false, confirm: true });\nconst widgetWrite = annotations.localAction({ destructive: false, confirm: false });\n\nconst taskOutput = z.object({\n id: z.string(),\n title: z.string(),\n priority,\n done: z.boolean(),\n});\n\nexport default server(\n 'acme_tasks',\n {\n title: 'Acme Tasks',\n version: '1.0.0',\n // ChatGPT's stateless MCP lane cannot carry Noodle's standard confirmation form. Keep\n // confirm:true for capable/embedded hosts, but explicitly trust native host approval there.\n interactions: { confirmationFallback: 'host' },\n branding: {\n name: 'Acme Tasks',\n accent: '#7C3AED',\n surface: '#F5F3FF',\n surfaceDark: '#161228',\n radius: 'lg',\n density: 'comfortable',\n },\n },\n [\n // Flow 2 — Prioritize / Today: render the list so the human triages and the model can speak to it.\n tool('list_today', {\n title: 'Show today’s tasks',\n description: 'Show today’s Acme Tasks and render the task-list widget.',\n annotations: readOnly,\n input: z.object({ focus: z.string().default('today') }),\n output: z.object({\n status: z.string(),\n focus: z.string(),\n tasks: z.array(taskOutput),\n }),\n fulfil: ({ input }) => ({\n status: `Acme Tasks for ${input.focus}: ${today.length} open items, highest priority first.`,\n focus: input.focus,\n tasks: today,\n }),\n viewTitle: 'Today’s tasks',\n viewDescription: 'A prioritized task list: capture, re-prioritize, and complete in place.',\n // ChatGPT host status copy (openai/toolInvocation/*) — required for widget-opening tools.\n invoking: 'Loading your tasks…',\n invoked: 'Tasks ready',\n domain: 'https://tasks.acme.example',\n view: {\n component: 'task-list',\n entry: './views/task-list.tsx',\n },\n csp: {\n connectDomains: ['https://acme.example'],\n resourceDomains: ['https://acme.example'],\n frameDomains: ['https://acme.example'],\n },\n }),\n // Flow 1 — Capture: add a task from natural language (\"remind me to email the vendor\").\n tool('add_task', {\n title: 'Add a task',\n description: 'Capture a new Acme task with a title and priority.',\n annotations: localWrite,\n input: z.object({\n title: z.string().meta({ title: 'Task' }),\n priority: priority.default('medium').meta({ title: 'Priority' }),\n }),\n output: z.object({\n status: z.string(),\n title: z.string(),\n priority,\n }),\n fulfil: ({ input }) => ({\n status: `Added “${input.title}” (${input.priority}).`,\n title: input.title,\n priority: input.priority,\n }),\n }),\n // Flow 3 — Complete: mark a task done. Model-visible so the model can complete on request.\n tool('complete_task', {\n title: 'Complete task',\n description: 'This will mark the selected task complete for everyone using Acme Tasks.',\n annotations: confirmedWrite,\n input: z.object({\n task: z.string().meta({ title: 'Task ID' }),\n title: z.string().min(1).meta({ title: 'Task' }),\n }),\n output: z.object({\n status: z.string(),\n task: z.string(),\n }),\n fulfil: ({ input }) => ({\n status: `Completed “${input.title}”.`,\n task: input.task,\n }),\n }),\n // Flow 2 helper (widget-only): re-prioritize a task from the list widget.\n tool('set_priority', {\n title: 'Change task priority',\n visibility: ['app'],\n description: 'Re-prioritize a task from the list widget.',\n annotations: widgetWrite,\n input: z.object({\n task: z.string().meta({ title: 'Task ID' }),\n priority: priority.meta({ title: 'New priority' }),\n }),\n output: z.object({\n status: z.string(),\n task: z.string(),\n priority,\n }),\n fulfil: ({ input }) => ({\n status: `Set ${input.task} to ${input.priority} priority.`,\n task: input.task,\n priority: input.priority,\n }),\n }),\n ],\n);\n" },
47
+ { relPath: "examples/acme-tasks/src/server.ts", content: "import { annotations, server, tool, z } from '@noodleseed/one';\n\n// Acme Tasks is a fictional productivity app. It is a two-way (read + write) experience rather than a\n// top-of-funnel handoff: the top-3 prioritized user flows all complete in chat — Capture, Prioritize,\n// and Complete. It is the flagship for designing an app around its prioritized flows (see the README).\n//\n// Authoring notes:\n// - A tool `fulfil` is *recorded*, not run as live JS. Place inputs directly into an output string as\n// `${input.x}` so they substitute at runtime; do not transform them (no arithmetic/filter on inputs).\n// The seed list below is static data the runtime returns verbatim — the \"today\" view.\n// - A real deployment would connect the user's account with the end-user auth pattern (see the\n// `customer-auth` example); this example keeps a seeded list so the focus stays on the flows.\n\nconst today = [\n {\n id: 'email_vendor',\n title: 'Email the vendor about the Q3 quote',\n priority: 'high',\n done: false,\n },\n { id: 'review_pr', title: 'Review the analytics pull request', priority: 'medium', done: false },\n { id: 'book_offsite', title: 'Book flights for the team offsite', priority: 'low', done: false },\n] as const;\n\nconst priority = z.enum(['high', 'medium', 'low']);\n\n// Tool annotations for host planners: listing is read-only; capture/complete/re-prioritize are local\n// non-destructive writes.\nconst readOnly = annotations.readOnly();\nconst localWrite = annotations.localAction({ destructive: false });\nconst confirmedWrite = annotations.localAction({ destructive: false, confirm: true });\nconst widgetWrite = annotations.localAction({ destructive: false, confirm: false });\n\nconst taskOutput = z.object({\n id: z.string(),\n title: z.string(),\n priority,\n done: z.boolean(),\n});\n\nexport default server(\n 'acme_tasks',\n {\n title: 'Acme Tasks',\n version: '1.0.0',\n // ChatGPT's stateless MCP lane cannot carry Noodle's standard confirmation form. Keep\n // confirm:true for capable/embedded hosts, but explicitly trust native host approval there.\n interactions: { confirmationFallback: 'host' },\n branding: {\n name: 'Acme Tasks',\n accent: '#7C3AED',\n surface: '#F5F3FF',\n surfaceDark: '#161228',\n radius: 'lg',\n density: 'comfortable',\n },\n },\n [\n // Flow 2 — Prioritize / Today: render the list so the human triages and the model can speak to it.\n tool('list_today', {\n title: 'Show today’s tasks',\n description: 'Show today’s Acme Tasks and render the task-list widget.',\n annotations: readOnly,\n input: z.object({ focus: z.string().default('today') }),\n // Bound the list output. A recorded `fulfil` cannot slice an array, so the honest bound here is\n // a cap on the shape itself; a connector-backed list takes a pagination input instead (see the\n // `weather` example). `noodle check` reports an unbounded array as `tool_design_output_bounds`.\n output: z.object({\n status: z.string(),\n focus: z.string(),\n tasks: z.array(taskOutput).max(20),\n }),\n fulfil: ({ input }) => ({\n status: `Acme Tasks for ${input.focus}: ${today.length} open items, highest priority first.`,\n focus: input.focus,\n tasks: today,\n }),\n viewTitle: 'Today’s tasks',\n viewDescription: 'A prioritized task list: capture, re-prioritize, and complete in place.',\n // ChatGPT host status copy (openai/toolInvocation/*) — required for widget-opening tools.\n invoking: 'Loading your tasks…',\n invoked: 'Tasks ready',\n domain: 'https://tasks.acme.example',\n view: {\n component: 'task-list',\n entry: './views/task-list.tsx',\n },\n csp: {\n connectDomains: ['https://acme.example'],\n resourceDomains: ['https://acme.example'],\n frameDomains: ['https://acme.example'],\n },\n }),\n // Flow 1 — Capture: add a task from natural language (\"remind me to email the vendor\").\n tool('add_task', {\n title: 'Add a task',\n description: 'Capture a new Acme task with a title and priority.',\n annotations: localWrite,\n input: z.object({\n title: z.string().meta({ title: 'Task' }),\n priority: priority.default('medium').meta({ title: 'Priority' }),\n }),\n output: z.object({\n status: z.string(),\n title: z.string(),\n priority,\n }),\n fulfil: ({ input }) => ({\n status: `Added “${input.title}” (${input.priority}).`,\n title: input.title,\n priority: input.priority,\n }),\n }),\n // Flow 3 — Complete: mark a task done. Model-visible so the model can complete on request.\n tool('complete_task', {\n title: 'Complete task',\n description: 'This will mark the selected task complete for everyone using Acme Tasks.',\n annotations: confirmedWrite,\n input: z.object({\n task: z.string().meta({ title: 'Task ID' }),\n title: z.string().min(1).meta({ title: 'Task' }),\n }),\n output: z.object({\n status: z.string(),\n task: z.string(),\n }),\n fulfil: ({ input }) => ({\n status: `Completed “${input.title}”.`,\n task: input.task,\n }),\n }),\n // Flow 2 helper (widget-only): re-prioritize a task from the list widget.\n tool('set_priority', {\n title: 'Change task priority',\n visibility: ['app'],\n description: 'Re-prioritize a task from the list widget.',\n annotations: widgetWrite,\n input: z.object({\n task: z.string().meta({ title: 'Task ID' }),\n priority: priority.meta({ title: 'New priority' }),\n }),\n output: z.object({\n status: z.string(),\n task: z.string(),\n priority,\n }),\n fulfil: ({ input }) => ({\n status: `Set ${input.task} to ${input.priority} priority.`,\n task: input.task,\n priority: input.priority,\n }),\n }),\n ],\n);\n" },
48
48
  { relPath: "examples/acme-tasks/src/views/task-list.tsx", content: "import { useState } from 'react';\nimport { useCallTool, useLayout, useToolInfo, useViewState } from '../helpers.js';\nimport './widget-style.css';\n\ntype Priority = 'high' | 'medium' | 'low';\n\ntype Task = {\n readonly id: string;\n readonly title: string;\n readonly priority: Priority;\n readonly done: boolean;\n};\n\nconst PRIORITIES: readonly Priority[] = ['high', 'medium', 'low'];\n\nfunction asToday(value: unknown) {\n return value as { readonly status?: string; readonly tasks?: readonly Task[] } | undefined;\n}\n\nexport default function TaskList() {\n const { displayMode, theme } = useLayout();\n const today = asToday(useToolInfo('list_today').structuredContent);\n const addTask = useCallTool('add_task');\n const completeTask = useCallTool('complete_task');\n const setPriorityTool = useCallTool('set_priority');\n\n const seeded = today?.tasks ?? [];\n // Local, session-scoped state layered over the seeded list — the three flows write here and record\n // the change through a tool call. Captured tasks live in `added` so the list updates in place.\n const [added, setAdded] = useState<readonly Task[]>([]);\n const [done, setDone] = useState<readonly string[]>([]);\n const [priority, setPriority] = useState<Record<string, Priority>>({});\n const [draft, setDraft] = useViewState('draft', '');\n const [status, setStatus] = useState(today?.status ?? 'Capture, prioritize, and complete.');\n\n const tasks: readonly Task[] = [...seeded, ...added];\n const openCount = tasks.filter((task) => !done.includes(task.id)).length;\n\n // Each flow updates local state optimistically, then records the change through a tool call. If the\n // call fails, revert the optimistic change and surface a failure message instead of a false success.\n async function capture() {\n const title = draft.trim();\n if (title.length === 0) return;\n const id = `added_${added.length}`;\n setAdded((current) => [...current, { id, title, priority: 'medium', done: false }]);\n setDraft('');\n try {\n const result = await addTask.callTool({ title, priority: 'medium' });\n const structured = result.structuredContent as { readonly status?: string } | undefined;\n setStatus(structured?.status ?? `Added “${title}”.`);\n } catch {\n setAdded((current) => current.filter((task) => task.id !== id));\n setStatus(`Couldn't add “${title}” — try again.`);\n }\n }\n\n async function reprioritize(task: Task, next: Priority) {\n const prev = priority[task.id];\n setPriority((current) => ({ ...current, [task.id]: next }));\n try {\n const result = await setPriorityTool.callTool({ task: task.id, priority: next });\n const structured = result.structuredContent as { readonly status?: string } | undefined;\n setStatus(structured?.status ?? `Set ${task.title} to ${next}.`);\n } catch {\n setPriority((current) => {\n const restored = { ...current };\n if (prev === undefined) delete restored[task.id];\n else restored[task.id] = prev;\n return restored;\n });\n setStatus(`Couldn't re-prioritize ${task.title} — try again.`);\n }\n }\n\n async function complete(task: Task) {\n setDone((current) => [...current, task.id]);\n try {\n const result = await completeTask.callTool({ task: task.id, title: task.title });\n const structured = result.structuredContent as { readonly status?: string } | undefined;\n setStatus(structured?.status ?? `Completed “${task.title}”.`);\n } catch {\n setDone((current) => current.filter((id) => id !== task.id));\n setStatus(`Couldn't complete “${task.title}” — try again.`);\n }\n }\n\n return (\n <main\n className={`nw-shell${theme === 'dark' ? ' dark' : ''}`}\n data-llm={`Acme Tasks: ${openCount} open of ${tasks.length}; ${done.length} completed this session`}\n >\n <section className=\"nw-card\">\n <header className=\"nw-header\">\n <span className=\"nw-icon\" aria-hidden=\"true\">\n <CheckIcon />\n </span>\n <div className=\"nw-title-block\">\n <h1 className=\"nw-title\">Acme Tasks</h1>\n <p className=\"nw-subtitle\" aria-live=\"polite\">\n {status}\n </p>\n </div>\n <span className=\"nw-chip\">\n {displayMode === 'fullscreen' ? 'Fullscreen' : `${openCount} open`}\n </span>\n </header>\n\n {/* Flow 1 — Capture */}\n <div className=\"nw-capture\">\n <input\n className=\"nw-input\"\n placeholder=\"Add a task…\"\n value={draft}\n onChange={(event) => setDraft(event.currentTarget.value)}\n onKeyDown={(event) => {\n if (event.key === 'Enter') capture();\n }}\n />\n <button\n className=\"nw-button nw-button-primary\"\n type=\"button\"\n disabled={addTask.isPending}\n onClick={capture}\n >\n Add\n </button>\n </div>\n\n {/* Flow 2 — Prioritize, Flow 3 — Complete */}\n <ul className=\"nw-list\">\n {tasks.map((task) => {\n const isDone = done.includes(task.id);\n const level = priority[task.id] ?? task.priority;\n return (\n <li className={`nw-task${isDone ? ' nw-task-done' : ''}`} key={task.id}>\n <button\n aria-label={isDone ? 'Completed' : 'Complete task'}\n className={`nw-check${isDone ? ' nw-check-on' : ''}`}\n type=\"button\"\n disabled={isDone}\n onClick={() => complete(task)}\n >\n {isDone ? '✓' : ''}\n </button>\n <span className=\"nw-task-title\">{task.title}</span>\n <select\n aria-label=\"Priority\"\n className={`nw-priority nw-priority-${level}`}\n value={level}\n disabled={isDone}\n onChange={(event) => reprioritize(task, event.currentTarget.value as Priority)}\n >\n {PRIORITIES.map((entry) => (\n <option key={entry} value={entry}>\n {entry}\n </option>\n ))}\n </select>\n </li>\n );\n })}\n </ul>\n <p className=\"nw-note\">\n Two-way in chat: read your list, capture, re-prioritize, and complete.\n </p>\n </section>\n </main>\n );\n}\n\nfunction CheckIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M4 12.5 9 17l11-11\" />\n </svg>\n );\n}\n" },
49
49
  { relPath: "examples/acme-tasks/src/views/widget-style.css", content: ":root {\n color-scheme: light dark;\n font-family:\n Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n --nw-bg: #ffffff;\n --nw-surface: #f7f5ff;\n --nw-text: #1c1830;\n --nw-muted: #635d80;\n --nw-border: #e2ddf5;\n --nw-accent: #7c3aed;\n --nw-accent-strong: #6d28d9;\n --nw-accent-soft: #efe9ff;\n --nw-high: #dc2626;\n --nw-medium: #b45309;\n --nw-low: #2563eb;\n --nw-radius: 10px;\n --nw-shadow: 0 18px 50px rgb(30 20 60 / 12%);\n}\n\n.dark,\n[data-theme=\"dark\"] {\n --nw-bg: #161228;\n --nw-surface: #1d1735;\n --nw-text: #f2eeff;\n --nw-muted: #a99fce;\n --nw-border: #342a54;\n --nw-accent: #a78bfa;\n --nw-accent-strong: #8b5cf6;\n --nw-accent-soft: #2a2148;\n --nw-high: #f87171;\n --nw-medium: #fbbf24;\n --nw-low: #60a5fa;\n --nw-shadow: 0 18px 50px rgb(0 0 0 / 32%);\n}\n\n* {\n box-sizing: border-box;\n}\n\nbody {\n margin: 0;\n background: var(--nw-bg);\n color: var(--nw-text);\n}\n\nbutton,\ninput,\nselect {\n font: inherit;\n}\n\n.nw-shell {\n min-height: 100vh;\n padding: 14px;\n background: var(--nw-bg);\n color: var(--nw-text);\n}\n\n.nw-card {\n max-width: 620px;\n margin: 0 auto;\n background: var(--nw-surface);\n border: 1px solid var(--nw-border);\n border-radius: var(--nw-radius);\n box-shadow: var(--nw-shadow);\n overflow: hidden;\n}\n\n.nw-header {\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 16px;\n border-bottom: 1px solid var(--nw-border);\n}\n\n.nw-icon svg {\n width: 24px;\n height: 24px;\n fill: none;\n stroke: var(--nw-accent);\n stroke-width: 2;\n stroke-linecap: round;\n stroke-linejoin: round;\n}\n\n.nw-title-block {\n flex: 1;\n min-width: 0;\n}\n\n.nw-title {\n margin: 0;\n font-size: 17px;\n font-weight: 700;\n}\n\n.nw-subtitle {\n margin: 2px 0 0;\n font-size: 13px;\n color: var(--nw-muted);\n}\n\n.nw-chip {\n padding: 4px 10px;\n border-radius: 999px;\n background: var(--nw-accent-soft);\n color: var(--nw-accent-strong);\n font-size: 12px;\n font-weight: 600;\n}\n\n.nw-capture {\n display: flex;\n gap: 8px;\n padding: 14px 16px 4px;\n}\n\n.nw-input {\n flex: 1;\n padding: 9px 12px;\n border: 1px solid var(--nw-border);\n border-radius: 10px;\n background: var(--nw-bg);\n color: var(--nw-text);\n}\n\n.nw-button {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 9px 14px;\n border: 1px solid var(--nw-border);\n border-radius: 10px;\n background: var(--nw-bg);\n color: var(--nw-text);\n cursor: pointer;\n}\n\n.nw-button-primary {\n background: var(--nw-accent);\n border-color: var(--nw-accent);\n color: #ffffff;\n font-weight: 600;\n}\n\n.nw-button-primary:disabled {\n opacity: 0.6;\n cursor: default;\n}\n\n.nw-list {\n list-style: none;\n margin: 0;\n padding: 8px 16px 4px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n}\n\n.nw-task {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 10px 12px;\n border: 1px solid var(--nw-border);\n border-radius: 12px;\n background: var(--nw-bg);\n}\n\n.nw-task-title {\n flex: 1;\n min-width: 0;\n}\n\n.nw-task-done {\n opacity: 0.55;\n}\n\n.nw-task-done .nw-task-title {\n text-decoration: line-through;\n}\n\n.nw-check {\n width: 22px;\n height: 22px;\n border: 1.5px solid var(--nw-border);\n border-radius: 999px;\n background: transparent;\n color: #ffffff;\n cursor: pointer;\n flex: none;\n}\n\n.nw-check-on {\n background: var(--nw-accent);\n border-color: var(--nw-accent);\n}\n\n.nw-priority {\n padding: 5px 8px;\n border: 1px solid var(--nw-border);\n border-radius: 8px;\n background: var(--nw-bg);\n color: var(--nw-text);\n font-size: 12px;\n font-weight: 600;\n}\n\n.nw-priority-high {\n color: var(--nw-high);\n}\n\n.nw-priority-medium {\n color: var(--nw-medium);\n}\n\n.nw-priority-low {\n color: var(--nw-low);\n}\n\n.nw-note {\n margin: 0;\n padding: 8px 16px 16px;\n font-size: 12px;\n color: var(--nw-muted);\n}\n" },
50
50
  { relPath: "examples/acme-tasks/test/server.test.ts", content: "import { describe, expect, it } from 'vitest';\nimport app from '../src/server.js';\n\ndescribe('acme-tasks example', () => {\n it('exports a Noodle server definition', () => {\n expect(typeof app.toManifest).toBe('function');\n });\n\n it('exposes a tool for each of the top-3 prioritized flows', async () => {\n // Capture → add_task, Prioritize → list_today (+ set_priority helper), Complete → complete_task.\n const text = JSON.stringify(await app.toManifest());\n expect(text).toContain('add_task');\n expect(text).toContain('list_today');\n expect(text).toContain('complete_task');\n expect(text).toContain('set_priority');\n });\n\n it('seeds today’s list highest-priority first', async () => {\n const text = JSON.stringify(await app.toManifest());\n expect(text).toMatch(/\"tasks\":\\[\\{\"id\":\"email_vendor\".*\"priority\":\"high\"/);\n });\n\n it('opts the conversational completion action into runtime confirmation', async () => {\n const manifest = await app.toManifest();\n const completeTask = manifest.tools.find((candidate) => candidate.name === 'complete_task');\n const addTask = manifest.tools.find((candidate) => candidate.name === 'add_task');\n\n expect(completeTask?.annotations?.confirm).toBe(true);\n expect(addTask?.annotations).not.toHaveProperty('confirm');\n });\n});\n" },
@@ -52,13 +52,13 @@ export const BUNDLED_EXAMPLE_FILES = [
52
52
  { relPath: "examples/customer-auth/README.md", content: "# Customer Auth - NoodleSeed.com Firebase customer identity\n\nThis curated example owns the customer/end-user authentication with Firebase bridge capability slot. It is\nthe NoodleSeed.com dogfood app for proving that a SaaS app can protect an MCP endpoint with its own customer\nidentity provider while still using the generic Noodle Seed authoring API.\n\nIt also owns the embedded-assistant showcase: the same authenticated MCP surface can be dropped into the\nSaaS web application as a fully customer-branded assistant with independent light and dark themes. The\nassistant loads the active deployment's instructions and model-visible tools rather than installing a stale\nsecond skill bundle. The standard embedded element also hosts linked MCP Apps behind its sandbox bridge.\n\nThe public developer entrypoint is [`src/server.ts`](src/server.ts). It declares `customerAuth.firebase(...)` with\nthe NoodleSeed.com Firebase project and Firebase Web App public config. It exposes a deliberately small MCP\nsurface for org discovery:\n\n- `list_my_organizations` lists the NoodleSeed.com organizations the signed-in customer belongs to (no\n arguments — the org set comes from the verified customer session).\n- `list_org_apps` lists apps for one of those organizations through the dev app API.\n\nThe two tools chain: `list_my_organizations` surfaces the `org_id`s the customer can act on, and\n`list_org_apps` takes one of those `org_id`s. There is no NoodleSeed-specific SDK helper. The downstream API\nis an ordinary authored HTTP connector.\n\nDuring MCP OAuth login, Noodle Cloud hosts the Firebase bridge page at\n`https://cloud.noodleseed.dev/oauth/customer/firebase/authorize`. The customer app does not add an\nauthorization route. The SaaS operator only configures Firebase Auth to allow the Noodle Cloud origin, and\nNoodle Cloud signs the customer in with Firebase before posting the Firebase ID token back to its own bridge\ncallback.\n\n## How delegated customer credentials are used\n\nThis flagship uses the legacy Firebase provider bridge because it is runnable today. That is distinct from\nCore-v2 account-selecting connector aliases: those declare catalog `credentialProfiles` and bind each alias\nwith `bind(connector, { profile, connection })`. Hosted bound `managedSecret(...)` connections are runnable;\nbound `externalExchange()` and `clientCredentials(...)` currently compile as portable metadata but fail\nclosed before secret or network access until their provider slice lands. Do not replace this example's\nworking delegated-session-cookie path with either unsupported bound exchange source.\n\nThe example has two declarations that work together:\n\n```ts\nauth: customerAuth.firebase({\n projectId: variable('FIREBASE_PROJECT_ID'),\n apiKey: variable('FIREBASE_WEB_API_KEY'),\n authDomain: variable('FIREBASE_AUTH_DOMAIN'),\n user: {\n id: 'sub',\n email: 'email',\n name: 'name',\n tenant: 'firebase.tenant',\n orgs: 'claims.orgs',\n roles: 'claims.roles',\n },\n}),\n```\n\nThat protects the MCP endpoint with the SaaS customer's Firebase identity. The connector then opts into\ndelegated customer credentials:\n\n```ts\nauth: {\n kind: 'delegatedSessionCookie',\n provider: 'firebase',\n sessionUrl: `${noodleseedApiOrigin}/api/auth/session`,\n tokenField: 'idToken',\n},\n```\n\nTool code calls the connector normally:\n\n```ts\nfulfil({ input, connectors }) {\n const apps = connectors.app_api.listOrgApps({\n org_id: input.org_id,\n skip: input.skip,\n limit: input.limit,\n });\n\n return { result: apps.result };\n}\n```\n\nAt runtime, Noodle Seed verifies the Firebase customer during MCP OAuth, stores that customer's delegated\nFirebase refresh token in the credential broker, and refreshes a short-lived Firebase ID token only when a\nconnector-backed tool calls the NoodleSeed.com API. For this app API, the broker exchanges that ID token at\nthe existing Next.js `/api/auth/session` route and sends the resulting session cookie to the API. The MCP\naccess token remains a Noodle-issued resource-bound token and is never sent to the downstream API.\n\n## Delegated downstream auth for your own API (token exchange)\n\nThe Firebase path above only works for Firebase-session downstreams. When the downstream is **your own\nAPI** with its own token issuance, use `delegatedTokenExchange` instead\n([ADR 0152](../../docs/decisions/0152-delegated-token-exchange-connector-auth.md)): the platform signs a\nshort-lived, JWKS-verifiable assertion of the signed-in user and exchanges it (RFC 8693) at a token\nendpoint you implement, which mints your own user-scoped token — so your API enforces its own per-user\nauthorization on every call. It works for verified direct/federated OIDC, built-in provider identities, and\nembedded assistant sessions, with no per-user OAuth enrollment.\n\n```ts\nauth: {\n kind: 'delegatedTokenExchange',\n tokenUrl: 'https://app.example.com/api/assistant/oauth/token', // origin must be in allowedOrigins\n clientId: variable('EXAMPLE_DELEG_CLIENT_ID'),\n clientSecret: secret('EXAMPLE_DELEG_CLIENT_SECRET'),\n scopes: ['time_off'],\n},\n```\n\nYour endpoint authenticates the broker's client credential, verifies the `subject_token` JWT against the\nplatform issuer JWKS (claims include the verified `sub`, `email`, `name`, declared session `claims`,\n`tenant`, and `deployment`), mints a short-lived user-scoped token, and returns the standard\n`{ access_token, token_type, expires_in }` response. The exact wire contract and a copyable endpoint\nimplementation live in [docs/spec/connectors.md](../../docs/spec/connectors.md) and the Agent Kit\nauthoring-workflow reference (\"Delegated downstream auth\"). `noodle auth doctor` reports each declared\nexchange endpoint.\n\n## Validate\n\n```bash\nnoodle auth doctor examples/customer-auth/src/server.ts\nnoodle validate examples/customer-auth/src/server.ts\n```\n\nAgainst a deployed customer-protected environment, set a short-lived real customer token only in\n`NOODLE_CUSTOMER_TOKEN` and add `--live --org <org> --app <app> --env <env>`. The live doctor performs\ncredential exchanges without invoking either business tool.\n\n## Run locally\n\n```bash\nnoodle dev examples/customer-auth/src/server.ts --app noodleseed-customer-auth\n```\n\n## Configuration\n\nThe embedded assistant uses a customer-supplied OpenAI Chat Completions-compatible endpoint. Configure its\nmanaged values at the Noodle deployment environment; none of these values belongs in the customer web\napplication environment, and the API key never reaches the browser:\n\n```bash\nnoodle variables set ASSISTANT_MODEL_BASE_URL https://model.example.com/v1 --scope env\nnoodle variables set ASSISTANT_MODEL your-model --scope env\nnoodle secrets set ASSISTANT_MODEL_API_KEY --scope env\nnoodle variables set FIREBASE_PROJECT_ID your-firebase-project --scope env\nnoodle variables set FIREBASE_WEB_API_KEY your-firebase-web-api-key --scope env\nnoodle variables set FIREBASE_AUTH_DOMAIN your-firebase-project.firebaseapp.com --scope env\nnoodle check --target embedded-assistant src/server.ts\n```\n\nAssistant origins are exact. Production embedding origins must use HTTPS; plain HTTP is accepted only for\nloopback development origins such as `http://localhost:3000`, `http://127.0.0.1:3000`, or\n`http://[::1]:3000`. `noodle dev` serves the MCP project, not that separate embedding application.\n\nThe assistant's panel, launcher, status header, composer, and message treatment are configured through the\nbounded `presentation` object in `src/server.ts`. The Atlas-style product treatment is the supported\ncustomization ceiling. These primitives derive their colors from the shared server `branding` block and\nrequire no edits to `@noodleseed/assistant`; raw HTML, CSS, inline SVG, renderer class names, and callbacks\nare intentionally not accepted in deployment configuration.\n\nCreate the backend credential after deployment. The CLI writes it to a mode-0600 file and never prints the\nsecret:\n\n```bash\nnoodle assistant clients create --name web --org noodleseed --app customer-auth --env prod\n```\n\nOnly the Noodle service URL, assistant client ID, and assistant client secret belong in the authenticated\ncustomer backend. The model URL, model name, and model API key remain managed by the Noodle deployment.\n\nThe customer's authenticated backend calls `createAssistantSession(...)` from\n`@noodleseed/assistant/server`, passing the already-verified user and browser origin. The browser then uses\nthe returned short-lived session through the managed Web Component/React renderer or a customer-owned UI:\n\n```bash\npnpm add @noodleseed/assistant\n```\n\n```tsx\nimport { NoodleAssistant } from '@noodleseed/assistant/react';\n\n<NoodleAssistant\n sessionEndpoint=\"/api/noodle-assistant/session\"\n theme=\"auto\"\n onSessionExpired={() => console.info('Assistant session renewed')}\n/>;\n```\n\nFor an entirely application-owned React renderer, use the renderer-free hook. It creates no custom element\nand returns the AI SDK transcript plus the canonical client commands:\n\n```tsx\n'use client';\n\nimport { useState } from 'react';\nimport { useNoodleAssistant } from '@noodleseed/assistant/react/client';\n\nexport function CustomerAssistant({ principalKey }: { principalKey: string }) {\n const [draft, setDraft] = useState('');\n const { client, messages, status, error } = useNoodleAssistant({\n sessionEndpoint: '/api/noodle-assistant/session',\n principalKey,\n });\n const busy = status === 'submitted' || status === 'streaming';\n const settle = (operation: Promise<void>) => {\n void operation.catch(() => {\n // The hook exposes this same structured failure through `error`.\n });\n };\n\n return (\n <section aria-label=\"Assistant\" aria-busy={busy}>\n {messages.map((message) => (\n <article key={message.id} data-role={message.role}>\n {message.parts.map((part, index) => {\n if (part.type === 'text') return <p key={index}>{part.text}</p>;\n if (part.type === 'data-confirmation') {\n const review = part.data;\n return (\n <section key={review.id} aria-label=\"Review proposed action\">\n <h3>{review.title ?? 'Review proposed action'}</h3>\n {review.description ? <p>{review.description}</p> : null}\n <pre aria-label=\"Proposed action arguments\">\n {JSON.stringify(review.arguments ?? {}, null, 2)}\n </pre>\n <button\n disabled={busy || review.status !== 'pending'}\n onClick={() => settle(client.respond(review.id, { action: 'accept' }))}\n >\n Confirm\n </button>\n <button\n disabled={busy || review.status !== 'pending'}\n onClick={() => settle(client.respond(review.id, { action: 'decline' }))}\n >\n Don't proceed\n </button>\n </section>\n );\n }\n if (part.type === 'data-input-request') {\n const request = part.data;\n return (\n <section key={request.id} aria-label=\"Assistant needs input\">\n <p>{request.message}</p>\n <p>This renderer has not implemented the requested form.</p>\n <button\n disabled={busy || request.status !== 'pending'}\n onClick={() => settle(client.respond(request.id, { action: 'decline' }))}\n >\n Cancel request\n </button>\n </section>\n );\n }\n if (part.type === 'data-tool-result') {\n return (\n <pre key={part.data.id} aria-label={`${part.data.tool} result`}>\n {JSON.stringify(part.data.result, null, 2)}\n </pre>\n );\n }\n if (part.type === 'data-view') {\n return (\n <p key={part.data.id}>\n Trusted app view available: {part.data.title ?? part.data.resourceUri}\n </p>\n );\n }\n return <p key={index}>Unsupported assistant content.</p>;\n })}\n </article>\n ))}\n {error ? <p role=\"alert\">{error.message}</p> : null}\n <form\n onSubmit={(event) => {\n event.preventDefault();\n const message = draft.trim();\n if (!message) return;\n setDraft('');\n settle(client.sendMessage(message));\n }}\n >\n <input\n aria-label=\"Message\"\n value={draft}\n onChange={(event) => setDraft(event.currentTarget.value)}\n />\n {busy ? (\n <button type=\"button\" onClick={() => client.abort()}>\n Stop\n </button>\n ) : (\n <button type=\"submit\">Send</button>\n )}\n </form>\n </section>\n );\n}\n```\n\n`principalKey` stays in the browser. Change it whenever the authenticated user or tenant changes; the hook\nthen aborts and clears the prior session and transcript. The sample fails closed on input requests until its\nfallback is replaced with a form generated from `requestedSchema`. A production renderer must show the\ncomplete confirmation review and both decisions. For `data-view`, map `resourceUri` or `tool` and the\nbounded/redacted result to a component already trusted by this application. Never inject `part.data.html`,\nassign it to `srcdoc`, or fetch a `ui://` URI.\n\nOutside React, subscribe to the DOM-free client directly. It exposes the same conversation as headless AI\nSDK `UIMessage` state, including typed confirmation, input, tool-result, and linked-view parts:\n\n```ts\nimport { createAssistantClient } from '@noodleseed/assistant/client';\n\nconst assistant = createAssistantClient({\n sessionEndpoint: '/api/noodle-assistant/session',\n});\n\nassistant.subscribeChat((state) => {\n renderUIMessageState(state);\n for (const message of state.messages) {\n for (const part of message.parts) {\n if (part.type === 'data-confirmation' && part.data.status === 'pending') {\n renderConfirmation(part.data, (response) => assistant.respond(part.data.id, response));\n }\n }\n }\n});\n```\n\n`theme=\"auto\"` follows the SaaS application. The server-level `branding` block is inherited by both MCP App\nwidgets and the assistant; documented `--ns-assistant-*` semantic CSS variables remain the final integration\nescape hatch. There is no second assistant branding declaration.\nThe end-user UI contains only customer branding.\nText streams progressively. Expired turns re-exchange through the authenticated backend and retry once;\nconsent-bound tool confirmations never replay automatically.\n\nThe Firebase project ID is required because Firebase ID tokens use the project ID as the token audience and\nissuer suffix. The runtime verifies `aud` against the project ID and `iss` against\n`https://securetoken.google.com/<projectId>`.\n\nThe Firebase Web API key and auth domain are public Firebase browser configuration. They let the Noodle\nCloud-hosted bridge initialize Firebase Auth for this customer project; they are not server secrets. Keep\nthem out of source with `variable(...)`, restrict the Firebase key to the expected browser origins and APIs,\nand use `secret(...)` only for credentials that must never reach a browser.\n\nThe `noodleseed_app_api` connector currently points at the NoodleSeed.com dev app surface:\n\n```text\nhttps://dev.noodleseed.com\n```\n\nWhen the customer app moves from `dev.noodleseed.com` to `app.noodleseed.com`, update the connector's\n`noodleseedApiOrigin` constant to the production API origin that serves the same paths.\n\nThe connector uses delegated Firebase customer credentials. During the customer OAuth bridge, Noodle Seed\nverifies the Firebase ID token, stores the Firebase refresh token through the credential broker, and refreshes\na Firebase ID token when the connector calls the NoodleSeed.com app API. The broker then exchanges that ID\ntoken for the app's existing Next.js session cookie. There is no shared `NOODLESEED_APP_API_TOKEN` for this\nexample.\n\nFirebase Auth must list `cloud.noodleseed.dev` as an authorized domain before browser sign-in works in\nproduction.\n\n## Deploy customer-protected to Noodle Seed Cloud\n\n```bash\nnoodle deploy examples/customer-auth/src/server.ts \\\n --org noodleseed \\\n --app customer-auth \\\n --env prod \\\n --access customers\n```\n\nEndpoint:\n\n```text\nhttps://cloud.noodleseed.dev/o/noodleseed/customer-auth/mcp\n```\n\n## MCP Primitives\n\n- Tool `list_my_organizations`: calls `GET /api/organizations` and returns the organizations the signed-in\n customer is a member of. Takes no arguments; the org set is scoped by the verified customer session.\n- Tool `list_org_apps`: calls `GET /api/organizations/{org_id}/apps` for one organization `org_id`.\n\n## Auth boundary\n\nFirebase ID-token verification is handled by Noodle Seed's generic Firebase bridge adapter during OAuth\nissuance. The MCP client receives a Noodle-issued, resource-bound access token marked as a Firebase customer\nidentity; raw Firebase tokens and inbound MCP bearer tokens are never forwarded to tools, connectors,\nwidgets, or downstream systems.\n\nThe connector-backed tools use the credential broker to turn the signed-in Firebase customer session into the\nsame session-cookie credential that the existing NoodleSeed.com Next.js API already expects. The inbound MCP\nbearer token is never used as an app API credential.\n" },
53
53
  { relPath: "examples/customer-auth/noodle.json", content: "{\n \"entrypoint\": \"src/server.ts\",\n \"name\": \"customer-auth\"\n}\n" },
54
54
  { relPath: "examples/customer-auth/package.json", content: "{\n \"name\": \"customer-auth\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {\n \"test\": \"vitest run\",\n \"validate\": \"noodle validate\",\n \"dev\": \"noodle dev\",\n \"deploy\": \"noodle deploy\"\n },\n \"devDependencies\": {\n \"@noodleseed/one\": \"latest\",\n \"vitest\": \"latest\"\n }\n}\n" },
55
- { relPath: "examples/customer-auth/src/server.ts", content: "import {\n annotations,\n connector,\n customerAuth,\n embeddedAssistant,\n openAICompatible,\n secret,\n server,\n tool,\n variable,\n z,\n} from '@noodleseed/one';\n\nconst noodleseedApiOrigin = 'https://dev.noodleseed.com';\n\nconst noodleseedApi = connector('noodleseed_app_api')\n .version('1.0.0')\n .http({\n baseUrl: noodleseedApiOrigin,\n allowedOrigins: [noodleseedApiOrigin],\n auth: {\n kind: 'delegatedSessionCookie',\n provider: 'firebase',\n sessionUrl: `${noodleseedApiOrigin}/api/auth/session`,\n tokenField: 'idToken',\n },\n operations: {\n list_org_apps: {\n type: 'read',\n method: 'GET',\n path: '/api/organizations/${args.org_id}/apps',\n query: ['skip', 'limit'],\n input: z.object({\n org_id: z.string(),\n skip: z.number().optional(),\n limit: z.number().optional(),\n }),\n output: z.object({ result: z.unknown().optional() }),\n response: {\n result: '${response}',\n },\n },\n list_organizations: {\n type: 'read',\n method: 'GET',\n path: '/api/organizations',\n output: z.object({ organizations: z.array(z.unknown()).optional() }),\n response: {\n organizations: '${response.organizations}',\n },\n },\n },\n });\n\nexport default server(\n 'noodleseed_customer_auth',\n {\n title: 'NoodleSeed.com Customer Auth',\n version: '1.0.0',\n branding: {\n name: 'Noodle Seed Assistant',\n accent: '#E85D24',\n surface: '#FFFFFF',\n surfaceDark: '#171310',\n colorScheme: 'auto',\n theme: {\n light: { accentText: '#FFFFFF', text: '#1C1714' },\n dark: { accent: '#FF8A4C', accentText: '#1C100A', text: '#FFF8F2' },\n },\n },\n use: { app_api: noodleseedApi },\n auth: customerAuth.firebase({\n projectId: variable('FIREBASE_PROJECT_ID'),\n apiKey: variable('FIREBASE_WEB_API_KEY'),\n authDomain: variable('FIREBASE_AUTH_DOMAIN'),\n user: {\n id: 'sub',\n email: 'email',\n name: 'name',\n tenant: 'firebase.tenant',\n orgs: 'claims.orgs',\n roles: 'claims.roles',\n },\n }),\n instructions:\n 'Customer-authenticated demo. Firebase proves the customer identity, while read-only NoodleSeed.com API calls use broker-managed delegated Firebase customer credentials.',\n assistant: embeddedAssistant({\n model: openAICompatible({\n baseUrl: variable('ASSISTANT_MODEL_BASE_URL'),\n model: variable('ASSISTANT_MODEL'),\n apiKey: secret('ASSISTANT_MODEL_API_KEY'),\n }),\n // Production origins are exact HTTPS; http://localhost:<port> is allowed for local development.\n allowedOrigins: [\n 'https://app.noodleseed.com',\n 'https://dev.noodleseed.com',\n 'http://localhost:3000',\n ],\n layout: { mode: 'floating', position: 'bottom-right', panelWidth: 420 },\n labels: {\n welcomeHeading: 'How can I help with Noodle Seed?',\n composerPlaceholder: 'Ask about your apps…',\n },\n presentation: {\n panel: { surface: 'glass', elevation: 'soft', border: 'subtle' },\n launcher: { icon: 'brand-mark', status: 'session', effect: 'pulse' },\n header: {\n mark: 'status',\n badge: { text: 'Workspace online', tone: 'success', indicator: true },\n },\n composer: { leadingIcon: 'brand-mark', shape: 'pill' },\n },\n suggestedPrompts: ['Show my organizations', 'List the apps in my organization'],\n }),\n },\n [\n tool('list_org_apps', {\n title: 'List organization apps',\n description: 'List NoodleSeed.com apps for an organization from the dev app API.',\n input: z.object({\n org_id: z.string().meta({ title: 'Organization' }),\n skip: z.number().int().min(0).optional().meta({ title: 'Starting item' }),\n limit: z.number().int().min(1).max(100).optional().meta({ title: 'Maximum results' }),\n }),\n output: z.object({\n result: z.unknown(),\n }),\n annotations: annotations.readOnly(),\n fulfil({ input, connectors }) {\n const apps = connectors.app_api.listOrgApps({\n org_id: input.org_id,\n skip: input.skip,\n limit: input.limit,\n });\n return {\n result: apps.result,\n };\n },\n }),\n tool('list_my_organizations', {\n title: 'List my organizations',\n description: 'List the NoodleSeed.com organizations the signed-in customer belongs to.',\n contextProvider: true,\n input: z.object({}),\n output: z.object({\n organizations: z.array(z.unknown()),\n }),\n annotations: annotations.readOnly(),\n fulfil({ connectors }) {\n const organizations = connectors.app_api.listOrganizations();\n return {\n organizations: organizations.organizations,\n };\n },\n }),\n ],\n);\n" },
55
+ { relPath: "examples/customer-auth/src/server.ts", content: "import {\n annotations,\n connector,\n customerAuth,\n embeddedAssistant,\n openAICompatible,\n secret,\n server,\n tool,\n variable,\n z,\n} from '@noodleseed/one';\n\nconst noodleseedApiOrigin = 'https://dev.noodleseed.com';\n\nconst noodleseedApi = connector('noodleseed_app_api')\n .version('1.0.0')\n .http({\n baseUrl: noodleseedApiOrigin,\n allowedOrigins: [noodleseedApiOrigin],\n auth: {\n kind: 'delegatedSessionCookie',\n provider: 'firebase',\n sessionUrl: `${noodleseedApiOrigin}/api/auth/session`,\n tokenField: 'idToken',\n },\n operations: {\n list_org_apps: {\n type: 'read',\n method: 'GET',\n path: '/api/organizations/${args.org_id}/apps',\n query: ['skip', 'limit'],\n input: z.object({\n org_id: z.string(),\n skip: z.number().optional(),\n limit: z.number().optional(),\n }),\n output: z.object({ result: z.unknown().optional() }),\n response: {\n result: '${response}',\n },\n },\n list_organizations: {\n type: 'read',\n method: 'GET',\n path: '/api/organizations',\n output: z.object({ organizations: z.array(z.unknown()).optional() }),\n response: {\n organizations: '${response.organizations}',\n },\n },\n },\n });\n\nexport default server(\n 'noodleseed_customer_auth',\n {\n title: 'NoodleSeed.com Customer Auth',\n version: '1.0.0',\n branding: {\n name: 'Noodle Seed Assistant',\n accent: '#E85D24',\n surface: '#FFFFFF',\n surfaceDark: '#171310',\n colorScheme: 'auto',\n theme: {\n light: { accentText: '#FFFFFF', text: '#1C1714' },\n dark: { accent: '#FF8A4C', accentText: '#1C100A', text: '#FFF8F2' },\n },\n },\n use: { app_api: noodleseedApi },\n auth: customerAuth.firebase({\n projectId: variable('FIREBASE_PROJECT_ID'),\n apiKey: variable('FIREBASE_WEB_API_KEY'),\n authDomain: variable('FIREBASE_AUTH_DOMAIN'),\n user: {\n id: 'sub',\n email: 'email',\n name: 'name',\n tenant: 'firebase.tenant',\n orgs: 'claims.orgs',\n roles: 'claims.roles',\n },\n }),\n instructions:\n 'Customer-authenticated demo. Firebase proves the customer identity, while read-only NoodleSeed.com API calls use broker-managed delegated Firebase customer credentials.',\n assistant: embeddedAssistant({\n model: openAICompatible({\n baseUrl: variable('ASSISTANT_MODEL_BASE_URL'),\n model: variable('ASSISTANT_MODEL'),\n apiKey: secret('ASSISTANT_MODEL_API_KEY'),\n }),\n // Production origins are exact HTTPS; http://localhost:<port> is allowed for local development.\n allowedOrigins: [\n 'https://app.noodleseed.com',\n 'https://dev.noodleseed.com',\n 'http://localhost:3000',\n ],\n layout: { mode: 'floating', position: 'bottom-right', panelWidth: 420 },\n labels: {\n welcomeHeading: 'How can I help with Noodle Seed?',\n composerPlaceholder: 'Ask about your apps…',\n },\n presentation: {\n panel: { surface: 'glass', elevation: 'soft', border: 'subtle' },\n launcher: { icon: 'brand-mark', status: 'session', effect: 'pulse' },\n header: {\n mark: 'status',\n badge: { text: 'Workspace online', tone: 'success', indicator: true },\n },\n composer: { leadingIcon: 'brand-mark', shape: 'pill' },\n },\n suggestedPrompts: ['Show my organizations', 'List the apps in my organization'],\n }),\n },\n [\n tool('list_org_apps', {\n title: 'List organization apps',\n description: 'List NoodleSeed.com apps for an organization from the dev app API.',\n input: z.object({\n org_id: z.string().meta({ title: 'Organization' }),\n skip: z.number().int().min(0).optional().meta({ title: 'Starting item' }),\n limit: z.number().int().min(1).max(100).optional().meta({ title: 'Maximum results' }),\n }),\n output: z.object({\n result: z.unknown(),\n }),\n annotations: annotations.readOnly(),\n fulfil({ input, connectors }) {\n const apps = connectors.app_api.listOrgApps({\n org_id: input.org_id,\n skip: input.skip,\n limit: input.limit,\n });\n return {\n result: apps.result,\n };\n },\n }),\n tool('list_my_organizations', {\n title: 'List my organizations',\n description: 'List the NoodleSeed.com organizations the signed-in customer belongs to.',\n contextProvider: true,\n input: z.object({}),\n // The dev app API returns every organization for the signed-in customer in one response, with no\n // page parameter to pass through, so the bound is declared on the shape. A customer belongs to a\n // handful of organizations; `noodle check` reports an unbounded list as\n // `tool_design_output_bounds`.\n output: z.object({\n organizations: z.array(z.unknown()).max(100),\n }),\n annotations: annotations.readOnly(),\n fulfil({ connectors }) {\n const organizations = connectors.app_api.listOrganizations();\n return {\n organizations: organizations.organizations,\n };\n },\n }),\n ],\n);\n" },
56
56
  { relPath: "examples/customer-auth/test/server.test.ts", content: "import { describe, expect, it } from 'vitest';\nimport app from '../src/server.js';\n\ndescribe('customer-auth example', () => {\n it('exports a customer-authenticated, customer-branded embedded assistant', async () => {\n expect(typeof app.toManifest).toBe('function');\n const manifest = await app.toManifest();\n expect(manifest.server.assistant).toMatchObject({\n model: { kind: 'openai-compatible', apiKey: 'ASSISTANT_MODEL_API_KEY' },\n layout: { mode: 'floating' },\n presentation: {\n panel: { surface: 'glass', elevation: 'soft', border: 'subtle' },\n launcher: { icon: 'brand-mark', status: 'session', effect: 'pulse' },\n header: { mark: 'status', badge: { text: 'Workspace online', tone: 'success' } },\n },\n });\n expect(\n manifest.server.assistant?.allowedOrigins.every(\n (origin) => origin.startsWith('https://') || origin.startsWith('http://localhost:'),\n ),\n ).toBe(true);\n expect(manifest.server.branding).toMatchObject({\n name: 'Noodle Seed Assistant',\n colorScheme: 'auto',\n });\n expect(manifest.server.auth).toMatchObject({\n projectId: '${env.FIREBASE_PROJECT_ID}',\n apiKey: '${env.FIREBASE_WEB_API_KEY}',\n authDomain: '${env.FIREBASE_AUTH_DOMAIN}',\n });\n });\n});\n" },
57
57
  { relPath: "examples/food-ordering/README.md", content: "# Food Ordering\n\n**Owns:** The flagship consumer ordering MCP App example: React view authoring, app-only helper tools,\ncaller-scoped cart state handles, invocation context, model-visible widget state/lifecycle, packaged image\nassets, portable structured elicitation, checkout handoff policy, host actions, CSP/permissions metadata,\nand widget preview coverage.\n\nFood Ordering is a generic, synthetic version of a live marketplace ordering app. It lets a user search\nstores, browse menus, customize an item, build a multi-line cart, review the order, and hand off checkout to\nan allowlisted example domain. It does not use real restaurant APIs, real checkout, customer credentials, or\nprivate customer data.\n\n## What It Shows\n\n| Capability | Example |\n| :--- | :--- |\n| Public entry tool | `open_ordering` returns structured fallback content and renders the React widget |\n| App-only helper tools | `search_stores`, `load_menu`, `load_item`, `read_cart`, `sync_cart`, `prepare_checkout`; mutating widget-owned helpers use `confirm: false` (equivalent to omission) and execute directly because action hints alone never gate |\n| Durable cart state | `server(..., { state: { handles: { cart } }, use: { state } })` with caller scope and revision checks |\n| React app runtime kit | `@noodleseed/one/react` supplies app flow, shell/nav/view, async state, form, quantity, choice, and handoff primitives |\n| Multi-step widget flow | One React shell navigates stores, menu, item customization, cart, review, and handoff views through `useAppFlow` |\n| Invocation context | `server.context` sets locale/time-zone defaults, derives an ambient service area/date, and makes the same snapshot available to tools and the reserved `noodle_context` MCP adapter |\n| Structured missing input | `plan_order` uses `ctx.elicit` to collect a fulfilment method and date through embedded/headless forms, standard bidirectional elicitation, a linked MCP App form, or an exact structured conversational retry on stateless hosts |\n| Model-visible widget state | `useUpdateModelContext` publishes one cohesive replacement snapshot when supported; `useWidgetLifecycle` auto-publishes mounted/cancelled/dismissed and reports author-owned submitted milestones for future context (not host-presentation proof), while the user-triggered submit pairs `useSendFollowUpMessage` for an immediate reply |\n| Handoff | `handoff.allowedDomains` allows only `https://orders.example.com` checkout URLs |\n| Progressive enhancement | Non-Apps hosts still receive stores, featured items, and a readable fallback summary |\n\nThe example is intentionally richer than the generated starter, but each inline view still follows the\nsame default: one immediate purpose, one primary action, at most one subordinate action, and progressive\ndisclosure for the rest. Preview it at 280px before adding navigation or local CSS; loading, empty, stale,\nerror/retry, and success states must remain readable without nested vertical scrolling.\n\nLike the comprehensive default `noodle init my-app` scaffold, this flagship keeps the server feature-rich\nwhile making each individual widget view focused; server capability breadth and screen density are separate.\nThe compiled initial widget should normally remain under the 1 MiB performance recommendation; Noodle Seed's\nhard ceilings are 10 MiB per compiled widget and 20 MiB across one deployment. Run `noodle check` to see raw\nand gzip-estimated sizes. Deploy requests are gzip-compressed as one stream so repeated self-contained React\nruntime bytes deduplicate on the wire without a cross-tenant CDN. Keep menu images or large live datasets in assets/resources and app-only tools\nrather than embedding them into the initial HTML bundle.\n\n## Local Author Loop\n\n```sh\nnoodle validate\nnoodle test\nnoodle dev\n```\n\nIn another terminal:\n\n```sh\nnoodle tools list\nnoodle tools call open_ordering --args '{\"customer\":\"Asha\",\"query\":\"noodles\"}'\nnoodle tools call summarize_ordering_options --args '{}'\n```\n\nFor Apps metadata conformance, start `noodle dev`, copy the loopback MCP endpoint, then run:\n\n```sh\nnpx @mcpjam/cli@latest apps conformance --url http://127.0.0.1:<port>/o/demo/food-ordering/mcp --quiet --format json\n```\n\n## Client Setup\n\nUse the CLI to print the exact setup flow for your MCP client:\n\n```sh\nnoodle connect claude\nnoodle connect chatgpt\nnoodle connect inspector\n```\n\n## Deploy\n\n```sh\nnoodle link --org demo --app food-ordering\nnoodle deploy --access owner-only\nnoodle open\n```\n\nUse `--access org-members` for an org-wide internal demo. This example has no connector secrets and does not\ninclude tokens, caller-key mechanisms, or `.env.noodle` values.\n\n## Demo Assets\n\nThe packaged demo images live under `assets/` and are public web assets when deployed. The current app uses\n`assets/noodle-bowl.jpg` as the server branding image.\n\nImage sources:\n\n- `assets/noodle-bowl.jpg` — Unsplash photo\n [`IRv8V9Hb8gI`](https://unsplash.com/photos/IRv8V9Hb8gI), downloaded from Unsplash.\n- `assets/lentil-soup.jpg` — Unsplash image\n [`photo-1510431198580-7727c9fa1e3a`](https://images.unsplash.com/photo-1510431198580-7727c9fa1e3a), downloaded from Unsplash.\n- `assets/mint-lemonade.jpg` — Unsplash photo\n [`X7Nx327NtuA`](https://unsplash.com/photos/X7Nx327NtuA) by Imad 786.\n\nUnsplash photos are free to use under the [Unsplash License](https://unsplash.com/license); attribution is\nnot required, but source notes are kept here for provenance.\n" },
58
58
  { relPath: "examples/food-ordering/noodle.json", content: "{\n \"entrypoint\": \"src/server.ts\",\n \"name\": \"food-ordering\",\n \"template\": \"widget\"\n}\n" },
59
59
  { relPath: "examples/food-ordering/package.json", content: "{\n \"name\": \"food-ordering\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {\n \"test\": \"vitest run test/server.test.ts\",\n \"validate\": \"noodle validate\",\n \"dev\": \"noodle dev\",\n \"deploy\": \"noodle deploy\"\n },\n \"devDependencies\": {\n \"@vitejs/plugin-react\": \"latest\",\n \"@noodleseed/one\": \"latest\",\n \"@types/react\": \"latest\",\n \"@types/react-dom\": \"latest\",\n \"react\": \"latest\",\n \"react-dom\": \"latest\",\n \"vite\": \"latest\",\n \"vitest\": \"latest\"\n }\n}\n" },
60
60
  { relPath: "examples/food-ordering/src/helpers.ts", content: "import type { ServerDefinition } from '@noodleseed/one';\n\nexport {\n ActionBar,\n AppShell,\n AsyncBoundary,\n ChoiceGroup,\n createViewStore,\n DataCard,\n DataList,\n Field,\n HandoffButton,\n QuantityStepper,\n ShellNav,\n StatusBadge,\n SubmitButton,\n View,\n ViewStack,\n} from '@noodleseed/one/react';\n\nimport { generateHelpers } from '@noodleseed/one/react';\n\nexport type AppType = ServerDefinition;\n\nexport const {\n useCallTool,\n useAppFlow,\n useHandoff,\n useLayout,\n useOpenExternal,\n useSendFollowUpMessage,\n useToolInfo,\n useUpdateModelContext,\n useViewState,\n useWidgetLifecycle,\n} = generateHelpers<AppType>();\n" },
61
- { relPath: "examples/food-ordering/src/server.ts", content: "import { annotations, asset, connector, resource, server, tool, z } from '@noodleseed/one';\n\nconst heroImage = asset('assets/noodle-bowl.jpg');\n\nconst state = connector('noodle_state')\n .version('1.0.0')\n .operation('read_state', {\n type: 'read',\n input: z.object({\n handle: z.string(),\n key: z.string().optional(),\n }),\n output: z.object({\n value: z.record(z.string(), z.unknown()),\n revision: z.number().int(),\n status: z.string(),\n }),\n })\n .operation('patch_state', {\n type: 'action',\n input: z.object({\n handle: z.string(),\n expectedRevision: z.number().int(),\n value: z.record(z.string(), z.unknown()),\n }),\n output: z.object({\n value: z.record(z.string(), z.unknown()),\n revision: z.number().int(),\n status: z.string(),\n }),\n });\n\nconst stores = [\n {\n id: 'harbor-noodles',\n name: 'Harbor Noodles',\n cuisine: 'Noodles',\n address: '18 Pier Lane',\n open: true,\n etaMinutes: 24,\n rating: 4.8,\n },\n {\n id: 'garden-wraps',\n name: 'Garden Wraps',\n cuisine: 'Vegetarian',\n address: '44 Market Street',\n open: true,\n etaMinutes: 18,\n rating: 4.6,\n },\n {\n id: 'midnight-tacos',\n name: 'Midnight Tacos',\n cuisine: 'Mexican',\n address: '7 Station Road',\n open: false,\n etaMinutes: 35,\n rating: 4.7,\n },\n] as const;\n\nconst menu = [\n {\n id: 'spicy_miso',\n storeId: 'harbor-noodles',\n category: 'Bowls',\n name: 'Spicy Miso Bowl',\n price: 16,\n description: 'Miso broth, wheat noodles, chili crisp, egg, and greens.',\n modifiers: ['extra_noodles', 'soft_egg', 'chili_crisp'],\n },\n {\n id: 'ginger_tofu',\n storeId: 'harbor-noodles',\n category: 'Bowls',\n name: 'Ginger Tofu Bowl',\n price: 15,\n description: 'Tofu, ginger broth, mushrooms, and scallions.',\n modifiers: ['extra_tofu', 'brown_rice', 'no_mushroom'],\n },\n {\n id: 'green_falafel',\n storeId: 'garden-wraps',\n category: 'Wraps',\n name: 'Green Falafel Wrap',\n price: 13,\n description: 'Falafel, herbs, pickles, tahini, and crisp vegetables.',\n modifiers: ['extra_tahini', 'add_fries', 'gluten_free_wrap'],\n },\n {\n id: 'sweet_potato',\n storeId: 'garden-wraps',\n category: 'Plates',\n name: 'Sweet Potato Plate',\n price: 14,\n description: 'Roasted sweet potato, grains, greens, and lemon yogurt.',\n modifiers: ['vegan_yogurt', 'extra_greens', 'hot_sauce'],\n },\n] as const;\n\nconst cartLine = z.object({\n itemId: z.string(),\n quantity: z.number().int().min(1),\n modifiers: z.array(z.string()).default([]),\n note: z.string().optional(),\n});\n\nconst cartInput = z.object({\n selectedStoreId: z.string().optional(),\n lines: z.array(cartLine).default([]),\n customer: z.string().default('Guest'),\n notes: z.string().optional(),\n subtotal: z.number().default(0),\n expectedRevision: z.number().int().min(0).default(0),\n});\n\nconst storeShape = z.object({\n id: z.string(),\n name: z.string(),\n cuisine: z.string(),\n address: z.string(),\n open: z.boolean(),\n etaMinutes: z.number(),\n rating: z.number(),\n});\n\nconst menuItemShape = z.object({\n id: z.string(),\n storeId: z.string(),\n category: z.string(),\n name: z.string(),\n price: z.number(),\n description: z.string(),\n modifiers: z.array(z.string()),\n});\n\nconst cartOutput = z.object({\n selectedStoreId: z.string().optional(),\n lines: z.array(cartLine),\n customer: z.string(),\n notes: z.string().optional(),\n subtotal: z.number(),\n status: z.enum(['draft', 'review', 'handoff']),\n checkoutUrl: z.string().optional(),\n});\n\nconst cartStateSchema = z.object({\n selectedStoreId: z.string().optional(),\n lines: z.array(cartLine),\n customer: z.string(),\n notes: z.string().optional(),\n subtotal: z.number(),\n // `.default()`/`.optional()` state fields are optional on write: a cart save that omits\n // `status` still validates, and a fresh cart starts in `draft`.\n status: z.enum(['draft', 'review', 'handoff']).default('draft'),\n checkoutUrl: z.string().optional(),\n});\n\ntype CartLine = {\n readonly itemId: string;\n readonly quantity: number;\n readonly modifiers: readonly string[];\n readonly note?: string;\n};\n\ntype CartInput = {\n readonly selectedStoreId?: string;\n readonly lines: readonly CartLine[];\n readonly customer: string;\n readonly notes?: string;\n readonly subtotal: number;\n readonly expectedRevision: number;\n};\n\nconst readOnly = annotations.readOnly();\n// These writes are app-only controls inside the cart widget. The widget already presents the\n// reviewed state and explicit button; `confirm: false` documents direct execution and is equivalent\n// to omission because action/open-world hints alone never enable the confirmation gate.\nconst action = annotations.openAction({ destructive: false, confirm: false });\n\nfunction checkoutUrl(customer: string): string {\n return `https://orders.example.com/checkout?customer=${encodeURIComponent(customer)}`;\n}\n\nfunction cartValue(input: CartInput, status: 'draft' | 'review' | 'handoff') {\n return {\n selectedStoreId: input.selectedStoreId,\n lines: input.lines,\n customer: input.customer,\n notes: input.notes,\n subtotal: input.subtotal,\n status,\n ...(status === 'handoff' ? { checkoutUrl: 'https://orders.example.com/checkout' } : {}),\n };\n}\n\nexport default server(\n 'food_ordering',\n {\n title: 'Food Ordering',\n version: '1.0.0',\n use: { state },\n context: {\n defaults: { locale: 'en-US', timeZone: 'America/New_York' },\n ambient: {\n output: z.object({ serviceArea: z.string(), orderingDate: z.string() }),\n fulfil: ({ context }) => ({\n serviceArea: 'Harbor District',\n orderingDate: context.temporal.localDate,\n }),\n },\n },\n state: {\n handles: {\n cart: {\n kind: 'cart',\n version: 'v1',\n scope: 'caller',\n ttlSeconds: 7200,\n schema: cartStateSchema,\n },\n },\n },\n branding: {\n name: 'Food Ordering',\n accent: '#0F8F5F',\n surface: '#F7F7F5',\n surfaceDark: '#111820',\n logo: {\n uri: heroImage,\n alt: 'Food Ordering noodle bowl',\n },\n radius: 'lg',\n density: 'comfortable',\n },\n handoff: {\n allowedDomains: ['https://orders.example.com'],\n },\n },\n [\n tool('open_ordering', {\n description:\n 'Open a complete food-ordering widget with store discovery, menu browsing, cart review, and checkout handoff.',\n annotations: readOnly,\n input: z.object({\n query: z.string().optional(),\n customer: z.string().default('Guest'),\n }),\n output: z.object({\n status: z.string(),\n customer: z.string(),\n stores: z.array(storeShape),\n featuredItems: z.array(menuItemShape),\n localDate: z.string(),\n serviceArea: z.string(),\n fallback: z.string(),\n }),\n fulfil: ({ input, context }) => ({\n status: 'Ready to build a food order.',\n customer: input.customer,\n stores,\n featuredItems: menu,\n localDate: context.temporal.localDate,\n serviceArea: context.ambient.serviceArea,\n fallback: 'Open stores: Harbor Noodles (Noodles), Garden Wraps (Vegetarian).',\n }),\n viewTitle: 'Food ordering',\n domain: 'https://orders.example.com',\n view: {\n component: 'ordering-flow',\n entry: './views/ordering-flow.tsx',\n },\n viewDescription:\n 'A complete consumer ordering surface with app-only helper tools, cart state, and checkout handoff.',\n csp: {\n connectDomains: ['https://orders.example.com'],\n resourceDomains: ['https://orders.example.com'],\n frameDomains: ['https://orders.example.com'],\n },\n permissions: { clipboardWrite: {} },\n }),\n tool('search_stores', {\n visibility: ['app'],\n description: 'Filter synthetic restaurants for the ordering widget.',\n annotations: readOnly,\n input: z.object({\n query: z.string().optional(),\n openOnly: z.boolean().default(false),\n }),\n output: z.object({ stores: z.array(storeShape) }),\n fulfil: () => ({ stores }),\n }),\n tool('load_menu', {\n visibility: ['app'],\n description: 'Load synthetic menu categories and items for one store.',\n annotations: readOnly,\n input: z.object({ storeId: z.string() }),\n output: z.object({\n storeId: z.string(),\n stores: z.array(storeShape),\n items: z.array(menuItemShape),\n }),\n fulfil: ({ input }) => ({ storeId: input.storeId, stores, items: menu }),\n }),\n tool('load_item', {\n visibility: ['app'],\n description: 'Load item details and modifier options for the ordering widget.',\n annotations: readOnly,\n input: z.object({ itemId: z.string() }),\n output: z.object({ itemId: z.string(), items: z.array(menuItemShape) }),\n fulfil: ({ input }) => ({ itemId: input.itemId, items: menu }),\n }),\n tool('read_cart', {\n visibility: ['app'],\n description: 'Read the caller-scoped ordering cart state.',\n annotations: readOnly,\n input: z.object({}),\n output: z.object({\n value: z.unknown(),\n revision: z.number(),\n status: z.string(),\n }),\n fulfil: ({ connectors }) => {\n const state = connectors.state.readState({ handle: 'cart' });\n return { value: state.value, revision: state.revision, status: state.status };\n },\n }),\n tool('sync_cart', {\n visibility: ['app'],\n description: 'Patch the caller-scoped ordering cart with the widget cart mirror.',\n annotations: action,\n input: cartInput,\n output: z.object({\n cart: cartOutput,\n revision: z.number(),\n status: z.string(),\n }),\n fulfil: ({ input, connectors }) => {\n const cart = cartValue(input, 'draft');\n const state = connectors.state.patchState({\n handle: 'cart',\n expectedRevision: input.expectedRevision,\n value: cart,\n });\n return { cart, revision: state.revision, status: state.status };\n },\n }),\n tool('prepare_checkout', {\n visibility: ['app'],\n description: 'Prepare the caller-scoped cart for checkout handoff.',\n annotations: action,\n input: cartInput,\n output: z.object({\n cart: cartOutput,\n revision: z.number(),\n checkoutUrl: z.string(),\n }),\n fulfil: ({ input, connectors }) => {\n const cart = cartValue(input, 'handoff');\n const state = connectors.state.patchState({\n handle: 'cart',\n expectedRevision: input.expectedRevision,\n value: cart,\n });\n return {\n cart,\n revision: state.revision,\n checkoutUrl: cart.checkoutUrl ?? checkoutUrl(input.customer),\n };\n },\n }),\n tool('summarize_ordering_options', {\n description: 'Summarize available stores and menu examples without opening the widget.',\n annotations: readOnly,\n input: z.object({}),\n output: z.object({\n stores: z.array(storeShape),\n featuredItems: z.array(menuItemShape),\n }),\n fulfil: () => ({ stores, featuredItems: menu }),\n }),\n tool('plan_order', {\n description:\n 'Collect a fulfilment method and requested date as structured input, then return a reviewable order plan without placing an order.',\n annotations: readOnly,\n input: z.object({ customer: z.string().default('Guest') }),\n output: z.object({\n customer: z.string(),\n method: z.enum(['pickup', 'delivery']),\n requestedDate: z.string(),\n serviceArea: z.string(),\n }),\n fulfil: ({ input, context, elicit }) => {\n const preference = elicit({\n id: 'choose_fulfilment',\n message: 'How should we fulfil this order?',\n input: z.object({\n method: z.enum(['pickup', 'delivery']).describe('Fulfilment method'),\n requestedDate: z.string().describe('Requested date').meta({ format: 'date' }),\n }),\n });\n return {\n customer: input.customer,\n method: preference.method,\n requestedDate: preference.requestedDate,\n serviceArea: context.ambient.serviceArea,\n };\n },\n }),\n tool('show_capabilities', {\n description: 'Return a concise summary for the standalone widget capability preview.',\n annotations: readOnly,\n input: z.object({}),\n output: z.object({ status: z.string(), note: z.string() }),\n fulfil: () => ({\n status: 'Food Ordering widget capabilities are ready.',\n note: 'Standalone preview covers React views, helper tools, cart state, handoff, CSP, and permissions.',\n }),\n viewName: 'capabilities_card',\n viewTitle: 'Food Ordering capabilities',\n viewDescription: 'Standalone widget resource for previewing the ordering capability surface.',\n domain: 'https://orders.example.com',\n view: { component: 'capabilities-card', entry: './views/capabilities-card.tsx' },\n csp: {\n connectDomains: ['https://orders.example.com'],\n resourceDomains: ['https://orders.example.com'],\n frameDomains: ['https://orders.example.com'],\n },\n permissions: { clipboardWrite: {} },\n }),\n resource('food_ordering_guide', {\n uri: 'docs://food-ordering',\n title: 'Food Ordering widget guide',\n description: 'Synthetic guide resource for the consumer ordering flagship.',\n mimeType: 'text/markdown',\n // Return the resource body directly; the runtime maps it into MCP `contents` using the\n // resource's own uri + mimeType. Do not return a `{ contents: [...] }` wrapper — that double-wraps.\n fulfil: () =>\n [\n '# Food Ordering Widget Guide',\n '',\n '- Demonstrates a multi-step ordering widget, app-only helper tools, typed cart state, and handoff.',\n '- Store, menu, and checkout data are synthetic and contain no customer credentials.',\n '- Checkout opens an allowlisted example URL; payment and final ordering remain out of scope.',\n ].join('\\n'),\n }),\n ],\n);\n" },
61
+ { relPath: "examples/food-ordering/src/server.ts", content: "import { annotations, asset, connector, resource, server, tool, z } from '@noodleseed/one';\n\nconst heroImage = asset('assets/noodle-bowl.jpg');\n\nconst state = connector('noodle_state')\n .version('1.0.0')\n .operation('read_state', {\n type: 'read',\n input: z.object({\n handle: z.string(),\n key: z.string().optional(),\n }),\n output: z.object({\n value: z.record(z.string(), z.unknown()),\n revision: z.number().int(),\n status: z.string(),\n }),\n })\n .operation('patch_state', {\n type: 'action',\n input: z.object({\n handle: z.string(),\n expectedRevision: z.number().int(),\n value: z.record(z.string(), z.unknown()),\n }),\n output: z.object({\n value: z.record(z.string(), z.unknown()),\n revision: z.number().int(),\n status: z.string(),\n }),\n });\n\nconst stores = [\n {\n id: 'harbor-noodles',\n name: 'Harbor Noodles',\n cuisine: 'Noodles',\n address: '18 Pier Lane',\n open: true,\n etaMinutes: 24,\n rating: 4.8,\n },\n {\n id: 'garden-wraps',\n name: 'Garden Wraps',\n cuisine: 'Vegetarian',\n address: '44 Market Street',\n open: true,\n etaMinutes: 18,\n rating: 4.6,\n },\n {\n id: 'midnight-tacos',\n name: 'Midnight Tacos',\n cuisine: 'Mexican',\n address: '7 Station Road',\n open: false,\n etaMinutes: 35,\n rating: 4.7,\n },\n] as const;\n\nconst menu = [\n {\n id: 'spicy_miso',\n storeId: 'harbor-noodles',\n category: 'Bowls',\n name: 'Spicy Miso Bowl',\n price: 16,\n description: 'Miso broth, wheat noodles, chili crisp, egg, and greens.',\n modifiers: ['extra_noodles', 'soft_egg', 'chili_crisp'],\n },\n {\n id: 'ginger_tofu',\n storeId: 'harbor-noodles',\n category: 'Bowls',\n name: 'Ginger Tofu Bowl',\n price: 15,\n description: 'Tofu, ginger broth, mushrooms, and scallions.',\n modifiers: ['extra_tofu', 'brown_rice', 'no_mushroom'],\n },\n {\n id: 'green_falafel',\n storeId: 'garden-wraps',\n category: 'Wraps',\n name: 'Green Falafel Wrap',\n price: 13,\n description: 'Falafel, herbs, pickles, tahini, and crisp vegetables.',\n modifiers: ['extra_tahini', 'add_fries', 'gluten_free_wrap'],\n },\n {\n id: 'sweet_potato',\n storeId: 'garden-wraps',\n category: 'Plates',\n name: 'Sweet Potato Plate',\n price: 14,\n description: 'Roasted sweet potato, grains, greens, and lemon yogurt.',\n modifiers: ['vegan_yogurt', 'extra_greens', 'hot_sauce'],\n },\n] as const;\n\nconst cartLine = z.object({\n itemId: z.string(),\n quantity: z.number().int().min(1),\n modifiers: z.array(z.string()).default([]),\n note: z.string().optional(),\n});\n\nconst cartInput = z.object({\n selectedStoreId: z.string().optional(),\n lines: z.array(cartLine).default([]),\n customer: z.string().default('Guest'),\n notes: z.string().optional(),\n subtotal: z.number().default(0),\n expectedRevision: z.number().int().min(0).default(0),\n});\n\nconst storeShape = z.object({\n id: z.string(),\n name: z.string(),\n cuisine: z.string(),\n address: z.string(),\n open: z.boolean(),\n etaMinutes: z.number(),\n rating: z.number(),\n});\n\nconst menuItemShape = z.object({\n id: z.string(),\n storeId: z.string(),\n category: z.string(),\n name: z.string(),\n price: z.number(),\n description: z.string(),\n // Nested lists count toward the output budget too: unbounded modifiers multiply by every item in a\n // menu payload, so the ceiling is declared here rather than only on the outer array.\n modifiers: z.array(z.string()).max(10),\n});\n\nconst cartOutput = z.object({\n selectedStoreId: z.string().optional(),\n lines: z.array(cartLine),\n customer: z.string(),\n notes: z.string().optional(),\n subtotal: z.number(),\n status: z.enum(['draft', 'review', 'handoff']),\n checkoutUrl: z.string().optional(),\n});\n\nconst cartStateSchema = z.object({\n selectedStoreId: z.string().optional(),\n lines: z.array(cartLine),\n customer: z.string(),\n notes: z.string().optional(),\n subtotal: z.number(),\n // `.default()`/`.optional()` state fields are optional on write: a cart save that omits\n // `status` still validates, and a fresh cart starts in `draft`.\n status: z.enum(['draft', 'review', 'handoff']).default('draft'),\n checkoutUrl: z.string().optional(),\n});\n\ntype CartLine = {\n readonly itemId: string;\n readonly quantity: number;\n readonly modifiers: readonly string[];\n readonly note?: string;\n};\n\ntype CartInput = {\n readonly selectedStoreId?: string;\n readonly lines: readonly CartLine[];\n readonly customer: string;\n readonly notes?: string;\n readonly subtotal: number;\n readonly expectedRevision: number;\n};\n\nconst readOnly = annotations.readOnly();\n// These writes are app-only controls inside the cart widget. The widget already presents the\n// reviewed state and explicit button; `confirm: false` documents direct execution and is equivalent\n// to omission because action/open-world hints alone never enable the confirmation gate.\nconst action = annotations.openAction({ destructive: false, confirm: false });\n\nfunction checkoutUrl(customer: string): string {\n return `https://orders.example.com/checkout?customer=${encodeURIComponent(customer)}`;\n}\n\nfunction cartValue(input: CartInput, status: 'draft' | 'review' | 'handoff') {\n return {\n selectedStoreId: input.selectedStoreId,\n lines: input.lines,\n customer: input.customer,\n notes: input.notes,\n subtotal: input.subtotal,\n status,\n ...(status === 'handoff' ? { checkoutUrl: 'https://orders.example.com/checkout' } : {}),\n };\n}\n\nexport default server(\n 'food_ordering',\n {\n title: 'Food Ordering',\n version: '1.0.0',\n use: { state },\n context: {\n defaults: { locale: 'en-US', timeZone: 'America/New_York' },\n ambient: {\n output: z.object({ serviceArea: z.string(), orderingDate: z.string() }),\n fulfil: ({ context }) => ({\n serviceArea: 'Harbor District',\n orderingDate: context.temporal.localDate,\n }),\n },\n },\n state: {\n handles: {\n cart: {\n kind: 'cart',\n version: 'v1',\n scope: 'caller',\n ttlSeconds: 7200,\n schema: cartStateSchema,\n },\n },\n },\n branding: {\n name: 'Food Ordering',\n accent: '#0F8F5F',\n surface: '#F7F7F5',\n surfaceDark: '#111820',\n logo: {\n uri: heroImage,\n alt: 'Food Ordering noodle bowl',\n },\n radius: 'lg',\n density: 'comfortable',\n },\n handoff: {\n allowedDomains: ['https://orders.example.com'],\n },\n },\n [\n tool('open_ordering', {\n title: 'Open food ordering',\n description:\n 'Open a complete food-ordering widget with store discovery, menu browsing, cart review, and checkout handoff.',\n annotations: readOnly,\n input: z.object({\n query: z.string().optional(),\n customer: z.string().default('Guest'),\n }),\n // List outputs declare a ceiling so a host and the model both know the payload is bounded.\n // A recorded `fulfil` cannot slice, so the cap belongs on the shape; connector-backed lists take\n // a pagination input instead. `noodle check` reports `tool_design_output_bounds` without one.\n output: z.object({\n status: z.string(),\n customer: z.string(),\n stores: z.array(storeShape).max(20),\n featuredItems: z.array(menuItemShape).max(20),\n localDate: z.string(),\n serviceArea: z.string(),\n fallback: z.string(),\n }),\n fulfil: ({ input, context }) => ({\n status: 'Ready to build a food order.',\n customer: input.customer,\n stores,\n featuredItems: menu,\n localDate: context.temporal.localDate,\n serviceArea: context.ambient.serviceArea,\n fallback: 'Open stores: Harbor Noodles (Noodles), Garden Wraps (Vegetarian).',\n }),\n viewTitle: 'Food ordering',\n domain: 'https://orders.example.com',\n view: {\n component: 'ordering-flow',\n entry: './views/ordering-flow.tsx',\n },\n viewDescription:\n 'A complete consumer ordering surface with app-only helper tools, cart state, and checkout handoff.',\n csp: {\n connectDomains: ['https://orders.example.com'],\n resourceDomains: ['https://orders.example.com'],\n frameDomains: ['https://orders.example.com'],\n },\n permissions: { clipboardWrite: {} },\n }),\n tool('search_stores', {\n visibility: ['app'],\n description: 'Filter synthetic restaurants for the ordering widget.',\n annotations: readOnly,\n input: z.object({\n query: z.string().optional(),\n openOnly: z.boolean().default(false),\n }),\n output: z.object({ stores: z.array(storeShape) }),\n fulfil: () => ({ stores }),\n }),\n tool('load_menu', {\n visibility: ['app'],\n description: 'Load synthetic menu categories and items for one store.',\n annotations: readOnly,\n input: z.object({ storeId: z.string() }),\n output: z.object({\n storeId: z.string(),\n stores: z.array(storeShape),\n items: z.array(menuItemShape),\n }),\n fulfil: ({ input }) => ({ storeId: input.storeId, stores, items: menu }),\n }),\n tool('load_item', {\n visibility: ['app'],\n description: 'Load item details and modifier options for the ordering widget.',\n annotations: readOnly,\n input: z.object({ itemId: z.string() }),\n output: z.object({ itemId: z.string(), items: z.array(menuItemShape) }),\n fulfil: ({ input }) => ({ itemId: input.itemId, items: menu }),\n }),\n tool('read_cart', {\n visibility: ['app'],\n description: 'Read the caller-scoped ordering cart state.',\n annotations: readOnly,\n input: z.object({}),\n output: z.object({\n value: z.unknown(),\n revision: z.number(),\n status: z.string(),\n }),\n fulfil: ({ connectors }) => {\n const state = connectors.state.readState({ handle: 'cart' });\n return { value: state.value, revision: state.revision, status: state.status };\n },\n }),\n tool('sync_cart', {\n visibility: ['app'],\n description: 'Patch the caller-scoped ordering cart with the widget cart mirror.',\n annotations: action,\n input: cartInput,\n output: z.object({\n cart: cartOutput,\n revision: z.number(),\n status: z.string(),\n }),\n fulfil: ({ input, connectors }) => {\n const cart = cartValue(input, 'draft');\n const state = connectors.state.patchState({\n handle: 'cart',\n expectedRevision: input.expectedRevision,\n value: cart,\n });\n return { cart, revision: state.revision, status: state.status };\n },\n }),\n tool('prepare_checkout', {\n visibility: ['app'],\n description: 'Prepare the caller-scoped cart for checkout handoff.',\n annotations: action,\n input: cartInput,\n output: z.object({\n cart: cartOutput,\n revision: z.number(),\n checkoutUrl: z.string(),\n }),\n fulfil: ({ input, connectors }) => {\n const cart = cartValue(input, 'handoff');\n const state = connectors.state.patchState({\n handle: 'cart',\n expectedRevision: input.expectedRevision,\n value: cart,\n });\n return {\n cart,\n revision: state.revision,\n checkoutUrl: cart.checkoutUrl ?? checkoutUrl(input.customer),\n };\n },\n }),\n tool('summarize_ordering_options', {\n title: 'Summarize ordering options',\n description: 'Summarize available stores and menu examples without opening the widget.',\n annotations: readOnly,\n input: z.object({}),\n output: z.object({\n stores: z.array(storeShape).max(20),\n featuredItems: z.array(menuItemShape).max(20),\n }),\n fulfil: () => ({ stores, featuredItems: menu }),\n }),\n tool('plan_order', {\n title: 'Plan an order',\n description:\n 'Collect a fulfilment method and requested date as structured input, then return a reviewable order plan without placing an order.',\n annotations: readOnly,\n input: z.object({ customer: z.string().default('Guest') }),\n output: z.object({\n customer: z.string(),\n method: z.enum(['pickup', 'delivery']),\n requestedDate: z.string(),\n serviceArea: z.string(),\n }),\n fulfil: ({ input, context, elicit }) => {\n const preference = elicit({\n id: 'choose_fulfilment',\n message: 'How should we fulfil this order?',\n input: z.object({\n method: z.enum(['pickup', 'delivery']).describe('Fulfilment method'),\n requestedDate: z.string().describe('Requested date').meta({ format: 'date' }),\n }),\n });\n return {\n customer: input.customer,\n method: preference.method,\n requestedDate: preference.requestedDate,\n serviceArea: context.ambient.serviceArea,\n };\n },\n }),\n tool('show_capabilities', {\n title: 'Show capabilities',\n description: 'Return a concise summary for the standalone widget capability preview.',\n annotations: readOnly,\n input: z.object({}),\n output: z.object({ status: z.string(), note: z.string() }),\n fulfil: () => ({\n status: 'Food Ordering widget capabilities are ready.',\n note: 'Standalone preview covers React views, helper tools, cart state, handoff, CSP, and permissions.',\n }),\n viewName: 'capabilities_card',\n viewTitle: 'Food Ordering capabilities',\n viewDescription: 'Standalone widget resource for previewing the ordering capability surface.',\n domain: 'https://orders.example.com',\n view: { component: 'capabilities-card', entry: './views/capabilities-card.tsx' },\n csp: {\n connectDomains: ['https://orders.example.com'],\n resourceDomains: ['https://orders.example.com'],\n frameDomains: ['https://orders.example.com'],\n },\n permissions: { clipboardWrite: {} },\n }),\n resource('food_ordering_guide', {\n uri: 'docs://food-ordering',\n title: 'Food Ordering widget guide',\n description: 'Synthetic guide resource for the consumer ordering flagship.',\n mimeType: 'text/markdown',\n // Return the resource body directly; the runtime maps it into MCP `contents` using the\n // resource's own uri + mimeType. Do not return a `{ contents: [...] }` wrapper — that double-wraps.\n fulfil: () =>\n [\n '# Food Ordering Widget Guide',\n '',\n '- Demonstrates a multi-step ordering widget, app-only helper tools, typed cart state, and handoff.',\n '- Store, menu, and checkout data are synthetic and contain no customer credentials.',\n '- Checkout opens an allowlisted example URL; payment and final ordering remain out of scope.',\n ].join('\\n'),\n }),\n ],\n);\n" },
62
62
  { relPath: "examples/food-ordering/src/views/capabilities-card.tsx", content: "import { useLayout, useOpenExternal, useSendFollowUpMessage, useToolInfo } from '../helpers.js';\nimport './widget-style.css';\n\nconst capabilities: readonly {\n readonly name: string;\n readonly description: string;\n}[] = [\n {\n name: 'React view resource',\n description: 'Compiled widget HTML with a hydrated React entrypoint.',\n },\n {\n name: 'Widget helper calls',\n description: 'App-only tools route through the same host-mediated path.',\n },\n {\n name: 'Durable cart state',\n description: 'Caller-scoped cart records use Noodle state handles with revisions.',\n },\n {\n name: 'External handoff',\n description: 'Checkout opens through declared allowlisted domains.',\n },\n {\n name: 'Model context',\n description: 'Relevant UI state is mirrored through data-llm.',\n },\n {\n name: 'CSP and permissions',\n description: 'Resource metadata declares network and clipboard needs.',\n },\n] as const;\n\nexport default function CapabilitiesCard() {\n const { theme } = useLayout();\n const toolInfo = useToolInfo('show_capabilities');\n const openExternal = useOpenExternal();\n const sendFollowUpMessage = useSendFollowUpMessage();\n const structured = toolInfo.structuredContent as\n | { readonly status?: string; readonly note?: string }\n | undefined;\n\n return (\n <main\n className={`nw-shell${theme === 'dark' ? ' dark' : ''}`}\n data-llm={`Food Ordering capabilities: ${capabilities.map((item) => item.name).join(', ')}`}\n >\n <section className=\"nw-card\">\n <header className=\"nw-header\">\n <span className=\"nw-icon\" aria-hidden=\"true\">\n <PuzzleIcon />\n </span>\n <div className=\"nw-title-block\">\n <h1 className=\"nw-title\">MCP capabilities</h1>\n <p className=\"nw-subtitle\">\n {structured?.status ?? 'Food Ordering widget capabilities are ready.'}\n </p>\n </div>\n <span className=\"nw-chip\">Connected</span>\n </header>\n <div className=\"nw-body\">\n <p className=\"nw-section-title\">Available features</p>\n <ul className=\"nw-feature-list\">\n {capabilities.map((capability) => (\n <li className=\"nw-feature\" key={capability.name}>\n <span className=\"nw-check\" aria-hidden=\"true\">\n <CheckIcon />\n </span>\n <span>\n <span className=\"nw-feature-name\">{capability.name}</span>\n <span className=\"nw-feature-desc\">{capability.description}</span>\n </span>\n </li>\n ))}\n </ul>\n <div className=\"nw-actions\">\n <button\n className=\"nw-button nw-button-primary\"\n type=\"button\"\n onClick={() =>\n sendFollowUpMessage({\n prompt: 'Summarize what the Food Ordering MCP App widget demonstrates.',\n })\n }\n >\n <SparkIcon />\n Ask assistant\n </button>\n <button\n className=\"nw-button\"\n type=\"button\"\n onClick={() => openExternal('https://example.com/noodle-widget-docs')}\n >\n <ExternalIcon />\n Open docs\n </button>\n </div>\n {structured?.note ? <p className=\"nw-note\">{structured.note}</p> : null}\n </div>\n <footer className=\"nw-footer\">\n <span className=\"nw-meta\">\n <ShieldIcon />\n Secure widget surface\n </span>\n <span>v1.0.0</span>\n </footer>\n </section>\n </main>\n );\n}\n\nfunction PuzzleIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M8 4h5v4h3a2 2 0 0 1 0 4h-3v3H9v-3H6a2 2 0 0 1 0-4h2V4Z\" />\n <path d=\"M13 15v5H4v-5\" />\n <path d=\"M13 20h7v-8\" />\n </svg>\n );\n}\n\nfunction CheckIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"m6 12 4 4 8-8\" />\n </svg>\n );\n}\n\nfunction SparkIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"m12 3 1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8L12 3Z\" />\n </svg>\n );\n}\n\nfunction ExternalIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M14 4h6v6\" />\n <path d=\"m20 4-9 9\" />\n <path d=\"M20 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5\" />\n </svg>\n );\n}\n\nfunction ShieldIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M12 3 5 6v5c0 4.4 2.8 8.3 7 10 4.2-1.7 7-5.6 7-10V6l-7-3Z\" />\n <path d=\"m9 12 2 2 4-4\" />\n </svg>\n );\n}\n" },
63
63
  { relPath: "examples/food-ordering/src/views/ordering-flow.tsx", content: "import { useEffect, useMemo, useState } from 'react';\nimport {\n ActionBar,\n AppShell,\n AsyncBoundary,\n ChoiceGroup,\n createViewStore,\n DataCard,\n DataList,\n Field,\n HandoffButton,\n QuantityStepper,\n ShellNav,\n StatusBadge,\n SubmitButton,\n useAppFlow,\n useCallTool,\n useHandoff,\n useLayout,\n useSendFollowUpMessage,\n useToolInfo,\n useUpdateModelContext,\n useViewState,\n useWidgetLifecycle,\n View,\n ViewStack,\n} from '../helpers.js';\nimport './widget-style.css';\n\ntype ViewName = 'stores' | 'menu' | 'item' | 'cart' | 'review' | 'handoff';\n\ntype Store = {\n readonly id: string;\n readonly name: string;\n readonly cuisine: string;\n readonly address: string;\n readonly open: boolean;\n readonly etaMinutes: number;\n readonly rating: number;\n};\n\ntype MenuItem = {\n readonly id: string;\n readonly storeId: string;\n readonly category: string;\n readonly name: string;\n readonly price: number;\n readonly description: string;\n readonly modifiers: readonly string[];\n};\n\ntype CartLine = {\n readonly itemId: string;\n readonly quantity: number;\n readonly modifiers: readonly string[];\n readonly note?: string;\n};\n\ntype CartState = {\n readonly selectedStoreId?: string;\n readonly lines: readonly CartLine[];\n readonly customer: string;\n readonly notes?: string;\n readonly subtotal: number;\n readonly status: 'draft' | 'review' | 'handoff';\n readonly checkoutUrl?: string;\n};\n\nconst zeroCart: CartState = {\n lines: [],\n customer: 'Guest',\n subtotal: 0,\n status: 'draft',\n};\n\nconst useCartStore = createViewStore<CartState>('cart', zeroCart);\nconst useCartRevisionStore = createViewStore('cart_revision', { value: 0 });\n\nfunction structured<T>(value: unknown): T | undefined {\n return (value as { structuredContent?: T } | undefined)?.structuredContent;\n}\n\nfunction currency(value: number): string {\n return `$${value.toFixed(2)}`;\n}\n\nfunction modifierLabel(value: string): string {\n return value\n .split('_')\n .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n .join(' ');\n}\n\nexport default function OrderingFlow() {\n const { displayMode, supports, theme } = useLayout();\n const entry = structured<{\n readonly customer?: string;\n readonly stores?: readonly Store[];\n readonly featuredItems?: readonly MenuItem[];\n readonly status?: string;\n }>(useToolInfo('open_ordering'));\n const flow = useAppFlow<ViewName>({\n key: 'ordering_flow',\n initialView: 'stores',\n views: ['stores', 'menu', 'item', 'cart', 'review', 'handoff'],\n });\n const view = flow.activeView;\n const setView = flow.navigate;\n const handoff = useHandoff();\n const sendFollowUpMessage = useSendFollowUpMessage();\n const updateModelContext = useUpdateModelContext();\n const publishLifecycle = useWidgetLifecycle('ordering-flow');\n const searchStores = useCallTool('search_stores');\n const loadMenu = useCallTool('load_menu');\n const loadItem = useCallTool('load_item');\n const readCart = useCallTool('read_cart');\n const syncCart = useCallTool('sync_cart');\n const prepareCheckout = useCallTool('prepare_checkout');\n\n const [query, setQuery] = useViewState('query', '');\n const [customer, setCustomer] = useViewState('customer', entry?.customer ?? 'Guest');\n const [selectedStoreId, setSelectedStoreId] = useViewState<string | undefined>(\n 'selected_store',\n entry?.stores?.[0]?.id,\n );\n const [selectedItemId, setSelectedItemId] = useViewState<string | undefined>(\n 'selected_item',\n entry?.featuredItems?.[0]?.id,\n );\n const cartStore = useCartStore();\n const revisionStore = useCartRevisionStore((state) => state.value);\n const cart =\n cartStore.state.customer === 'Guest' && entry?.customer\n ? { ...cartStore.state, customer: entry.customer }\n : cartStore.state;\n const revision = revisionStore.selected;\n const setCart = cartStore.setState;\n const setRevision = (value: number) => revisionStore.setState({ value });\n const [quantity, setQuantity] = useState(1);\n const [selectedModifiers, setSelectedModifiers] = useState<readonly string[]>([]);\n const [lifecycle, setLifecycle] = useState<'active' | 'submitted'>('active');\n\n const storeData = structured<{ readonly stores?: readonly Store[] }>(searchStores.data);\n const menuData = structured<{\n readonly stores?: readonly Store[];\n readonly items?: readonly MenuItem[];\n }>(loadMenu.data);\n const itemData = structured<{ readonly items?: readonly MenuItem[] }>(loadItem.data);\n const checkoutData = structured<{ readonly checkoutUrl?: string; readonly cart?: CartState }>(\n prepareCheckout.data,\n );\n const stores = storeData?.stores ?? entry?.stores ?? [];\n const queryText = query.trim().toLowerCase();\n const displayedStores = stores.filter(\n (store) =>\n queryText.length === 0 ||\n store.name.toLowerCase().includes(queryText) ||\n store.cuisine.toLowerCase().includes(queryText),\n );\n const allItems = menuData?.items ?? itemData?.items ?? entry?.featuredItems ?? [];\n const items = allItems.filter((item) => item.storeId === selectedStoreId);\n const currentStore =\n stores.find((store) => store.id === selectedStoreId) ?? menuData?.stores?.[0] ?? stores[0];\n const currentItem = allItems.find((item) => item.id === selectedItemId) ?? items[0];\n const lineItems = useMemo(\n () =>\n cart.lines.map((line) => ({\n ...line,\n item: [...(entry?.featuredItems ?? []), ...allItems].find(\n (item) => item.id === line.itemId,\n ),\n })),\n [cart.lines, entry?.featuredItems, allItems],\n );\n const llmSummary = `${view} view for ${customer}; ${cart.lines.length} cart lines; subtotal ${currency(\n cart.subtotal,\n )}`;\n const canUpdateModelContext = supports?.modelContext === true;\n\n useEffect(() => {\n if (!canUpdateModelContext) return;\n // Each call replaces model context, so publish one cohesive snapshot of current surface state.\n void updateModelContext({\n content: [{ type: 'text', text: `Food ordering: ${llmSummary}; ${lifecycle}.` }],\n structuredContent: {\n widget: { name: 'ordering-flow', lifecycle },\n ordering: {\n view,\n customer,\n cartLines: cart.lines.length,\n subtotal: cart.subtotal,\n selectedStoreId: selectedStoreId ?? null,\n selectedItemId: selectedItemId ?? null,\n },\n },\n });\n }, [\n canUpdateModelContext,\n cart.lines.length,\n cart.subtotal,\n customer,\n lifecycle,\n llmSummary,\n selectedItemId,\n selectedStoreId,\n updateModelContext,\n view,\n ]);\n\n async function chooseStore(store: Store) {\n setSelectedStoreId(store.id);\n setView('menu');\n const result = await loadMenu.callTool({ storeId: store.id });\n const loaded = structured<{ readonly items?: readonly MenuItem[] }>(result);\n setSelectedItemId(loaded?.items?.find((item) => item.storeId === store.id)?.id);\n }\n\n async function chooseItem(item: MenuItem) {\n setSelectedItemId(item.id);\n setSelectedModifiers([]);\n setQuantity(1);\n setView('item');\n await loadItem.callTool({ itemId: item.id });\n }\n\n async function persistCart(nextCart: CartState, nextView: ViewName) {\n const result = await syncCart.callTool({\n selectedStoreId: nextCart.selectedStoreId,\n lines: nextCart.lines,\n customer: nextCart.customer,\n notes: nextCart.notes,\n subtotal: nextCart.subtotal,\n expectedRevision: revision,\n });\n const synced = structured<{ readonly cart?: CartState; readonly revision?: number }>(result);\n setCart(synced?.cart ?? nextCart);\n setRevision(synced?.revision ?? revision + 1);\n setView(nextView);\n }\n\n async function addCurrentItem() {\n if (!currentItem) return;\n const nextLines = [\n ...cart.lines,\n {\n itemId: currentItem.id,\n quantity,\n modifiers: selectedModifiers,\n },\n ];\n await persistCart(\n {\n ...cart,\n selectedStoreId: currentStore?.id ?? selectedStoreId,\n lines: nextLines,\n customer,\n subtotal: nextLines.reduce(\n (sum, line) =>\n sum +\n ([...(entry?.featuredItems ?? []), ...allItems].find((item) => item.id === line.itemId)\n ?.price ?? 0) *\n line.quantity,\n 0,\n ),\n status: 'draft',\n },\n 'cart',\n );\n }\n\n async function prepareHandoff() {\n const result = await prepareCheckout.callTool({\n selectedStoreId: cart.selectedStoreId,\n lines: cart.lines,\n customer,\n notes: cart.notes,\n subtotal: cart.subtotal,\n expectedRevision: revision,\n });\n const prepared = structured<{\n readonly cart?: CartState;\n readonly revision?: number;\n readonly checkoutUrl?: string;\n }>(result);\n const preparedCart = prepared?.cart ?? cart;\n setCart(preparedCart);\n setRevision(prepared?.revision ?? revision + 1);\n setLifecycle('submitted');\n setView('handoff');\n if (canUpdateModelContext) {\n await publishLifecycle('submitted', {\n view: 'handoff',\n customer,\n cartLines: preparedCart.lines.length,\n subtotal: preparedCart.subtotal,\n selectedStoreId: preparedCart.selectedStoreId ?? null,\n selectedItemId: selectedItemId ?? null,\n });\n }\n if (supports?.followUpMessage) {\n await sendFollowUpMessage({\n prompt: 'Checkout is prepared. Confirm the cart summary and explain the final handoff.',\n });\n }\n }\n\n return (\n <AppShell\n className={`nw-shell${theme === 'dark' ? ' dark' : ''}`}\n data-llm={llmSummary}\n title=\"Food Ordering\"\n subtitle={entry?.status ?? 'Find a store, build a cart, and hand off checkout.'}\n icon={<BowlIcon />}\n badge={displayMode === 'fullscreen' ? 'Fullscreen' : view}\n footer={\n <>\n <span className=\"nw-meta\">\n <BagIcon />\n {cart.lines.length} items\n </span>\n <span className=\"nw-meta\">Revision {revision}</span>\n </>\n }\n >\n <ShellNav\n activeView={view}\n aria-label=\"Ordering steps\"\n items={(['stores', 'menu', 'cart', 'review', 'handoff'] as const).map((step) => ({\n view: step,\n label: step,\n }))}\n onNavigate={setView}\n />\n <div className=\"nw-body\">\n <ViewStack flow={flow}>\n <View name=\"stores\">\n <div className=\"nw-field-grid\">\n <Field className=\"nw-field\" label=\"Customer\">\n <input\n className=\"nw-input\"\n value={customer}\n onChange={(event) => setCustomer(event.currentTarget.value)}\n />\n </Field>\n <Field className=\"nw-field\" label=\"Search\">\n <input\n className=\"nw-input\"\n value={query}\n placeholder=\"Noodles\"\n onChange={(event) => setQuery(event.currentTarget.value)}\n />\n </Field>\n </div>\n <ActionBar className=\"nw-actions\">\n <SubmitButton\n className=\"nw-button nw-button-primary\"\n pending={searchStores.isPending}\n pendingLabel=\"Searching...\"\n onClick={() => searchStores.callTool({ query, openOnly: false })}\n >\n <SearchIcon />\n Search stores\n </SubmitButton>\n <SubmitButton\n className=\"nw-button\"\n pending={readCart.isPending}\n pendingLabel=\"Loading...\"\n onClick={async () => {\n const result = await readCart.callTool({});\n const stored = structured<{\n readonly value?: CartState;\n readonly revision?: number;\n }>(result);\n if (stored?.value?.lines) setCart(stored.value);\n setRevision(stored?.revision ?? revision);\n }}\n >\n <RefreshIcon />\n Load cart\n </SubmitButton>\n </ActionBar>\n <AsyncBoundary\n state={searchStores}\n isEmpty={displayedStores.length === 0}\n empty=\"No matching stores\"\n >\n <StoreList stores={displayedStores} onChoose={chooseStore} />\n </AsyncBoundary>\n </View>\n\n <View name=\"menu\">\n <SectionHeader title={currentStore?.name ?? 'Menu'} detail={currentStore?.address} />\n <AsyncBoundary\n state={loadMenu}\n isEmpty={items.length === 0}\n empty=\"No menu items loaded\"\n >\n <ItemList items={items} onChoose={chooseItem} />\n </AsyncBoundary>\n </View>\n\n <View name=\"item\">\n {currentItem ? (\n <>\n <SectionHeader title={currentItem.name} detail={currentItem.description} />\n <div className=\"nw-field-grid\">\n <Field className=\"nw-field\" label=\"Quantity\">\n <QuantityStepper value={quantity} min={1} onChange={setQuantity} />\n </Field>\n <div className=\"nw-summary nw-summary-compact\">\n <div className=\"nw-summary-row\">\n <dt>Price</dt>\n <dd>{currency(currentItem.price)}</dd>\n </div>\n </div>\n </div>\n <ChoiceGroup\n className=\"nw-modifier-grid\"\n values={currentItem.modifiers}\n selected={selectedModifiers}\n onChange={setSelectedModifiers}\n labelFor={modifierLabel}\n />\n <ActionBar className=\"nw-actions\">\n <SubmitButton\n className=\"nw-button nw-button-primary\"\n pending={syncCart.isPending}\n pendingLabel=\"Adding...\"\n onClick={addCurrentItem}\n >\n <BagIcon />\n Add to cart\n </SubmitButton>\n <button className=\"nw-button\" type=\"button\" onClick={() => setView('menu')}>\n Back to menu\n </button>\n </ActionBar>\n </>\n ) : null}\n </View>\n\n <View name=\"cart\">\n <SectionHeader\n title=\"Cart\"\n detail={`${cart.lines.length} line${cart.lines.length === 1 ? '' : 's'}`}\n />\n <CartSummary lineItems={lineItems} subtotal={cart.subtotal} />\n <Field className=\"nw-field\" label=\"Notes\">\n <input\n className=\"nw-input\"\n value={cart.notes ?? ''}\n placeholder=\"Utensils, pickup name, or allergies\"\n onChange={(event) => setCart({ ...cart, notes: event.currentTarget.value })}\n />\n </Field>\n <ActionBar className=\"nw-actions\">\n <SubmitButton\n className=\"nw-button\"\n pending={syncCart.isPending}\n pendingLabel=\"Saving...\"\n onClick={() => persistCart({ ...cart, customer, status: 'draft' }, 'review')}\n >\n <SaveIcon />\n Save cart\n </SubmitButton>\n <SubmitButton\n className=\"nw-button nw-button-primary\"\n disabled={cart.lines.length === 0}\n pending={prepareCheckout.isPending}\n pendingLabel=\"Preparing...\"\n onClick={prepareHandoff}\n >\n <ExternalIcon />\n Prepare checkout\n </SubmitButton>\n </ActionBar>\n </View>\n\n <View name=\"review\">\n <SectionHeader\n title=\"Review order\"\n detail={`${cart.lines.length} line${cart.lines.length === 1 ? '' : 's'}`}\n />\n <CartSummary lineItems={lineItems} subtotal={cart.subtotal} />\n <ActionBar className=\"nw-actions\">\n <button className=\"nw-button\" type=\"button\" onClick={() => setView('cart')}>\n Edit cart\n </button>\n <SubmitButton\n className=\"nw-button nw-button-primary\"\n disabled={cart.lines.length === 0}\n pending={prepareCheckout.isPending}\n pendingLabel=\"Preparing...\"\n onClick={prepareHandoff}\n >\n <ExternalIcon />\n Prepare checkout\n </SubmitButton>\n </ActionBar>\n </View>\n\n <View name=\"handoff\">\n <SectionHeader\n title=\"Checkout handoff\"\n detail=\"Payment stays with the ordering site.\"\n />\n <CartSummary lineItems={lineItems} subtotal={cart.subtotal} />\n <p className=\"nw-note\">\n The MCP App prepared a synthetic checkout URL and will open only the allowlisted\n example domain.\n </p>\n <ActionBar className=\"nw-actions\">\n <HandoffButton\n handoff={handoff}\n target={checkoutData?.checkoutUrl ?? cart.checkoutUrl ?? ''}\n className=\"nw-button nw-button-primary\"\n type=\"button\"\n pendingLabel=\"Opening...\"\n >\n <ExternalIcon />\n Continue checkout\n </HandoffButton>\n <button\n className=\"nw-button\"\n type=\"button\"\n onClick={() =>\n sendFollowUpMessage({\n prompt: `Summarize my food order for ${customer} before checkout.`,\n })\n }\n >\n <SparkIcon />\n Ask assistant\n </button>\n </ActionBar>\n </View>\n </ViewStack>\n </div>\n </AppShell>\n );\n}\n\nfunction SectionHeader({ title, detail }: { readonly title: string; readonly detail?: string }) {\n return (\n <div className=\"nw-section-head\">\n <p className=\"nw-section-title\">{title}</p>\n {detail ? <p className=\"nw-section-detail\">{detail}</p> : null}\n </div>\n );\n}\n\nfunction StoreList({\n stores,\n onChoose,\n}: {\n readonly stores: readonly Store[];\n readonly onChoose: (store: Store) => void;\n}) {\n return (\n <DataList className=\"nw-menu-list\">\n {stores.map((store) => (\n <DataCard\n as=\"button\"\n className=\"nw-menu-item\"\n key={store.id}\n type=\"button\"\n onClick={() => onChoose(store)}\n >\n <span>\n <span className=\"nw-menu-name\">{store.name}</span>\n <span className=\"nw-menu-desc\">\n {store.cuisine} · {store.address}\n </span>\n </span>\n <StatusBadge className=\"nw-status\" tone={store.open ? 'success' : 'neutral'}>\n {store.open ? `${store.etaMinutes}m` : 'Closed'}\n </StatusBadge>\n </DataCard>\n ))}\n </DataList>\n );\n}\n\nfunction ItemList({\n items,\n onChoose,\n}: {\n readonly items: readonly MenuItem[];\n readonly onChoose: (item: MenuItem) => void;\n}) {\n return (\n <DataList className=\"nw-menu-list\">\n {items.map((item) => (\n <DataCard\n as=\"button\"\n className=\"nw-menu-item\"\n key={item.id}\n type=\"button\"\n onClick={() => onChoose(item)}\n >\n <span>\n <span className=\"nw-menu-name\">{item.name}</span>\n <span className=\"nw-menu-desc\">{item.description}</span>\n </span>\n <span className=\"nw-price\">{currency(item.price)}</span>\n </DataCard>\n ))}\n </DataList>\n );\n}\n\nfunction CartSummary({\n lineItems,\n subtotal,\n}: {\n readonly lineItems: readonly (CartLine & { readonly item?: MenuItem })[];\n readonly subtotal: number;\n}) {\n return (\n <dl className=\"nw-summary\">\n {lineItems.length === 0 ? (\n <div className=\"nw-summary-row\">\n <dt>No items yet</dt>\n <dd>{currency(0)}</dd>\n </div>\n ) : (\n lineItems.map((line, index) => (\n <div className=\"nw-summary-row\" key={`${line.itemId}-${index}`}>\n <dt>\n {line.quantity} x {line.item?.name ?? line.itemId}\n {line.modifiers.length > 0 ? (\n <span className=\"nw-line-note\">{line.modifiers.map(modifierLabel).join(', ')}</span>\n ) : null}\n </dt>\n <dd>{currency((line.item?.price ?? 0) * line.quantity)}</dd>\n </div>\n ))\n )}\n <div className=\"nw-summary-row nw-total\">\n <dt>Subtotal</dt>\n <dd>{currency(subtotal)}</dd>\n </div>\n </dl>\n );\n}\n\nfunction BowlIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M4 11h16a7 7 0 0 1-7 7h-2a7 7 0 0 1-7-7Z\" />\n <path d=\"M7 21h10\" />\n <path d=\"M8 6c0-1 1-1 1-2\" />\n <path d=\"M12 6c0-1 1-1 1-2\" />\n <path d=\"M16 6c0-1 1-1 1-2\" />\n </svg>\n );\n}\n\nfunction SearchIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <circle cx=\"11\" cy=\"11\" r=\"7\" />\n <path d=\"m20 20-4-4\" />\n </svg>\n );\n}\n\nfunction RefreshIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M20 12a8 8 0 1 1-2.3-5.6\" />\n <path d=\"M20 4v5h-5\" />\n </svg>\n );\n}\n\nfunction BagIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M6 8h12l-1 12H7L6 8Z\" />\n <path d=\"M9 8a3 3 0 0 1 6 0\" />\n </svg>\n );\n}\n\nfunction SaveIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M5 4h12l2 2v14H5V4Z\" />\n <path d=\"M8 4v6h8\" />\n <path d=\"M8 20v-5h8v5\" />\n </svg>\n );\n}\n\nfunction ExternalIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M14 4h6v6\" />\n <path d=\"m20 4-9 9\" />\n <path d=\"M20 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5\" />\n </svg>\n );\n}\n\nfunction SparkIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"m12 3 1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8L12 3Z\" />\n </svg>\n );\n}\n" },
64
64
  { relPath: "examples/food-ordering/src/views/widget-style.css", content: ":root {\n color-scheme: light dark;\n font-family:\n Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n --nw-bg: #ffffff;\n --nw-surface: #f7f7f5;\n --nw-surface-strong: #ffffff;\n --nw-text: #1f2328;\n --nw-muted: #677079;\n --nw-border: #d9dee3;\n --nw-accent: #0f8f5f;\n --nw-accent-strong: #047857;\n --nw-accent-soft: #e8f7ef;\n --nw-info: #1d6feb;\n --nw-info-soft: #eaf2ff;\n --nw-warn: #b7791f;\n --nw-danger: #c2410c;\n --nw-shadow: 0 18px 50px rgb(15 23 42 / 12%);\n --nw-radius: 8px;\n}\n\n.dark,\n[data-theme=\"dark\"] {\n --nw-bg: #111820;\n --nw-surface: #16212a;\n --nw-surface-strong: #101820;\n --nw-text: #f4f7f8;\n --nw-muted: #a4b0ba;\n --nw-border: #33414c;\n --nw-accent: #25c385;\n --nw-accent-strong: #19a974;\n --nw-accent-soft: #123a2b;\n --nw-info: #4c9aff;\n --nw-info-soft: #132b4d;\n --nw-warn: #f0b35b;\n --nw-danger: #fb7b54;\n --nw-shadow: 0 18px 50px rgb(0 0 0 / 28%);\n}\n\n* {\n box-sizing: border-box;\n}\n\nbody {\n margin: 0;\n background: var(--nw-bg);\n color: var(--nw-text);\n}\n\nbutton,\ninput,\nselect {\n font: inherit;\n}\n\n.nw-shell {\n min-height: 100vh;\n padding: 14px;\n background: var(--nw-bg);\n color: var(--nw-text);\n}\n\n.nw-card {\n width: min(100%, 560px);\n margin: 0 auto;\n border: 1px solid var(--nw-border);\n border-radius: var(--nw-radius);\n background: var(--nw-surface-strong);\n box-shadow: var(--nw-shadow);\n overflow: hidden;\n}\n\n.nw-card-wide {\n width: min(100%, 720px);\n}\n\n.nsr-card {\n width: min(100%, 560px);\n margin: 0 auto;\n border: 1px solid var(--nw-border);\n border-radius: var(--nw-radius);\n background: var(--nw-surface-strong);\n box-shadow: var(--nw-shadow);\n overflow: hidden;\n}\n\n.nsr-card-wide {\n width: min(100%, 720px);\n}\n\n.nw-header {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 14px;\n border-bottom: 1px solid var(--nw-border);\n}\n\n.nsr-header {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 14px;\n border-bottom: 1px solid var(--nw-border);\n}\n\n.nw-icon {\n display: inline-grid;\n width: 32px;\n height: 32px;\n flex: 0 0 auto;\n place-items: center;\n border-radius: 8px;\n background: linear-gradient(145deg, var(--nw-accent), var(--nw-accent-strong));\n color: white;\n}\n\n.nsr-icon {\n display: inline-grid;\n width: 32px;\n height: 32px;\n flex: 0 0 auto;\n place-items: center;\n border-radius: 8px;\n background: linear-gradient(145deg, var(--nw-accent), var(--nw-accent-strong));\n color: white;\n}\n\n.nw-icon svg,\n.nsr-icon svg,\n.nw-button svg,\n.nw-meta svg {\n width: 16px;\n height: 16px;\n stroke: currentColor;\n stroke-width: 2;\n fill: none;\n stroke-linecap: round;\n stroke-linejoin: round;\n}\n\n.nw-title-block {\n min-width: 0;\n flex: 1;\n}\n\n.nsr-title-block {\n min-width: 0;\n flex: 1;\n}\n\n.nw-title {\n margin: 0;\n font-size: 16px;\n line-height: 1.2;\n font-weight: 700;\n letter-spacing: 0;\n}\n\n.nsr-title {\n margin: 0;\n font-size: 16px;\n line-height: 1.2;\n font-weight: 700;\n letter-spacing: 0;\n}\n\n.nw-subtitle {\n margin: 4px 0 0;\n color: var(--nw-muted);\n font-size: 12px;\n line-height: 1.35;\n}\n\n.nsr-subtitle {\n margin: 4px 0 0;\n color: var(--nw-muted);\n font-size: 12px;\n line-height: 1.35;\n}\n\n.nw-chip {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n min-height: 24px;\n padding: 3px 9px;\n border: 1px solid var(--nw-border);\n border-radius: 999px;\n background: var(--nw-surface);\n color: var(--nw-text);\n font-size: 12px;\n font-weight: 600;\n white-space: nowrap;\n}\n\n.nsr-chip {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n min-height: 24px;\n padding: 3px 9px;\n border: 1px solid var(--nw-border);\n border-radius: 999px;\n background: var(--nw-surface);\n color: var(--nw-text);\n font-size: 12px;\n font-weight: 600;\n white-space: nowrap;\n}\n\n.nw-chip::before {\n width: 7px;\n height: 7px;\n border-radius: 99px;\n background: var(--nw-accent);\n content: \"\";\n}\n\n.nsr-chip::before {\n width: 7px;\n height: 7px;\n border-radius: 99px;\n background: var(--nw-accent);\n content: \"\";\n}\n\n.nw-tabs {\n display: grid;\n grid-template-columns: repeat(5, minmax(0, 1fr));\n gap: 1px;\n border-bottom: 1px solid var(--nw-border);\n background: var(--nw-border);\n}\n\n.nsr-tabs {\n display: grid;\n grid-template-columns: repeat(5, minmax(0, 1fr));\n gap: 1px;\n border-bottom: 1px solid var(--nw-border);\n background: var(--nw-border);\n}\n\n.nw-tab {\n min-width: 0;\n min-height: 34px;\n border: 0;\n background: var(--nw-surface-strong);\n color: var(--nw-muted);\n font-size: 12px;\n font-weight: 700;\n text-transform: capitalize;\n cursor: pointer;\n}\n\n.nsr-tab {\n min-width: 0;\n min-height: 34px;\n border: 0;\n background: var(--nw-surface-strong);\n color: var(--nw-muted);\n font-size: 12px;\n font-weight: 700;\n text-transform: capitalize;\n cursor: pointer;\n}\n\n.nw-tab[aria-current=\"step\"] {\n color: var(--nw-text);\n background: var(--nw-accent-soft);\n}\n\n.nsr-tab[aria-current=\"step\"] {\n color: var(--nw-text);\n background: var(--nw-accent-soft);\n}\n\n.nw-body {\n display: grid;\n gap: 14px;\n padding: 14px;\n}\n\n.nw-grid {\n display: grid;\n grid-template-columns: minmax(0, 1fr) minmax(180px, 0.75fr);\n gap: 14px;\n}\n\n.nw-section-title {\n margin: 0 0 8px;\n font-size: 12px;\n font-weight: 700;\n}\n\n.nw-section-head {\n display: grid;\n gap: 2px;\n}\n\n.nw-section-detail {\n margin: 0;\n color: var(--nw-muted);\n font-size: 12px;\n}\n\n.nw-menu-list,\n.nw-summary-list,\n.nw-feature-list {\n display: grid;\n gap: 8px;\n margin: 0;\n padding: 0;\n list-style: none;\n}\n\n.nw-menu-item {\n display: grid;\n grid-template-columns: minmax(0, 1fr) auto;\n gap: 10px;\n width: 100%;\n padding: 10px;\n border: 1px solid var(--nw-border);\n border-radius: var(--nw-radius);\n background: var(--nw-bg);\n color: var(--nw-text);\n text-align: left;\n cursor: pointer;\n}\n\n.nw-menu-item[aria-pressed=\"true\"] {\n border-color: var(--nw-accent);\n background: var(--nw-accent-soft);\n}\n\n.nw-menu-name,\n.nw-feature-name {\n display: block;\n font-size: 13px;\n font-weight: 700;\n}\n\n.nw-menu-desc,\n.nw-feature-desc {\n display: block;\n margin-top: 3px;\n color: var(--nw-muted);\n font-size: 12px;\n line-height: 1.35;\n}\n\n.nw-price {\n align-self: center;\n color: var(--nw-text);\n font-size: 13px;\n font-weight: 700;\n}\n\n.nw-status {\n align-self: center;\n color: var(--nw-muted);\n font-size: 12px;\n font-weight: 800;\n white-space: nowrap;\n}\n\n.nw-status-ok {\n color: var(--nw-accent-strong);\n}\n\n.nw-field-grid {\n display: grid;\n grid-template-columns: 1fr 120px;\n gap: 10px;\n}\n\n.nw-field {\n display: grid;\n gap: 5px;\n color: var(--nw-muted);\n font-size: 12px;\n font-weight: 650;\n}\n\n.nsr-field-label {\n color: var(--nw-muted);\n}\n\n.nsr-stepper {\n display: grid;\n grid-template-columns: 34px minmax(42px, 1fr) 34px;\n width: 100%;\n min-height: 34px;\n border: 1px solid var(--nw-border);\n border-radius: 7px;\n overflow: hidden;\n background: var(--nw-bg);\n}\n\n.nsr-stepper button {\n border: 0;\n background: var(--nw-surface);\n color: var(--nw-text);\n cursor: pointer;\n}\n\n.nsr-stepper button:disabled {\n cursor: not-allowed;\n opacity: 0.5;\n}\n\n.nsr-stepper-value {\n display: grid;\n place-items: center;\n color: var(--nw-text);\n font-size: 13px;\n font-weight: 800;\n}\n\n.nw-input,\n.nw-select {\n min-width: 0;\n width: 100%;\n min-height: 34px;\n padding: 7px 9px;\n border: 1px solid var(--nw-border);\n border-radius: 7px;\n background: var(--nw-bg);\n color: var(--nw-text);\n outline: none;\n}\n\n.nw-input:focus,\n.nw-select:focus,\n.nw-button:focus-visible,\n.nw-menu-item:focus-visible {\n border-color: var(--nw-info);\n box-shadow: 0 0 0 3px color-mix(in srgb, var(--nw-info) 24%, transparent);\n}\n\n.nw-modifier-grid {\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: 8px;\n}\n\n.nw-option {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 9px;\n border: 1px solid var(--nw-border);\n border-radius: 7px;\n background: var(--nw-bg);\n color: var(--nw-text);\n font-size: 12px;\n font-weight: 650;\n}\n\n.nsr-choice {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 9px;\n border: 1px solid var(--nw-border);\n border-radius: 7px;\n background: var(--nw-bg);\n color: var(--nw-text);\n font-size: 12px;\n font-weight: 650;\n}\n\n.nw-summary {\n display: grid;\n gap: 8px;\n padding: 10px;\n border: 1px solid var(--nw-border);\n border-radius: var(--nw-radius);\n background: var(--nw-surface);\n}\n\n.nw-summary-compact {\n align-self: end;\n}\n\n.nw-summary-row {\n display: flex;\n justify-content: space-between;\n gap: 12px;\n padding-bottom: 8px;\n border-bottom: 1px solid color-mix(in srgb, var(--nw-border) 70%, transparent);\n}\n\n.nw-summary-row:last-child {\n padding-bottom: 0;\n border-bottom: 0;\n}\n\n.nw-summary-row dt {\n color: var(--nw-muted);\n font-size: 12px;\n}\n\n.nw-line-note {\n display: block;\n margin-top: 2px;\n font-size: 11px;\n font-weight: 500;\n}\n\n.nw-summary-row dd {\n margin: 0;\n text-align: right;\n font-size: 13px;\n font-weight: 700;\n}\n\n.nw-total dd {\n font-size: 18px;\n}\n\n.nw-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n}\n\n.nsr-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n}\n\n.nw-button {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n min-height: 36px;\n padding: 8px 12px;\n border: 1px solid var(--nw-border);\n border-radius: 7px;\n background: var(--nw-bg);\n color: var(--nw-text);\n font-size: 13px;\n font-weight: 700;\n cursor: pointer;\n}\n\n.nw-button-primary {\n border-color: var(--nw-accent-strong);\n background: linear-gradient(145deg, var(--nw-accent), var(--nw-accent-strong));\n color: white;\n}\n\n.nw-button-info {\n border-color: var(--nw-info);\n background: var(--nw-info);\n color: white;\n}\n\n.nw-button:disabled {\n cursor: wait;\n opacity: 0.65;\n}\n\n.nw-note {\n margin: 0;\n padding: 10px;\n border: 1px solid var(--nw-border);\n border-radius: var(--nw-radius);\n background: var(--nw-surface);\n color: var(--nw-muted);\n font-size: 12px;\n line-height: 1.45;\n}\n\n.nw-footer {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n gap: 8px;\n padding: 10px 14px;\n border-top: 1px solid var(--nw-border);\n color: var(--nw-muted);\n font-size: 12px;\n}\n\n.nsr-footer {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n gap: 8px;\n padding: 10px 14px;\n border-top: 1px solid var(--nw-border);\n color: var(--nw-muted);\n font-size: 12px;\n}\n\n.nw-meta {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n}\n\n.nw-feature {\n display: grid;\n grid-template-columns: 24px minmax(0, 1fr);\n gap: 10px;\n padding: 10px;\n border: 1px solid var(--nw-border);\n border-radius: var(--nw-radius);\n background: var(--nw-bg);\n}\n\n.nw-check {\n display: inline-grid;\n width: 22px;\n height: 22px;\n place-items: center;\n border-radius: 6px;\n background: var(--nw-accent);\n color: white;\n font-size: 13px;\n font-weight: 800;\n}\n\n.nw-check svg {\n width: 14px;\n height: 14px;\n stroke: currentColor;\n stroke-width: 3;\n fill: none;\n stroke-linecap: round;\n stroke-linejoin: round;\n}\n\n@media (max-width: 560px) {\n .nw-shell {\n padding: 10px;\n }\n\n .nw-grid,\n .nw-field-grid,\n .nw-modifier-grid {\n grid-template-columns: 1fr;\n }\n\n .nw-tabs {\n grid-template-columns: repeat(3, minmax(0, 1fr));\n }\n\n .nsr-tabs {\n grid-template-columns: repeat(3, minmax(0, 1fr));\n }\n\n .nw-header {\n align-items: flex-start;\n }\n\n .nsr-header {\n align-items: flex-start;\n }\n\n .nw-chip {\n max-width: 120px;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n .nsr-chip {\n max-width: 120px;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n .nw-button {\n flex: 1 1 140px;\n }\n}\n" },
@@ -67,7 +67,7 @@ export const BUNDLED_EXAMPLE_FILES = [
67
67
  { relPath: "examples/gmail-multi-account/README.md", content: "# Gmail multi-account automation\n\n**Owns:** The flagship proof that one reusable connector can be bound to multiple independently\nauthenticated accounts inside one MCP server.\n\nThis fictional example binds `gmailConnector()` twice through separate `externalExchange()` logical\nconnections. Public tools always accept `accounts: [...]`; reads accept either account or the canonical\npersonal-then-work pair, while mutations accept exactly one account and require runtime confirmation.\n\nCapability slot: **reusable connector + independently authenticated multi-account bindings**. It is distinct\nfrom `customer-auth`, which owns authentication of the MCP caller rather than downstream connector accounts.\n\nThe labels `personal@example.com` and `work@example.com` are static display labels, not provider identities.\nThe deployment-owned credential provider maps each logical connection to its real Google authorization.\nNo Google client, provider account id, token, or real email address belongs in this project.\n\n## Safety and API boundary\n\n- Search, message/thread reads, draft reads, and vacation-setting reads may target one or both accounts.\n- Draft creation/update/send, label changes, archive, raw send, trash, and vacation updates target one account.\n- Every mutation is prepared against the exact selected binding and must be confirmed before dispatch.\n- `send_message.raw` and draft `raw` are RFC 2822 MIME bytes encoded with base64url. This example does not\n pretend that `to`/`subject`/`body` strings are sufficient to encode Unicode MIME correctly.\n- Vacation `startTime`/`endTime` schemas enforce only digit-shaped 1–19 character epoch-millisecond strings.\n When both are supplied, Gmail's backend remains authoritative for the required `startTime < endTime`\n relationship; this example does not claim cross-field JSON Schema validation.\n- Trash is reversible. Permanent message/thread/draft deletion, delegation, forwarding/sharing settings,\n and unrestricted raw HTTP requests are intentionally absent.\n\n## Local checks\n\n```sh\nnoodle validate\nnoodle test\n```\n\nThe committed tests compile hermetic fake connector responses. They never contact Gmail or load OAuth\ncredentials. A real deployment additionally needs an operator-provided external credential exchange\nendpoint for each logical connection.\n\n## Personal automation skill\n\nThe source skill is [`skills/personal-email-automation/SKILL.md`](skills/personal-email-automation/SKILL.md).\nValidate it with the standard skill validator before distribution. The source skill is shipped as part of\nthis example; canonical export of an app and its skill as an installable Codex plugin remains roadmap work\nand is not currently provided by Noodle Seed.\n" },
68
68
  { relPath: "examples/gmail-multi-account/noodle.json", content: "{\n \"entrypoint\": \"src/server.ts\",\n \"name\": \"gmail-multi-account\"\n}\n" },
69
69
  { relPath: "examples/gmail-multi-account/package.json", content: "{\n \"name\": \"gmail-multi-account\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {\n \"test\": \"vitest run\",\n \"validate\": \"noodle validate\",\n \"dev\": \"noodle dev\",\n \"deploy\": \"noodle deploy\"\n },\n \"devDependencies\": {\n \"@noodleseed/one\": \"latest\",\n \"vitest\": \"latest\"\n }\n}\n" },
70
- { relPath: "examples/gmail-multi-account/src/server.ts", content: "import {\n annotations,\n bind,\n connection,\n connector,\n externalExchange,\n gmailConnector,\n server,\n tool,\n when,\n z,\n} from '@noodleseed/one';\n\nexport const PERSONAL_ACCOUNT = 'personal@example.com';\nexport const WORK_ACCOUNT = 'work@example.com';\n\nconst readAccounts = z.union([\n z.tuple([z.literal(PERSONAL_ACCOUNT)]).and(z.array(z.literal(PERSONAL_ACCOUNT)).length(1)),\n z.tuple([z.literal(WORK_ACCOUNT)]).and(z.array(z.literal(WORK_ACCOUNT)).length(1)),\n z\n .tuple([z.literal(PERSONAL_ACCOUNT), z.literal(WORK_ACCOUNT)])\n .and(z.array(z.enum([PERSONAL_ACCOUNT, WORK_ACCOUNT])).length(2)),\n]);\nconst writeAccounts = z.union([\n z.tuple([z.literal(PERSONAL_ACCOUNT)]).and(z.array(z.literal(PERSONAL_ACCOUNT)).length(1)),\n z.tuple([z.literal(WORK_ACCOUNT)]).and(z.array(z.literal(WORK_ACCOUNT)).length(1)),\n]);\nconst identifier = z.string().min(1);\nconst rawMessage = z.string().min(1);\nconst labelIds = z.array(identifier).min(1).max(100);\nconst epochMillis = z.string().regex(/^\\d{1,19}$/);\nconst vacationOptionalFields = {\n restrict_to_contacts: z.boolean().optional(),\n restrict_to_domain: z.boolean().optional(),\n start_time: epochMillis.optional(),\n end_time: epochMillis.optional(),\n};\nconst output = z.object({\n results: z.array(\n z.object({ account: z.enum([PERSONAL_ACCOUNT, WORK_ACCOUNT]), data: z.unknown() }),\n ),\n});\n\nconst gmail = gmailConnector();\nconst personal = connection('personal_gmail', externalExchange());\nconst work = connection('work_gmail', externalExchange());\n\nconst merge = connector('gmail_account_results')\n .version('1.0.0')\n .compute('merge', {\n type: 'read',\n input: z.object({\n personal: z.unknown().optional(),\n work_first: z.unknown().optional(),\n work_second: z.unknown().optional(),\n }),\n output,\n run: (input) => {\n const results: Array<{ account: string; data: unknown }> = [];\n if (input.personal !== undefined) {\n results.push({ account: 'personal@example.com', data: input.personal });\n }\n const workData = input.work_first ?? input.work_second;\n if (workData !== undefined) {\n results.push({ account: 'work@example.com', data: workData });\n }\n return { results };\n },\n });\n\nconst confirmedWrite = annotations.openAction({ destructive: false, confirm: true });\nconst confirmedTrash = annotations.openAction({ destructive: true, confirm: true });\n\nexport default server(\n 'gmail_multi_account',\n {\n title: 'Gmail Multi-Account Automation',\n version: '1.0.0',\n use: {\n personal_gmail: bind(gmail, { profile: 'user_oauth', connection: personal }),\n work_gmail: bind(gmail, { profile: 'user_oauth', connection: work }),\n gmail_results: merge,\n },\n },\n [\n tool('search_messages', {\n description: 'Search one connected Gmail account or the canonical personal-and-work pair.',\n input: z.object({\n accounts: readAccounts,\n query: z.string(),\n max_results: z.number().int().min(1).max(500).optional(),\n }),\n output,\n fulfil: ({ input, connectors }) => {\n const personal = when(input.accounts.at(0).equals(PERSONAL_ACCOUNT), () =>\n connectors.personal_gmail.search_messages({\n q: input.query,\n maxResults: input.max_results,\n }),\n );\n const workFirst = when(input.accounts.at(0).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.search_messages({\n q: input.query,\n maxResults: input.max_results,\n }),\n );\n const workSecond = when(input.accounts.at(1).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.search_messages({\n q: input.query,\n maxResults: input.max_results,\n }),\n );\n const merged = connectors.gmail_results.merge({\n personal: personal.data.optional(),\n work_first: workFirst.data.optional(),\n work_second: workSecond.data.optional(),\n });\n return { results: merged.results };\n },\n }),\n tool('get_message', {\n description: 'Get one Gmail message from one connected account or both canonical accounts.',\n input: z.object({\n accounts: readAccounts,\n message_id: identifier,\n format: z.enum(['minimal', 'full', 'raw', 'metadata']).optional(),\n }),\n output,\n fulfil: ({ input, connectors }) => {\n const personal = when(input.accounts.at(0).equals(PERSONAL_ACCOUNT), () =>\n connectors.personal_gmail.get_message({\n message_id: input.message_id,\n format: input.format,\n }),\n );\n const workFirst = when(input.accounts.at(0).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.get_message({\n message_id: input.message_id,\n format: input.format,\n }),\n );\n const workSecond = when(input.accounts.at(1).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.get_message({\n message_id: input.message_id,\n format: input.format,\n }),\n );\n const merged = connectors.gmail_results.merge({\n personal: personal.data.optional(),\n work_first: workFirst.data.optional(),\n work_second: workSecond.data.optional(),\n });\n return { results: merged.results };\n },\n }),\n tool('get_thread', {\n description: 'Get one Gmail thread from one connected account or both canonical accounts.',\n input: z.object({\n accounts: readAccounts,\n thread_id: identifier,\n format: z.enum(['minimal', 'full', 'metadata']).optional(),\n }),\n output,\n fulfil: ({ input, connectors }) => {\n const personal = when(input.accounts.at(0).equals(PERSONAL_ACCOUNT), () =>\n connectors.personal_gmail.get_thread({\n thread_id: input.thread_id,\n format: input.format,\n }),\n );\n const workFirst = when(input.accounts.at(0).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.get_thread({\n thread_id: input.thread_id,\n format: input.format,\n }),\n );\n const workSecond = when(input.accounts.at(1).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.get_thread({\n thread_id: input.thread_id,\n format: input.format,\n }),\n );\n const merged = connectors.gmail_results.merge({\n personal: personal.data.optional(),\n work_first: workFirst.data.optional(),\n work_second: workSecond.data.optional(),\n });\n return { results: merged.results };\n },\n }),\n tool('list_drafts', {\n description: 'List drafts from one connected Gmail account or both canonical accounts.',\n input: z.object({\n accounts: readAccounts,\n query: z.string().optional(),\n max_results: z.number().int().min(1).max(500).optional(),\n }),\n output,\n fulfil: ({ input, connectors }) => {\n const personal = when(input.accounts.at(0).equals(PERSONAL_ACCOUNT), () =>\n connectors.personal_gmail.list_drafts({\n q: input.query,\n maxResults: input.max_results,\n }),\n );\n const workFirst = when(input.accounts.at(0).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.list_drafts({\n q: input.query,\n maxResults: input.max_results,\n }),\n );\n const workSecond = when(input.accounts.at(1).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.list_drafts({\n q: input.query,\n maxResults: input.max_results,\n }),\n );\n const merged = connectors.gmail_results.merge({\n personal: personal.data.optional(),\n work_first: workFirst.data.optional(),\n work_second: workSecond.data.optional(),\n });\n return { results: merged.results };\n },\n }),\n tool('get_draft', {\n description: 'Get one draft from one connected Gmail account or both canonical accounts.',\n input: z.object({\n accounts: readAccounts,\n draft_id: identifier,\n format: z.enum(['minimal', 'full', 'raw', 'metadata']).optional(),\n }),\n output,\n fulfil: ({ input, connectors }) => {\n const personal = when(input.accounts.at(0).equals(PERSONAL_ACCOUNT), () =>\n connectors.personal_gmail.get_draft({\n draft_id: input.draft_id,\n format: input.format,\n }),\n );\n const workFirst = when(input.accounts.at(0).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.get_draft({\n draft_id: input.draft_id,\n format: input.format,\n }),\n );\n const workSecond = when(input.accounts.at(1).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.get_draft({\n draft_id: input.draft_id,\n format: input.format,\n }),\n );\n const merged = connectors.gmail_results.merge({\n personal: personal.data.optional(),\n work_first: workFirst.data.optional(),\n work_second: workSecond.data.optional(),\n });\n return { results: merged.results };\n },\n }),\n tool('get_vacation', {\n description: 'Read vacation-responder settings from one connected account or both accounts.',\n input: z.object({ accounts: readAccounts }),\n output,\n fulfil: ({ input, connectors }) => {\n const personal = when(input.accounts.at(0).equals(PERSONAL_ACCOUNT), () =>\n connectors.personal_gmail.get_vacation({}),\n );\n const workFirst = when(input.accounts.at(0).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.get_vacation({}),\n );\n const workSecond = when(input.accounts.at(1).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.get_vacation({}),\n );\n const merged = connectors.gmail_results.merge({\n personal: personal.data.optional(),\n work_first: workFirst.data.optional(),\n work_second: workSecond.data.optional(),\n });\n return { results: merged.results };\n },\n }),\n tool('create_draft', {\n description:\n 'Create a Gmail draft in exactly one selected account from a base64url MIME message.',\n annotations: confirmedWrite,\n input: z.object({ accounts: writeAccounts, raw: rawMessage }),\n output,\n fulfil: ({ input, connectors }) => {\n const personal = when(input.accounts.at(0).equals(PERSONAL_ACCOUNT), () =>\n connectors.personal_gmail.create_draft({ raw: input.raw }),\n );\n const work = when(input.accounts.at(0).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.create_draft({ raw: input.raw }),\n );\n const merged = connectors.gmail_results.merge({\n personal: personal.data.optional(),\n work_first: work.data.optional(),\n });\n return { results: merged.results };\n },\n }),\n tool('update_draft', {\n description:\n 'Replace a Gmail draft in exactly one selected account with a base64url MIME message.',\n annotations: confirmedWrite,\n input: z.object({ accounts: writeAccounts, draft_id: identifier, raw: rawMessage }),\n output,\n fulfil: ({ input, connectors }) => {\n const args = { draft_id: input.draft_id, raw: input.raw };\n const personal = when(input.accounts.at(0).equals(PERSONAL_ACCOUNT), () =>\n connectors.personal_gmail.update_draft(args),\n );\n const work = when(input.accounts.at(0).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.update_draft(args),\n );\n const merged = connectors.gmail_results.merge({\n personal: personal.data.optional(),\n work_first: work.data.optional(),\n });\n return { results: merged.results };\n },\n }),\n tool('send_draft', {\n description: 'Send an existing Gmail draft from exactly one selected account.',\n annotations: confirmedWrite,\n input: z.object({ accounts: writeAccounts, draft_id: identifier }),\n output,\n fulfil: ({ input, connectors }) => {\n const personal = when(input.accounts.at(0).equals(PERSONAL_ACCOUNT), () =>\n connectors.personal_gmail.send_draft({ draft_id: input.draft_id }),\n );\n const work = when(input.accounts.at(0).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.send_draft({ draft_id: input.draft_id }),\n );\n const merged = connectors.gmail_results.merge({\n personal: personal.data.optional(),\n work_first: work.data.optional(),\n });\n return { results: merged.results };\n },\n }),\n tool('modify_message_labels', {\n description: 'Add or remove Gmail label ids on one message in exactly one selected account.',\n annotations: confirmedWrite,\n input: z.object({\n accounts: writeAccounts,\n message_id: identifier,\n add_label_ids: labelIds.optional(),\n remove_label_ids: labelIds.optional(),\n }),\n output,\n fulfil: ({ input, connectors }) => {\n const args = {\n message_id: input.message_id,\n addLabelIds: input.add_label_ids,\n removeLabelIds: input.remove_label_ids,\n };\n const personal = when(input.accounts.at(0).equals(PERSONAL_ACCOUNT), () =>\n connectors.personal_gmail.modify_message_labels(args),\n );\n const work = when(input.accounts.at(0).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.modify_message_labels(args),\n );\n const merged = connectors.gmail_results.merge({\n personal: personal.data.optional(),\n work_first: work.data.optional(),\n });\n return { results: merged.results };\n },\n }),\n tool('archive_message', {\n description: 'Archive one Gmail message in exactly one account by removing INBOX.',\n annotations: confirmedWrite,\n input: z.object({ accounts: writeAccounts, message_id: identifier }),\n output,\n fulfil: ({ input, connectors }) => {\n const personal = when(input.accounts.at(0).equals(PERSONAL_ACCOUNT), () =>\n connectors.personal_gmail.archive_message({ message_id: input.message_id }),\n );\n const work = when(input.accounts.at(0).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.archive_message({ message_id: input.message_id }),\n );\n const merged = connectors.gmail_results.merge({\n personal: personal.data.optional(),\n work_first: work.data.optional(),\n });\n return { results: merged.results };\n },\n }),\n tool('send_message', {\n description: 'Send one base64url MIME message from exactly one selected Gmail account.',\n annotations: confirmedWrite,\n input: z.object({\n accounts: writeAccounts,\n raw: rawMessage,\n thread_id: identifier.optional(),\n }),\n output,\n fulfil: ({ input, connectors }) => {\n const args = { raw: input.raw, threadId: input.thread_id };\n const personal = when(input.accounts.at(0).equals(PERSONAL_ACCOUNT), () =>\n connectors.personal_gmail.send_message(args),\n );\n const work = when(input.accounts.at(0).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.send_message(args),\n );\n const merged = connectors.gmail_results.merge({\n personal: personal.data.optional(),\n work_first: work.data.optional(),\n });\n return { results: merged.results };\n },\n }),\n tool('trash_message', {\n description: 'Move one Gmail message to trash in exactly one account. This is reversible.',\n annotations: confirmedTrash,\n input: z.object({ accounts: writeAccounts, message_id: identifier }),\n output,\n fulfil: ({ input, connectors }) => {\n const personal = when(input.accounts.at(0).equals(PERSONAL_ACCOUNT), () =>\n connectors.personal_gmail.trash_message({ message_id: input.message_id }),\n );\n const work = when(input.accounts.at(0).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.trash_message({ message_id: input.message_id }),\n );\n const merged = connectors.gmail_results.merge({\n personal: personal.data.optional(),\n work_first: work.data.optional(),\n });\n return { results: merged.results };\n },\n }),\n tool('update_vacation', {\n description: 'Update vacation-responder settings on exactly one selected Gmail account.',\n annotations: confirmedWrite,\n input: z.object({\n accounts: writeAccounts,\n settings: z.union([\n z.object({\n enable_auto_reply: z.literal(false),\n response_subject: z.string().min(1).optional(),\n response_body_plain_text: z.string().min(1).optional(),\n ...vacationOptionalFields,\n }),\n z.object({\n enable_auto_reply: z.literal(true),\n response_subject: z.string().min(1),\n response_body_plain_text: z.string().min(1).optional(),\n ...vacationOptionalFields,\n }),\n z.object({\n enable_auto_reply: z.literal(true),\n response_subject: z.string().min(1).optional(),\n response_body_plain_text: z.string().min(1),\n ...vacationOptionalFields,\n }),\n ]),\n }),\n output,\n fulfil: ({ input, connectors }) => {\n const args = {\n enableAutoReply: input.settings.enable_auto_reply,\n responseSubject: input.settings.response_subject,\n responseBodyPlainText: input.settings.response_body_plain_text,\n restrictToContacts: input.settings.restrict_to_contacts,\n restrictToDomain: input.settings.restrict_to_domain,\n startTime: input.settings.start_time,\n endTime: input.settings.end_time,\n };\n const personal = when(input.accounts.at(0).equals(PERSONAL_ACCOUNT), () =>\n connectors.personal_gmail.update_vacation(args),\n );\n const work = when(input.accounts.at(0).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.update_vacation(args),\n );\n const merged = connectors.gmail_results.merge({\n personal: personal.data.optional(),\n work_first: work.data.optional(),\n });\n return { results: merged.results };\n },\n }),\n ],\n);\n" },
70
+ { relPath: "examples/gmail-multi-account/src/server.ts", content: "import {\n annotations,\n bind,\n connection,\n connector,\n externalExchange,\n gmailConnector,\n server,\n tool,\n when,\n z,\n} from '@noodleseed/one';\n\nexport const PERSONAL_ACCOUNT = 'personal@example.com';\nexport const WORK_ACCOUNT = 'work@example.com';\n\nconst readAccounts = z.union([\n z.tuple([z.literal(PERSONAL_ACCOUNT)]).and(z.array(z.literal(PERSONAL_ACCOUNT)).length(1)),\n z.tuple([z.literal(WORK_ACCOUNT)]).and(z.array(z.literal(WORK_ACCOUNT)).length(1)),\n z\n .tuple([z.literal(PERSONAL_ACCOUNT), z.literal(WORK_ACCOUNT)])\n .and(z.array(z.enum([PERSONAL_ACCOUNT, WORK_ACCOUNT])).length(2)),\n]);\nconst writeAccounts = z.union([\n z.tuple([z.literal(PERSONAL_ACCOUNT)]).and(z.array(z.literal(PERSONAL_ACCOUNT)).length(1)),\n z.tuple([z.literal(WORK_ACCOUNT)]).and(z.array(z.literal(WORK_ACCOUNT)).length(1)),\n]);\nconst identifier = z.string().min(1);\nconst rawMessage = z.string().min(1);\nconst labelIds = z.array(identifier).min(1).max(100);\nconst epochMillis = z.string().regex(/^\\d{1,19}$/);\nconst vacationOptionalFields = {\n restrict_to_contacts: z.boolean().optional(),\n restrict_to_domain: z.boolean().optional(),\n start_time: epochMillis.optional(),\n end_time: epochMillis.optional(),\n};\n// One shared output shape for every tool: one entry per account the call fanned out to. The cap is\n// exactly the number of canonical accounts, so it is a true bound rather than a guess — `noodle check`\n// reports an unbounded array output as `tool_design_output_bounds`.\nconst output = z.object({\n results: z\n .array(z.object({ account: z.enum([PERSONAL_ACCOUNT, WORK_ACCOUNT]), data: z.unknown() }))\n .max(2),\n});\n\nconst gmail = gmailConnector();\nconst personal = connection('personal_gmail', externalExchange());\nconst work = connection('work_gmail', externalExchange());\n\nconst merge = connector('gmail_account_results')\n .version('1.0.0')\n .compute('merge', {\n type: 'read',\n input: z.object({\n personal: z.unknown().optional(),\n work_first: z.unknown().optional(),\n work_second: z.unknown().optional(),\n }),\n output,\n run: (input) => {\n const results: Array<{ account: string; data: unknown }> = [];\n if (input.personal !== undefined) {\n results.push({ account: 'personal@example.com', data: input.personal });\n }\n const workData = input.work_first ?? input.work_second;\n if (workData !== undefined) {\n results.push({ account: 'work@example.com', data: workData });\n }\n return { results };\n },\n });\n\nconst confirmedWrite = annotations.openAction({ destructive: false, confirm: true });\nconst confirmedTrash = annotations.openAction({ destructive: true, confirm: true });\n\nexport default server(\n 'gmail_multi_account',\n {\n title: 'Gmail Multi-Account Automation',\n version: '1.0.0',\n use: {\n personal_gmail: bind(gmail, { profile: 'user_oauth', connection: personal }),\n work_gmail: bind(gmail, { profile: 'user_oauth', connection: work }),\n gmail_results: merge,\n },\n },\n [\n tool('search_messages', {\n title: 'Search messages',\n description: 'Search one connected Gmail account or the canonical personal-and-work pair.',\n input: z.object({\n accounts: readAccounts,\n query: z.string(),\n max_results: z.number().int().min(1).max(500).optional(),\n }),\n output,\n fulfil: ({ input, connectors }) => {\n const personal = when(input.accounts.at(0).equals(PERSONAL_ACCOUNT), () =>\n connectors.personal_gmail.search_messages({\n q: input.query,\n maxResults: input.max_results,\n }),\n );\n const workFirst = when(input.accounts.at(0).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.search_messages({\n q: input.query,\n maxResults: input.max_results,\n }),\n );\n const workSecond = when(input.accounts.at(1).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.search_messages({\n q: input.query,\n maxResults: input.max_results,\n }),\n );\n const merged = connectors.gmail_results.merge({\n personal: personal.data.optional(),\n work_first: workFirst.data.optional(),\n work_second: workSecond.data.optional(),\n });\n return { results: merged.results };\n },\n }),\n tool('get_message', {\n title: 'Get message',\n description: 'Get one Gmail message from one connected account or both canonical accounts.',\n input: z.object({\n accounts: readAccounts,\n message_id: identifier,\n format: z.enum(['minimal', 'full', 'raw', 'metadata']).optional(),\n }),\n output,\n fulfil: ({ input, connectors }) => {\n const personal = when(input.accounts.at(0).equals(PERSONAL_ACCOUNT), () =>\n connectors.personal_gmail.get_message({\n message_id: input.message_id,\n format: input.format,\n }),\n );\n const workFirst = when(input.accounts.at(0).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.get_message({\n message_id: input.message_id,\n format: input.format,\n }),\n );\n const workSecond = when(input.accounts.at(1).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.get_message({\n message_id: input.message_id,\n format: input.format,\n }),\n );\n const merged = connectors.gmail_results.merge({\n personal: personal.data.optional(),\n work_first: workFirst.data.optional(),\n work_second: workSecond.data.optional(),\n });\n return { results: merged.results };\n },\n }),\n tool('get_thread', {\n title: 'Get thread',\n description: 'Get one Gmail thread from one connected account or both canonical accounts.',\n input: z.object({\n accounts: readAccounts,\n thread_id: identifier,\n format: z.enum(['minimal', 'full', 'metadata']).optional(),\n }),\n output,\n fulfil: ({ input, connectors }) => {\n const personal = when(input.accounts.at(0).equals(PERSONAL_ACCOUNT), () =>\n connectors.personal_gmail.get_thread({\n thread_id: input.thread_id,\n format: input.format,\n }),\n );\n const workFirst = when(input.accounts.at(0).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.get_thread({\n thread_id: input.thread_id,\n format: input.format,\n }),\n );\n const workSecond = when(input.accounts.at(1).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.get_thread({\n thread_id: input.thread_id,\n format: input.format,\n }),\n );\n const merged = connectors.gmail_results.merge({\n personal: personal.data.optional(),\n work_first: workFirst.data.optional(),\n work_second: workSecond.data.optional(),\n });\n return { results: merged.results };\n },\n }),\n tool('list_drafts', {\n title: 'List drafts',\n description: 'List drafts from one connected Gmail account or both canonical accounts.',\n input: z.object({\n accounts: readAccounts,\n query: z.string().optional(),\n max_results: z.number().int().min(1).max(500).optional(),\n }),\n output,\n fulfil: ({ input, connectors }) => {\n const personal = when(input.accounts.at(0).equals(PERSONAL_ACCOUNT), () =>\n connectors.personal_gmail.list_drafts({\n q: input.query,\n maxResults: input.max_results,\n }),\n );\n const workFirst = when(input.accounts.at(0).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.list_drafts({\n q: input.query,\n maxResults: input.max_results,\n }),\n );\n const workSecond = when(input.accounts.at(1).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.list_drafts({\n q: input.query,\n maxResults: input.max_results,\n }),\n );\n const merged = connectors.gmail_results.merge({\n personal: personal.data.optional(),\n work_first: workFirst.data.optional(),\n work_second: workSecond.data.optional(),\n });\n return { results: merged.results };\n },\n }),\n tool('get_draft', {\n title: 'Get draft',\n description: 'Get one draft from one connected Gmail account or both canonical accounts.',\n input: z.object({\n accounts: readAccounts,\n draft_id: identifier,\n format: z.enum(['minimal', 'full', 'raw', 'metadata']).optional(),\n }),\n output,\n fulfil: ({ input, connectors }) => {\n const personal = when(input.accounts.at(0).equals(PERSONAL_ACCOUNT), () =>\n connectors.personal_gmail.get_draft({\n draft_id: input.draft_id,\n format: input.format,\n }),\n );\n const workFirst = when(input.accounts.at(0).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.get_draft({\n draft_id: input.draft_id,\n format: input.format,\n }),\n );\n const workSecond = when(input.accounts.at(1).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.get_draft({\n draft_id: input.draft_id,\n format: input.format,\n }),\n );\n const merged = connectors.gmail_results.merge({\n personal: personal.data.optional(),\n work_first: workFirst.data.optional(),\n work_second: workSecond.data.optional(),\n });\n return { results: merged.results };\n },\n }),\n tool('get_vacation', {\n title: 'Get vacation responder',\n description: 'Read vacation-responder settings from one connected account or both accounts.',\n input: z.object({ accounts: readAccounts }),\n output,\n fulfil: ({ input, connectors }) => {\n const personal = when(input.accounts.at(0).equals(PERSONAL_ACCOUNT), () =>\n connectors.personal_gmail.get_vacation({}),\n );\n const workFirst = when(input.accounts.at(0).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.get_vacation({}),\n );\n const workSecond = when(input.accounts.at(1).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.get_vacation({}),\n );\n const merged = connectors.gmail_results.merge({\n personal: personal.data.optional(),\n work_first: workFirst.data.optional(),\n work_second: workSecond.data.optional(),\n });\n return { results: merged.results };\n },\n }),\n tool('create_draft', {\n title: 'Create draft',\n description:\n 'Create a Gmail draft in exactly one selected account from a base64url MIME message.',\n annotations: confirmedWrite,\n input: z.object({ accounts: writeAccounts, raw: rawMessage }),\n output,\n fulfil: ({ input, connectors }) => {\n const personal = when(input.accounts.at(0).equals(PERSONAL_ACCOUNT), () =>\n connectors.personal_gmail.create_draft({ raw: input.raw }),\n );\n const work = when(input.accounts.at(0).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.create_draft({ raw: input.raw }),\n );\n const merged = connectors.gmail_results.merge({\n personal: personal.data.optional(),\n work_first: work.data.optional(),\n });\n return { results: merged.results };\n },\n }),\n tool('update_draft', {\n title: 'Update draft',\n description:\n 'Replace a Gmail draft in exactly one selected account with a base64url MIME message.',\n annotations: confirmedWrite,\n input: z.object({ accounts: writeAccounts, draft_id: identifier, raw: rawMessage }),\n output,\n fulfil: ({ input, connectors }) => {\n const args = { draft_id: input.draft_id, raw: input.raw };\n const personal = when(input.accounts.at(0).equals(PERSONAL_ACCOUNT), () =>\n connectors.personal_gmail.update_draft(args),\n );\n const work = when(input.accounts.at(0).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.update_draft(args),\n );\n const merged = connectors.gmail_results.merge({\n personal: personal.data.optional(),\n work_first: work.data.optional(),\n });\n return { results: merged.results };\n },\n }),\n tool('send_draft', {\n title: 'Send draft',\n description: 'Send an existing Gmail draft from exactly one selected account.',\n annotations: confirmedWrite,\n input: z.object({ accounts: writeAccounts, draft_id: identifier }),\n output,\n fulfil: ({ input, connectors }) => {\n const personal = when(input.accounts.at(0).equals(PERSONAL_ACCOUNT), () =>\n connectors.personal_gmail.send_draft({ draft_id: input.draft_id }),\n );\n const work = when(input.accounts.at(0).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.send_draft({ draft_id: input.draft_id }),\n );\n const merged = connectors.gmail_results.merge({\n personal: personal.data.optional(),\n work_first: work.data.optional(),\n });\n return { results: merged.results };\n },\n }),\n tool('modify_message_labels', {\n title: 'Change message labels',\n description: 'Add or remove Gmail label ids on one message in exactly one selected account.',\n annotations: confirmedWrite,\n input: z.object({\n accounts: writeAccounts,\n message_id: identifier,\n add_label_ids: labelIds.optional(),\n remove_label_ids: labelIds.optional(),\n }),\n output,\n fulfil: ({ input, connectors }) => {\n const args = {\n message_id: input.message_id,\n addLabelIds: input.add_label_ids,\n removeLabelIds: input.remove_label_ids,\n };\n const personal = when(input.accounts.at(0).equals(PERSONAL_ACCOUNT), () =>\n connectors.personal_gmail.modify_message_labels(args),\n );\n const work = when(input.accounts.at(0).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.modify_message_labels(args),\n );\n const merged = connectors.gmail_results.merge({\n personal: personal.data.optional(),\n work_first: work.data.optional(),\n });\n return { results: merged.results };\n },\n }),\n tool('archive_message', {\n title: 'Archive message',\n description: 'Archive one Gmail message in exactly one account by removing INBOX.',\n annotations: confirmedWrite,\n input: z.object({ accounts: writeAccounts, message_id: identifier }),\n output,\n fulfil: ({ input, connectors }) => {\n const personal = when(input.accounts.at(0).equals(PERSONAL_ACCOUNT), () =>\n connectors.personal_gmail.archive_message({ message_id: input.message_id }),\n );\n const work = when(input.accounts.at(0).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.archive_message({ message_id: input.message_id }),\n );\n const merged = connectors.gmail_results.merge({\n personal: personal.data.optional(),\n work_first: work.data.optional(),\n });\n return { results: merged.results };\n },\n }),\n tool('send_message', {\n title: 'Send message',\n description: 'Send one base64url MIME message from exactly one selected Gmail account.',\n annotations: confirmedWrite,\n input: z.object({\n accounts: writeAccounts,\n raw: rawMessage,\n thread_id: identifier.optional(),\n }),\n output,\n fulfil: ({ input, connectors }) => {\n const args = { raw: input.raw, threadId: input.thread_id };\n const personal = when(input.accounts.at(0).equals(PERSONAL_ACCOUNT), () =>\n connectors.personal_gmail.send_message(args),\n );\n const work = when(input.accounts.at(0).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.send_message(args),\n );\n const merged = connectors.gmail_results.merge({\n personal: personal.data.optional(),\n work_first: work.data.optional(),\n });\n return { results: merged.results };\n },\n }),\n tool('trash_message', {\n title: 'Move message to trash',\n description: 'Move one Gmail message to trash in exactly one account. This is reversible.',\n annotations: confirmedTrash,\n input: z.object({ accounts: writeAccounts, message_id: identifier }),\n output,\n fulfil: ({ input, connectors }) => {\n const personal = when(input.accounts.at(0).equals(PERSONAL_ACCOUNT), () =>\n connectors.personal_gmail.trash_message({ message_id: input.message_id }),\n );\n const work = when(input.accounts.at(0).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.trash_message({ message_id: input.message_id }),\n );\n const merged = connectors.gmail_results.merge({\n personal: personal.data.optional(),\n work_first: work.data.optional(),\n });\n return { results: merged.results };\n },\n }),\n tool('update_vacation', {\n title: 'Update vacation responder',\n description: 'Update vacation-responder settings on exactly one selected Gmail account.',\n annotations: confirmedWrite,\n input: z.object({\n accounts: writeAccounts,\n settings: z.union([\n z.object({\n enable_auto_reply: z.literal(false),\n response_subject: z.string().min(1).optional(),\n response_body_plain_text: z.string().min(1).optional(),\n ...vacationOptionalFields,\n }),\n z.object({\n enable_auto_reply: z.literal(true),\n response_subject: z.string().min(1),\n response_body_plain_text: z.string().min(1).optional(),\n ...vacationOptionalFields,\n }),\n z.object({\n enable_auto_reply: z.literal(true),\n response_subject: z.string().min(1).optional(),\n response_body_plain_text: z.string().min(1),\n ...vacationOptionalFields,\n }),\n ]),\n }),\n output,\n fulfil: ({ input, connectors }) => {\n const args = {\n enableAutoReply: input.settings.enable_auto_reply,\n responseSubject: input.settings.response_subject,\n responseBodyPlainText: input.settings.response_body_plain_text,\n restrictToContacts: input.settings.restrict_to_contacts,\n restrictToDomain: input.settings.restrict_to_domain,\n startTime: input.settings.start_time,\n endTime: input.settings.end_time,\n };\n const personal = when(input.accounts.at(0).equals(PERSONAL_ACCOUNT), () =>\n connectors.personal_gmail.update_vacation(args),\n );\n const work = when(input.accounts.at(0).equals(WORK_ACCOUNT), () =>\n connectors.work_gmail.update_vacation(args),\n );\n const merged = connectors.gmail_results.merge({\n personal: personal.data.optional(),\n work_first: work.data.optional(),\n });\n return { results: merged.results };\n },\n }),\n ],\n);\n" },
71
71
  { relPath: "examples/gmail-multi-account/test/server.test.ts", content: "import { readFileSync } from 'node:fs';\nimport { dirname, join } from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport { describe, expect, it } from 'vitest';\nimport {\n compileManifest,\n InMemoryCatalog,\n validateJsonSchemaWithDefaults,\n} from '../../../packages/compiler/src/index.js';\nimport { compileConnectors } from '../../../packages/connector-defs/src/index.js';\nimport {\n type CredentialBroker,\n type CredentialRequest,\n type DownstreamCredential,\n executePreparedTool,\n executeTool,\n InMemoryConnectorRegistry,\n isConfirmationRequired,\n prepareToolForConfirmation,\n} from '../../../packages/runtime/src/index.js';\nimport app, { PERSONAL_ACCOUNT, WORK_ACCOUNT } from '../src/server.js';\n\nconst here = dirname(fileURLToPath(import.meta.url));\n\nasync function compiledFixture() {\n const catalog = structuredClone(app.toConnectorCatalog());\n if (catalog === undefined) throw new Error('expected connector catalog');\n for (const connector of catalog.connectors) {\n if ('http' in connector) {\n for (const [name, operation] of Object.entries(connector.operations)) {\n operation.fake = {\n response: {\n id: `${name}-fixture`,\n messages: [{ id: `${name}-message` }],\n drafts: [{ id: `${name}-draft` }],\n },\n };\n }\n }\n }\n const connectors = compileConnectors(JSON.stringify(catalog), { mode: 'fake' });\n if (!connectors.ok) throw new Error(JSON.stringify(connectors.errors));\n const manifest = await app.toManifest();\n const compiled = compileManifest(manifest, {\n catalog: new InMemoryCatalog(connectors.catalog),\n });\n if (!compiled.ok) throw new Error(JSON.stringify(compiled.errors));\n const requests: CredentialRequest[] = [];\n const broker: CredentialBroker = {\n getCredential(request): Promise<DownstreamCredential> {\n requests.push(request);\n return Promise.resolve({ token: `${request.bindingId}-fixture-token` });\n },\n };\n return {\n artifact: compiled.artifact,\n deps: { connectors: new InMemoryConnectorRegistry(connectors.connectors), broker },\n requests,\n };\n}\n\ndescribe('multi-account Gmail flagship', () => {\n it('binds the same curated connector twice without provider ids, credentials, or real labels', async () => {\n const manifest = await app.toManifest();\n expect(manifest.connectors).toMatchObject({\n personal_gmail: {\n id: 'gmail',\n binding: {\n profile: 'user_oauth',\n connection: { id: 'personal_gmail', source: { kind: 'externalExchange' } },\n },\n },\n work_gmail: {\n id: 'gmail',\n binding: {\n profile: 'user_oauth',\n connection: { id: 'work_gmail', source: { kind: 'externalExchange' } },\n },\n },\n });\n const wire = JSON.stringify({ manifest, catalog: app.toConnectorCatalog() });\n expect(wire).not.toMatch(/access[_-]?token|client[_-]?secret/i);\n });\n\n it('emits enforceable canonical account arrays on every tool', async () => {\n const manifest = await app.toManifest();\n for (const tool of manifest.tools) {\n expect(tool.inputSchema.properties).toHaveProperty('accounts');\n expect(JSON.stringify(tool.inputSchema.properties.accounts)).toContain(PERSONAL_ACCOUNT);\n expect(JSON.stringify(tool.inputSchema.properties.accounts)).toContain(WORK_ACCOUNT);\n }\n const read = manifest.tools.find((tool) => tool.name === 'search_messages');\n const write = manifest.tools.find((tool) => tool.name === 'trash_message');\n const readAccountsSchema = JSON.stringify(read?.inputSchema.properties.accounts);\n const writeAccountsSchema = JSON.stringify(write?.inputSchema.properties.accounts);\n expect(readAccountsSchema).toContain(PERSONAL_ACCOUNT);\n expect(readAccountsSchema).toContain(WORK_ACCOUNT);\n expect(readAccountsSchema).toContain('\"maxItems\":2');\n expect(writeAccountsSchema).not.toContain('\"maxItems\":2');\n });\n\n it('allows subject-only or plain-body-only vacation replies but rejects an empty enabled reply', async () => {\n const manifest = await app.toManifest();\n const schema = manifest.tools.find((tool) => tool.name === 'update_vacation')?.inputSchema;\n if (schema === undefined) throw new Error('expected update_vacation schema');\n const base = { accounts: [PERSONAL_ACCOUNT], settings: { enable_auto_reply: true } };\n\n expect(\n validateJsonSchemaWithDefaults(schema, {\n ...base,\n settings: { ...base.settings, response_subject: 'Away' },\n }).issues,\n ).toHaveLength(0);\n expect(\n validateJsonSchemaWithDefaults(schema, {\n ...base,\n settings: { ...base.settings, response_body_plain_text: 'Back soon' },\n }).issues,\n ).toHaveLength(0);\n expect(validateJsonSchemaWithDefaults(schema, base).issues).not.toHaveLength(0);\n });\n\n it.each([\n [],\n ['unknown@example.com'],\n [PERSONAL_ACCOUNT, PERSONAL_ACCOUNT],\n [WORK_ACCOUNT, PERSONAL_ACCOUNT],\n ])('rejects invalid read accounts %j before connector dispatch', async (accounts) => {\n const setup = await compiledFixture();\n const schema = setup.artifact.tools.find(\n (candidate) => candidate.name === 'search_messages',\n )?.inputSchema;\n if (!schema) throw new Error('expected search schema');\n const result = validateJsonSchemaWithDefaults(schema, { accounts, query: '' });\n expect(result.issues).not.toHaveLength(0);\n expect(setup.requests).toHaveLength(0);\n });\n\n it('rejects multi-account mutations at the write schema boundary', async () => {\n const setup = await compiledFixture();\n const schema = setup.artifact.tools.find(\n (candidate) => candidate.name === 'trash_message',\n )?.inputSchema;\n if (!schema) throw new Error('expected trash schema');\n\n const result = validateJsonSchemaWithDefaults(schema, {\n accounts: [PERSONAL_ACCOUNT, WORK_ACCOUNT],\n message_id: 'fixture-message',\n });\n\n expect(result.issues).not.toHaveLength(0);\n expect(setup.requests).toHaveLength(0);\n });\n\n it('rejects a no-op label mutation before confirmation or credential lookup', async () => {\n const setup = await compiledFixture();\n\n await expect(\n prepareToolForConfirmation(\n setup.artifact,\n 'modify_message_labels',\n { accounts: [PERSONAL_ACCOUNT], message_id: 'fixture-message' },\n setup.deps,\n ),\n ).resolves.toMatchObject({ status: 'failed', error: { code: 'arg_invalid' } });\n expect(setup.requests).toHaveLength(0);\n });\n\n it('routes personal, work, and combined reads to isolated bindings and preserves account labels', async () => {\n const personal = await compiledFixture();\n const personalResult = await executeTool(\n personal.artifact,\n 'search_messages',\n { accounts: [PERSONAL_ACCOUNT], query: 'is:unread' },\n personal.deps,\n );\n expect(personal.requests.flatMap((request) => request.bindingId ?? [])).toEqual([\n 'personal_gmail',\n ]);\n expect(personalResult).toMatchObject({\n ok: true,\n output: { results: [{ account: PERSONAL_ACCOUNT }] },\n });\n\n const work = await compiledFixture();\n const workResult = await executeTool(\n work.artifact,\n 'search_messages',\n { accounts: [WORK_ACCOUNT], query: 'is:unread' },\n work.deps,\n );\n expect(work.requests.flatMap((request) => request.bindingId ?? [])).toEqual(['work_gmail']);\n expect(workResult).toMatchObject({\n ok: true,\n output: { results: [{ account: WORK_ACCOUNT }] },\n });\n\n const both = await compiledFixture();\n const bothResult = await executeTool(\n both.artifact,\n 'search_messages',\n { accounts: [PERSONAL_ACCOUNT, WORK_ACCOUNT], query: 'is:unread' },\n both.deps,\n );\n expect(both.requests.flatMap((request) => request.bindingId ?? [])).toEqual([\n 'personal_gmail',\n 'work_gmail',\n ]);\n expect(bothResult).toMatchObject({\n ok: true,\n output: {\n results: [{ account: PERSONAL_ACCOUNT }, { account: WORK_ACCOUNT }],\n },\n });\n });\n\n it('prepares the exact selected write binding, dispatches only after confirmation, and rejects replay drift', async () => {\n const setup = await compiledFixture();\n const prepared = await prepareToolForConfirmation(\n setup.artifact,\n 'trash_message',\n { accounts: [WORK_ACCOUNT], message_id: 'fixture-message' },\n setup.deps,\n );\n expect(isConfirmationRequired(prepared)).toBe(true);\n if (!isConfirmationRequired(prepared)) throw new Error('expected confirmation');\n expect(setup.requests).toHaveLength(0);\n expect(prepared.review.action).toMatchObject({\n bindingId: 'work_gmail',\n connectionId: 'work_gmail',\n operation: 'trash_message',\n });\n\n await expect(\n executePreparedTool(setup.artifact, prepared.continuation, setup.deps),\n ).resolves.toMatchObject({ status: 'completed' });\n expect(setup.requests.flatMap((request) => request.bindingId ?? [])).toEqual(['work_gmail']);\n\n const personalPrepared = structuredClone(prepared.continuation);\n if (personalPrepared.reviewedAction) {\n (personalPrepared.reviewedAction as { bindingId?: string }).bindingId = 'personal_gmail';\n }\n await expect(\n executePreparedTool(setup.artifact, personalPrepared, setup.deps),\n ).resolves.toMatchObject({ status: 'failed' });\n });\n\n it('makes every mutation confirmable and keeps the personal-email skill concise and safe', async () => {\n const manifest = await app.toManifest();\n const catalog = app.toConnectorCatalog();\n const actionNames = new Set(\n catalog?.connectors.flatMap((connector) =>\n Object.entries(connector.operations)\n .filter(([, operation]) => operation.type === 'action')\n .map(([name]) => name),\n ),\n );\n for (const tool of manifest.tools.filter((candidate) => actionNames.has(candidate.name))) {\n expect(tool.annotations?.confirm, tool.name).toBe(true);\n }\n\n const skill = readFileSync(join(here, '../skills/personal-email-automation/SKILL.md'), 'utf8');\n expect(skill).toMatch(/^---\\nname: personal-email-automation\\ndescription:/);\n expect(skill.split('\\n').length).toBeLessThan(140);\n expect(skill).toContain('accounts');\n expect(skill).toMatch(/draft/i);\n expect(skill).toMatch(/confirm/i);\n expect(skill).toMatch(/permanent delete/i);\n expect(skill).not.toMatch(/client[_-]?secret|access[_-]?token/i);\n });\n});\n" },
72
72
  { relPath: "examples/gmail-multi-account/vitest.config.ts", content: "import { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n resolve: {\n alias: {\n '@noodleseed/one': new URL('../../packages/authoring/src/index.ts', import.meta.url).pathname,\n '@noodle-borg/capabilities': new URL(\n '../../packages/capabilities/src/index.ts',\n import.meta.url,\n ).pathname,\n '@noodle-borg/compiler': new URL('../../packages/compiler/src/index.ts', import.meta.url)\n .pathname,\n '@noodle-borg/compute': new URL('../../packages/compute/src/index.ts', import.meta.url)\n .pathname,\n '@noodle-borg/connector-defs': new URL(\n '../../packages/connector-defs/src/index.ts',\n import.meta.url,\n ).pathname,\n '@noodle-borg/connector-http': new URL(\n '../../packages/connector-http/src/index.ts',\n import.meta.url,\n ).pathname,\n '@noodle-borg/runtime': new URL('../../packages/runtime/src/index.ts', import.meta.url)\n .pathname,\n },\n },\n test: { include: ['test/**/*.test.ts'] },\n});\n" },
73
73
  { relPath: "examples/google-bigquery/README.md", content: "# Google BigQuery — keyless deployed-server authentication\n\nThis flagship owns the Google Workload Identity Federation capability slot: a deployed Noodle Seed server\ncalls BigQuery with hourly short-lived Google credentials and never uploads or stores a service-account JSON\nkey. The same `googleWorkloadIdentity(...)` connection works for other Google REST APIs when their connector\noperations declare the exact Google OAuth scopes and `https://*.googleapis.com` audience they require.\n\nThe developer authors only TypeScript:\n\n```ts\nconst google = connection(\n 'customer_google_cloud',\n googleWorkloadIdentity({\n provider: variable('GOOGLE_WIF_PROVIDER'),\n access: {\n kind: 'serviceAccountImpersonation',\n serviceAccount: variable('GOOGLE_SERVICE_ACCOUNT'),\n },\n }),\n);\n\nuse: {\n bigquery: bind(bigquery, { profile: 'google_wif', connection: google }),\n}\n```\n\n`GOOGLE_WIF_PROVIDER` is the non-secret provider resource\n`projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL/providers/PROVIDER`.\n`GOOGLE_SERVICE_ACCOUNT` is the non-secret email of the customer service account that Google will\nimpersonate. The private key does not exist in Noodle.\n\n## Complete operator setup\n\nAfter creating or joining the target Noodle organization, prepare the environment's stable workload identity.\nThis can run before the first deploy and prints copyable Google Cloud and managed-variable commands:\n\n```sh\nnoodle auth google prepare \\\n --org acme \\\n --app google-bigquery \\\n --env prod \\\n --project-number 123456789012 \\\n --pool noodle-prod \\\n --provider google-bigquery \\\n --service-account bigquery-reader@customer-project.iam.gserviceaccount.com\n```\n\nThe command asks for every Google-specific value Noodle cannot infer, creates no Google resources itself,\nand prints:\n\n- the required API-enablement, workload-pool, and OIDC-provider commands;\n- the exact issuer, attribute mapping, tenant condition, and federated principal URI;\n- the least-privilege `roles/iam.workloadIdentityUser` service-account binding;\n- the exact `noodle variables set ... --runtime cloud` commands.\n\nIn the customer project, grant the service account only the data permissions the server needs. For this\nread-only example, use dataset-level `roles/bigquery.dataViewer` and project-level\n`roles/bigquery.jobUser` so it can create query jobs; do not grant project Owner or Editor.\n\nAfter applying the printed Google and Noodle commands, deploy the authored server and validate a real token\nexchange without running the `query_bigquery` business tool:\n\n```sh\nnoodle deploy --access owner-only\nnoodle auth google doctor --org acme --app google-bigquery --env prod\n```\n\nThe broker signs a one-hour RS256 OIDC subject token with the platform signing key, exchanges it only at\nGoogle STS, optionally calls IAM Credentials `generateAccessToken`, and caches the final binding-scoped token\nuntil five minutes before expiry. Revocation immediately stops Noodle from issuing or reusing credentials:\n\n```sh\nnoodle auth google revoke --org acme --app google-bigquery --env prod\n```\n\nAn already-issued Google token can remain usable until its short expiry. Remove the customer-side IAM\nprincipal binding as defense in depth. Re-preparing after revocation creates a new subject, so the old\nGoogle IAM binding cannot silently become valid again.\n\n## Direct federation instead of impersonation\n\nWhere the target Google API supports direct federated principals, omit the service account:\n\n```ts\ngoogleWorkloadIdentity({\n provider: variable('GOOGLE_WIF_PROVIDER'),\n access: { kind: 'direct' },\n});\n```\n\nRun `noodle auth google prepare` without `--service-account`, then grant the printed federated principal the\nleast-privilege role directly on the target resource.\n\n## Local validation\n\nCompilation and tests need no Google credentials:\n\n```sh\nnoodle validate\nnoodle test\n```\n" },
@@ -76,15 +76,15 @@ export const BUNDLED_EXAMPLE_FILES = [
76
76
  { relPath: "examples/google-bigquery/src/server.ts", content: "import {\n annotations,\n bind,\n connection,\n connector,\n googleWorkloadIdentity,\n server,\n tool,\n variable,\n z,\n} from '@noodleseed/one';\n\nconst BIGQUERY_ORIGIN = 'https://bigquery.googleapis.com';\nconst BIGQUERY_READONLY = 'https://www.googleapis.com/auth/bigquery.readonly';\n\nconst bigquery = connector('google_bigquery')\n .version('1.0.0')\n .http({\n baseUrl: BIGQUERY_ORIGIN,\n allowedOrigins: [BIGQUERY_ORIGIN],\n credentialProfiles: { google_wif: { kind: 'bearer' } },\n operations: {\n query: {\n type: 'read',\n method: 'POST',\n path: '/bigquery/v2/projects/${args.project_id}/queries',\n input: z.object({\n project_id: z.string().min(1),\n query: z.string().min(1),\n max_results: z.number().int().min(1).max(1000).optional(),\n }),\n output: z.object({\n job_complete: z.boolean().optional(),\n schema: z.unknown().optional(),\n rows: z.array(z.unknown()).optional(),\n total_rows: z.string().optional(),\n }),\n request: {\n query: '${args.query}',\n useLegacySql: false,\n maxResults: '${args.max_results}',\n },\n response: {\n job_complete: '${response.jobComplete}',\n schema: '${response.schema}',\n rows: '${response.rows}',\n total_rows: '${response.totalRows}',\n },\n credentials: {\n profiles: ['google_wif'],\n scopes: [BIGQUERY_READONLY],\n audience: BIGQUERY_ORIGIN,\n },\n },\n },\n });\n\nconst google = connection(\n 'customer_google_cloud',\n googleWorkloadIdentity({\n provider: variable('GOOGLE_WIF_PROVIDER'),\n access: {\n kind: 'serviceAccountImpersonation',\n serviceAccount: variable('GOOGLE_SERVICE_ACCOUNT'),\n },\n }),\n);\n\nexport default server(\n 'google_bigquery',\n {\n title: 'Google BigQuery Reader',\n version: '1.0.0',\n use: {\n bigquery: bind(bigquery, { profile: 'google_wif', connection: google }),\n },\n instructions:\n 'Run read-only GoogleSQL queries in the configured customer BigQuery project. Never invent project, dataset, or table names.',\n },\n [\n tool('query_bigquery', {\n title: 'Query BigQuery',\n description:\n 'Run one read-only GoogleSQL query in an explicitly named Google Cloud project and return the typed BigQuery rows.',\n input: z.object({\n project_id: z.string().min(1).meta({ title: 'Google Cloud project' }),\n query: z.string().min(1).meta({ title: 'GoogleSQL query' }),\n max_results: z.number().int().min(1).max(1000).optional(),\n }),\n output: z.object({\n job_complete: z.boolean(),\n schema: z.unknown().optional(),\n rows: z.array(z.unknown()),\n total_rows: z.string().optional(),\n }),\n annotations: annotations.readOnly(),\n fulfil({ input, connectors }) {\n const result = connectors.bigquery.query({\n project_id: input.project_id,\n query: input.query,\n max_results: input.max_results,\n });\n return {\n job_complete: result.job_complete,\n schema: result.schema.optional(),\n rows: result.rows,\n total_rows: result.total_rows.optional(),\n };\n },\n }),\n ],\n);\n" },
77
77
  { relPath: "examples/google-bigquery/test/server.test.ts", content: "import { describe, expect, it } from 'vitest';\nimport app from '../src/server.js';\n\ndescribe('Google BigQuery workload-identity flagship', () => {\n it('binds a read-only Google operation to keyless service-account impersonation', async () => {\n const manifest = await app.toManifest();\n expect(manifest.connectors?.bigquery).toMatchObject({\n id: 'google_bigquery',\n binding: {\n profile: 'google_wif',\n connection: {\n id: 'customer_google_cloud',\n source: {\n kind: 'googleWorkloadIdentity',\n provider: '${env.GOOGLE_WIF_PROVIDER}',\n access: {\n kind: 'serviceAccountImpersonation',\n serviceAccount: '${env.GOOGLE_SERVICE_ACCOUNT}',\n },\n },\n },\n },\n });\n expect(app.toConnectorCatalog()).toMatchObject({\n connectors: [\n {\n credentialProfiles: { google_wif: { kind: 'bearer' } },\n operations: {\n query: {\n credentials: {\n profiles: ['google_wif'],\n scopes: ['https://www.googleapis.com/auth/bigquery.readonly'],\n audience: 'https://bigquery.googleapis.com',\n },\n },\n },\n },\n ],\n });\n expect(JSON.stringify({ manifest, catalog: app.toConnectorCatalog() })).not.toMatch(\n /private[_-]?key|client[_-]?secret|access[_-]?token/i,\n );\n });\n});\n" },
78
78
  { relPath: "examples/google-bigquery/vitest.config.ts", content: "import { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n resolve: {\n alias: {\n '@noodleseed/one': new URL('../../packages/authoring/src/index.ts', import.meta.url).pathname,\n '@noodle-borg/capabilities': new URL(\n '../../packages/capabilities/src/index.ts',\n import.meta.url,\n ).pathname,\n '@noodle-borg/compiler': new URL('../../packages/compiler/src/index.ts', import.meta.url)\n .pathname,\n '@noodle-borg/connector-defs': new URL(\n '../../packages/connector-defs/src/index.ts',\n import.meta.url,\n ).pathname,\n },\n },\n test: { include: ['test/**/*.test.ts'] },\n});\n" },
79
- { relPath: "examples/hello/README.md", content: "# hello — minimal TypeScript quickstart\n\nThe smallest deployable Noodle app: a single `greet` tool authored in TypeScript with no\nconnectors, secrets, flows, widgets, or handoff policy. It still uses the current server options form\nso new authors see where server-level branding belongs. Use it to smoke the author loop\n(`noodle validate` / `noodle dev`) or a first deploy.\n\nWhen an installed Noodle Developer plugin drives this example, its skill runs these logical\n`noodle` commands through the plugin-managed, version-pinned launcher and isolated host profile.\nDo not install or update a global CLI: the coding agent writes and tests this source while Noodle\nguides and operates the validate, preview, deploy, inspect, and debug workflow.\nIf that agent discovers a Noodle Seed product gap while working, the installed skill prepares a\nsanitized `noodle feedback` proposal, discovers current fields from `noodle commands --json`, and\nsubmits it only after showing the exact command and receiving explicit user approval.\nEvery `--json` command writes its canonical success or failure envelope to stdout and leaves stderr\nempty. One-shot commands write one envelope; streaming commands write NDJSON snapshot, event, and\nterminal-failure envelopes so agents can parse each line independently.\n\n```sh\nnoodle dev examples/hello/src/server.ts --app hello\nnoodle deploy examples/hello/src/server.ts --org acme --app hello\n```\n\n`noodle export manifest examples/hello/src/server.ts` compiles the same entrypoint locally and prints\nthe portable, vendor-neutral manifest JSON — the eject path: your `server.ts` plus this manifest is\nthe whole app, yours to read, diff, and keep.\n\nIt is also the fixture for `pnpm smoke:dev` and the e2e harness, so keep its tool surface stable.\n" },
79
+ { relPath: "examples/hello/README.md", content: "# hello — minimal TypeScript quickstart\n\nThe smallest deployable Noodle app: a single `greet` tool authored in TypeScript with no\nconnectors, secrets, flows, widgets, or handoff policy. It still uses the current server options form\nso new authors see where server-level branding belongs. Use it to smoke the author loop\n(`noodle validate` / `noodle dev`) or a first deploy.\n\nWhen an installed Noodle Developer plugin drives this example, its skill runs these logical\n`noodle` commands through the plugin-managed, version-pinned launcher and isolated host profile.\nDo not install or update a global CLI: the coding agent writes and tests this source while Noodle\nguides and operates the validate, preview, deploy, inspect, and debug workflow.\nIf that agent discovers a Noodle Seed product gap while working, the installed skill prepares a\nsanitized `noodle feedback` proposal, discovers current fields from `noodle commands --json`, runs\n`--dry-run --json` to inspect the exact normalized submission, diagnostics, and private destination,\nthen shows a POSIX-safely quoted live command. It submits once without `--dry-run` only after explicit\nuser approval of that exact preview; it never auto-logs in or retry-loops.\nEvery `--json` command writes its canonical success or failure envelope to stdout and leaves stderr\nempty. One-shot commands write one envelope; streaming commands write NDJSON snapshot, event, and\nterminal-failure envelopes so agents can parse each line independently.\n\n```sh\nnoodle dev examples/hello/src/server.ts --app hello\nnoodle deploy examples/hello/src/server.ts --org acme --app hello\n```\n\n`noodle export manifest examples/hello/src/server.ts` compiles the same entrypoint locally and prints\nthe portable, vendor-neutral manifest JSON — the eject path: your `server.ts` plus this manifest is\nthe whole app, yours to read, diff, and keep.\n\nIt is also the fixture for `pnpm smoke:dev` and the e2e harness, so keep its tool surface stable.\n" },
80
80
  { relPath: "examples/hello/noodle.json", content: "{\n \"entrypoint\": \"src/server.ts\",\n \"name\": \"hello\",\n \"template\": \"hello\"\n}\n" },
81
81
  { relPath: "examples/hello/package.json", content: "{\n \"name\": \"hello\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {\n \"test\": \"vitest run\",\n \"validate\": \"noodle validate\",\n \"dev\": \"noodle dev\",\n \"deploy\": \"noodle deploy\"\n },\n \"devDependencies\": {\n \"@noodleseed/one\": \"latest\",\n \"vitest\": \"latest\"\n }\n}\n" },
82
- { relPath: "examples/hello/src/server.ts", content: "import { annotations, server, tool, z } from '@noodleseed/one';\n\nexport default server(\n 'hello',\n {\n title: 'Hello',\n version: '1.0.0',\n branding: {\n name: 'Hello',\n accent: '#1D9E75',\n radius: 'md',\n density: 'comfortable',\n },\n },\n [\n tool('greet', {\n description: 'Greet someone by name.',\n input: z.object({\n // Defaults are advertised to the model and applied at runtime when the argument is omitted.\n name: z.string().default('world'),\n }),\n output: z.object({\n message: z.string(),\n }),\n // Read-only, closed-world: assistant surfaces run this without a consent prompt.\n annotations: annotations.readOnly(),\n fulfil: ({ input }) => {\n return { message: `Hello, ${input.name}!` };\n },\n }),\n ],\n);\n" },
82
+ { relPath: "examples/hello/src/server.ts", content: "import { annotations, server, tool, z } from '@noodleseed/one';\n\nexport default server(\n 'hello',\n {\n title: 'Hello',\n version: '1.0.0',\n branding: {\n name: 'Hello',\n accent: '#1D9E75',\n radius: 'md',\n density: 'comfortable',\n },\n },\n [\n tool('greet', {\n // Every model-visible tool declares a title: hosts show it in tool pickers and confirmation\n // prompts, and both consumer directories reject tools without one.\n title: 'Greet someone',\n description: 'Greet someone by name.',\n input: z.object({\n // Defaults are advertised to the model and applied at runtime when the argument is omitted.\n name: z.string().default('world'),\n }),\n output: z.object({\n message: z.string(),\n }),\n // Read-only, closed-world: assistant surfaces run this without a consent prompt.\n annotations: annotations.readOnly(),\n fulfil: ({ input }) => {\n return { message: `Hello, ${input.name}!` };\n },\n }),\n ],\n);\n" },
83
83
  { relPath: "examples/hello/test/server.test.ts", content: "import { describe, expect, it } from 'vitest';\nimport app from '../src/server.js';\n\ndescribe('hello example', () => {\n it('exports a Noodle server definition', () => {\n expect(typeof app.toManifest).toBe('function');\n });\n\n it('advertises the greet default and keeps the argument optional', async () => {\n const manifest = await app.toManifest();\n const greet = manifest.tools?.find((tool) => tool.name === 'greet');\n const schema = greet?.inputSchema as {\n properties?: { name?: { default?: unknown } };\n required?: string[];\n };\n expect(schema.properties?.name?.default).toBe('world');\n expect(schema.required ?? []).not.toContain('name');\n });\n});\n" },
84
84
  { relPath: "examples/weather/README.md", content: "# Weather Briefing\n\nTwo declarative tools that show the runtime's breadth working together, with **no auth and no API\nkeys**. The `weather_briefing` tool takes a city name and runs a **three-step flow**:\n\nCapability slots: HTTP connector authoring, ordered fulfilment flows, query/response mapping,\n**list-returning connector output** (a connector that returns a live, variable-length array), and\nsandboxed compute.\n\n1. **`geo.search`** → geocode the city to coordinates (Open-Meteo Geocoding API)\n2. **`forecast.current`** → fetch current weather for those coordinates (Open-Meteo Forecast API)\n3. **`brief.summarize`** → derive a human-readable briefing in a **WASM/QuickJS compute sandbox**\n\nThe second tool, `search_places`, shows a connector returning a **live, variable-length list**: it binds\nthe whole Open-Meteo geocoding `results` array with `${response.results}`, then narrows each match to\n`{ id, label }` in a compute connector — the \"search → a list of options the model can pick from\"\npattern. Narrowing lives in compute because a `${...}` response mapping cannot iterate an array and a\ntool's Zod output does not strip fields at runtime.\n\nIt exercises, in one TypeScript-authored app:\n\n- **Server-level branding** with semantic tokens carried through the runtime artifact for any generated\n app surface.\n- **Ordered flow execution** with outputs threaded between steps (`${steps.geo.latitude}` → next step).\n- **Two HTTP connectors on two different hosts**, each with its own egress allowlist.\n- **Query parameters** (`query: [...]`) and a constant query baked into the path (`?current_weather=true`).\n- **Deep response mapping** with the `${...}` language — single-element indexing\n (`${response.results[0].latitude}`, `${response.current_weather.temperature}`) **and** whole-array\n binding (`${response.results}` returns the entire list verbatim).\n- **A list-returning connector + compute narrowing** — `geo.search_list` binds the whole `results`\n array; `places.narrow` reduces each element to `{ id, label }` and normalizes the no-results case\n to `[]`.\n- **Sandboxed compute** (no network/fs/env/clock) turning raw numbers into conditions + advice.\n- **Typed input/output schemas** emitted as JSON Schema 2020-12.\n\n## APIs that require form-urlencoded search bodies\n\nThe live Open-Meteo calls above are GET requests. For APIs whose search endpoint is a POST expecting\n`application/x-www-form-urlencoded`, keep authoring a request object and select the encoding explicitly:\n\n```ts\nsearch_quotes: {\n type: 'read',\n method: 'POST',\n path: '/quotes/search',\n requestEncoding: 'form-urlencoded',\n input: z.object({\n fromAirportId: z.string(),\n categories: z.array(z.string()),\n }),\n request: {\n 'from airport id': '${args.fromAirportId}',\n 'aircraft[categories]': '${args.categories}',\n },\n // output and response mapping omitted\n},\n```\n\nNoodle builds a `URLSearchParams` body: spaces and punctuation in field names are encoded normally, while\neach array or nested object is JSON-stringified into its individual form field. Do not pre-encode the body\nor set `Content-Type` manually; the connector owns both.\n\n## Run it locally\n\nFrom the repo root, with the workspace built (`pnpm build`):\n\n```bash\n: # 1. boot the local loopback dev server\nnode packages/cli/dist/cli.js dev examples/weather/src/server.ts --app weather\n\n: # 2. in another shell, call the printed local endpoint\nURL=http://127.0.0.1:<port>/o/local/weather/dev/mcp\ncurl -s \"$URL\" \\\n -H 'content-type: application/json' \\\n -H 'accept: application/json, text/event-stream' \\\n -H 'mcp-protocol-version: 2025-11-25' \\\n -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"weather_briefing\",\"arguments\":{\"city\":\"Paris\"}}}'\n```\n\nExample result (live data, abbreviated):\n\n```json\n{\n \"place\": \"Paris\", \"country\": \"France\",\n \"temperature_c\": 25.1, \"windspeed_kmh\": 8.3,\n \"conditions\": \"overcast\",\n \"headline\": \"Paris, France: 25°C, overcast.\",\n \"advice\": \"Comfortable conditions — no special prep needed.\"\n}\n```\n\nTry other cities (`Reykjavik`, `Singapore`, `Denver`) to see the conditions and advice change.\n\nCall `search_places` to see the **list-returning** tool — one query, many matches:\n\n```bash\ncurl -s \"$URL\" \\\n -H 'content-type: application/json' \\\n -H 'accept: application/json, text/event-stream' \\\n -H 'mcp-protocol-version: 2025-11-25' \\\n -d '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"search_places\",\"arguments\":{\"query\":\"Springfield\"}}}'\n```\n\n```json\n{\n \"places\": [\n { \"id\": \"4951788\", \"label\": \"Springfield, Massachusetts, United States\" },\n { \"id\": \"4250542\", \"label\": \"Springfield, Illinois, United States\" },\n { \"id\": \"4508722\", \"label\": \"Springfield, Ohio, United States\" }\n ]\n}\n```\n" },
85
85
  { relPath: "examples/weather/noodle.json", content: "{\n \"entrypoint\": \"src/server.ts\",\n \"name\": \"weather\"\n}\n" },
86
86
  { relPath: "examples/weather/package.json", content: "{\n \"name\": \"weather\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {\n \"test\": \"vitest run\",\n \"validate\": \"noodle validate\",\n \"dev\": \"noodle dev\",\n \"deploy\": \"noodle deploy\"\n },\n \"devDependencies\": {\n \"@noodleseed/one\": \"latest\",\n \"vitest\": \"latest\"\n }\n}\n" },
87
- { relPath: "examples/weather/src/server.ts", content: "import { connector, server, tool, z } from '@noodleseed/one';\n\n// The same Weather Briefing server, authored in TypeScript with the Noodle authoring SDK.\n//\n// The SDK owns the *manifest*: the tool, its Zod-typed input/output schemas, and the flow — which you\n// write as ordinary code in `fulfil` and the SDK records symbolically into ordered steps.\n//\n// HTTP and compute connectors are authored here too, so the public developer entrypoint is one\n// self-contained server.ts. The SDK still compiles this to internal manifest/catalog data for the runtime.\n\nconst geocoding = connector('open_meteo_geocoding')\n .version('1.0.0')\n .http({\n baseUrl: 'https://geocoding-api.open-meteo.com',\n allowedOrigins: ['https://geocoding-api.open-meteo.com'],\n operations: {\n search: {\n type: 'read',\n method: 'GET',\n path: '/v1/search',\n query: ['name'],\n input: z.object({ name: z.string() }),\n output: z.object({\n latitude: z.number(),\n longitude: z.number(),\n place: z.string().optional(),\n country: z.string().optional(),\n }),\n response: {\n latitude: '${response.results[0].latitude}',\n longitude: '${response.results[0].longitude}',\n place: '${response.results[0].name}',\n country: '${response.results[0].country}',\n },\n },\n // A LIST-returning read. `${response.results}` binds the WHOLE array verbatim — a\n // variable-length list of place objects — with no pagination (Open-Meteo returns every match in\n // one page). Contrast the `search` op above, which indexes a single element (`results[0]`). To\n // reduce each element to a few fields, narrow it in the `geo_places` compute connector below: a\n // response mapping cannot iterate an array, and a tool's Zod output does not strip fields at\n // runtime.\n search_list: {\n type: 'read',\n method: 'GET',\n path: '/v1/search',\n query: ['name', 'count'],\n input: z.object({ name: z.string(), count: z.number().optional() }),\n output: z.object({ results: z.array(z.unknown()).optional() }),\n response: {\n results: '${response.results}',\n },\n },\n },\n });\n\nconst forecast = connector('open_meteo_forecast')\n .version('1.0.0')\n .http({\n baseUrl: 'https://api.open-meteo.com',\n allowedOrigins: ['https://api.open-meteo.com'],\n operations: {\n current: {\n type: 'read',\n method: 'GET',\n path: '/v1/forecast?current_weather=true',\n query: ['latitude', 'longitude'],\n input: z.object({ latitude: z.number(), longitude: z.number() }),\n output: z.object({\n temperature: z.number().optional(),\n windspeed: z.number().optional(),\n weathercode: z.number().optional(),\n }),\n response: {\n temperature: '${response.current_weather.temperature}',\n windspeed: '${response.current_weather.windspeed}',\n weathercode: '${response.current_weather.weathercode}',\n },\n },\n },\n });\n\nconst brief = connector('weather_brief')\n .version('1.0.0')\n .compute('summarize', {\n type: 'read',\n input: z.object({\n place: z.string(),\n country: z.string().optional(),\n temperature: z.number(),\n windspeed: z.number(),\n weathercode: z.number(),\n }),\n output: z.object({\n conditions: z.string(),\n headline: z.string(),\n advice: z.string(),\n }),\n // A real function — type-checked here, serialized to source and run in the sandbox. It must be\n // self-contained: no imports, no closure over outer variables, synchronous.\n run: (input) => {\n const codes: Record<number, string> = {\n 0: 'clear sky',\n 1: 'mainly clear',\n 2: 'partly cloudy',\n 3: 'overcast',\n 45: 'fog',\n 48: 'depositing rime fog',\n 51: 'light drizzle',\n 53: 'moderate drizzle',\n 55: 'dense drizzle',\n 61: 'slight rain',\n 63: 'moderate rain',\n 65: 'heavy rain',\n 71: 'slight snow',\n 73: 'moderate snow',\n 75: 'heavy snow',\n 77: 'snow grains',\n 80: 'slight rain showers',\n 81: 'moderate rain showers',\n 82: 'violent rain showers',\n 85: 'slight snow showers',\n 86: 'heavy snow showers',\n 95: 'thunderstorm',\n 96: 'thunderstorm with hail',\n 99: 'thunderstorm with heavy hail',\n };\n const code = Number(input.weathercode);\n const conditions = codes[code] || 'unknown conditions';\n const temp = Math.round(Number(input.temperature));\n const wind = Math.round(Number(input.windspeed));\n const where = input.country ? `${input.place}, ${input.country}` : input.place;\n const headline = `${where}: ${temp}°C, ${conditions}.`;\n const tips: string[] = [];\n if (temp <= 0) tips.push(\"bundle up, it's freezing\");\n else if (temp <= 10) tips.push('wear a warm coat');\n else if (temp >= 28) tips.push(\"stay hydrated, it's hot\");\n if (code >= 95) tips.push('thunderstorms expected — seek shelter');\n else if (code >= 71 && code <= 86 && code !== 80 && code !== 81 && code !== 82)\n tips.push('snow — dress warm and tread carefully');\n else if (code >= 51 && code <= 82) tips.push('bring an umbrella');\n if (wind >= 30) tips.push('expect strong winds');\n const advice = tips.length\n ? `${tips.join('; ')}.`\n : 'Comfortable conditions — no special prep needed.';\n return { conditions, headline, advice };\n },\n });\n\n// Narrowing a live list to `{ id, label }` summaries is the ONE reshape a response mapping cannot do\n// (the `${...}` language has no per-item iteration) and a tool's Zod output does not enforce at runtime\n// — so it happens here, in a sandboxed compute connector (a connector is HTTP or compute, not both).\n// This also normalizes the no-results case (Open-Meteo omits `results` when nothing matches) to `[]`.\nconst placeNarrow = connector('geo_places')\n .version('1.0.0')\n .compute('narrow', {\n type: 'read',\n input: z.object({ results: z.unknown().optional() }),\n output: z.object({ places: z.array(z.unknown()) }),\n // Self-contained: no imports, no closure over outer variables, synchronous.\n run: (input) => {\n const raw = input.results;\n const list = Array.isArray(raw) ? raw : [];\n const places = list.map((entry) => {\n const parts = [entry.name, entry.admin1, entry.country].filter(\n (part) => typeof part === 'string' && part.length > 0,\n );\n const id =\n entry.id !== undefined && entry.id !== null\n ? String(entry.id)\n : `${entry.latitude},${entry.longitude}`;\n return { id, label: parts.join(', ') };\n });\n return { places };\n },\n });\n\nexport default server(\n 'weather_briefing',\n {\n title: 'Weather Briefing',\n version: '1.0.0',\n use: { geo: geocoding, forecast, brief, places: placeNarrow },\n branding: {\n name: 'Weather Briefing',\n accent: '#0284C7',\n radius: 'md',\n density: 'comfortable',\n },\n },\n [\n tool('weather_briefing', {\n description:\n 'Look up a city, fetch its current weather, and return a human-readable briefing. Runs a ' +\n 'three-step flow: geocode the city, fetch the forecast, then derive the briefing in a sandboxed compute step.',\n input: z.object({\n city: z.string(),\n }),\n output: z.object({\n place: z.string(),\n country: z.string(),\n temperature_c: z.number(),\n windspeed_kmh: z.number(),\n conditions: z.string(),\n headline: z.string(),\n advice: z.string(),\n }),\n fulfil: ({ input, connectors }) => {\n const located = connectors.geo.search({ name: input.city });\n const weather = connectors.forecast.current({\n latitude: located.latitude,\n longitude: located.longitude,\n });\n const briefing = connectors.brief.summarize({\n place: located.place,\n country: located.country,\n temperature: weather.temperature,\n windspeed: weather.windspeed,\n weathercode: weather.weathercode,\n });\n return {\n place: located.place,\n country: located.country,\n temperature_c: weather.temperature,\n windspeed_kmh: weather.windspeed,\n conditions: briefing.conditions,\n headline: briefing.headline,\n advice: briefing.advice,\n };\n },\n }),\n // A connector that returns a live, variable-length LIST: search a place name, get back the\n // matching locations as `{ id, label }` options the model can resolve against. The HTTP op binds\n // the whole array; the compute connector narrows each element to the two fields the model speaks\n // from. Append new tools AFTER existing ones so `tools[0]` stays stable for host harnesses.\n tool('search_places', {\n description:\n 'Search a place name and return the matching locations as a list of { id, label } options.',\n input: z.object({ query: z.string() }),\n output: z.object({\n places: z.array(z.object({ id: z.string(), label: z.string() })),\n }),\n fulfil: ({ input, connectors }) => {\n const found = connectors.geo.search_list({ name: input.query });\n const narrowed = connectors.places.narrow({ results: found.results });\n return { places: narrowed.places };\n },\n }),\n ],\n);\n" },
87
+ { relPath: "examples/weather/src/server.ts", content: "import { connector, server, tool, z } from '@noodleseed/one';\n\n// The same Weather Briefing server, authored in TypeScript with the Noodle authoring SDK.\n//\n// The SDK owns the *manifest*: the tool, its Zod-typed input/output schemas, and the flow — which you\n// write as ordinary code in `fulfil` and the SDK records symbolically into ordered steps.\n//\n// HTTP and compute connectors are authored here too, so the public developer entrypoint is one\n// self-contained server.ts. The SDK still compiles this to internal manifest/catalog data for the runtime.\n\nconst geocoding = connector('open_meteo_geocoding')\n .version('1.0.0')\n .http({\n baseUrl: 'https://geocoding-api.open-meteo.com',\n allowedOrigins: ['https://geocoding-api.open-meteo.com'],\n operations: {\n search: {\n type: 'read',\n method: 'GET',\n path: '/v1/search',\n query: ['name'],\n input: z.object({ name: z.string() }),\n output: z.object({\n latitude: z.number(),\n longitude: z.number(),\n place: z.string().optional(),\n country: z.string().optional(),\n }),\n response: {\n latitude: '${response.results[0].latitude}',\n longitude: '${response.results[0].longitude}',\n place: '${response.results[0].name}',\n country: '${response.results[0].country}',\n },\n },\n // A LIST-returning read. `${response.results}` binds the WHOLE array verbatim — a\n // variable-length list of place objects — with no pagination (Open-Meteo returns every match in\n // one page). Contrast the `search` op above, which indexes a single element (`results[0]`). To\n // reduce each element to a few fields, narrow it in the `geo_places` compute connector below: a\n // response mapping cannot iterate an array, and a tool's Zod output does not strip fields at\n // runtime.\n search_list: {\n type: 'read',\n method: 'GET',\n path: '/v1/search',\n query: ['name', 'count'],\n input: z.object({ name: z.string(), count: z.number().optional() }),\n output: z.object({ results: z.array(z.unknown()).optional() }),\n response: {\n results: '${response.results}',\n },\n },\n },\n });\n\nconst forecast = connector('open_meteo_forecast')\n .version('1.0.0')\n .http({\n baseUrl: 'https://api.open-meteo.com',\n allowedOrigins: ['https://api.open-meteo.com'],\n operations: {\n current: {\n type: 'read',\n method: 'GET',\n path: '/v1/forecast?current_weather=true',\n query: ['latitude', 'longitude'],\n input: z.object({ latitude: z.number(), longitude: z.number() }),\n output: z.object({\n temperature: z.number().optional(),\n windspeed: z.number().optional(),\n weathercode: z.number().optional(),\n }),\n response: {\n temperature: '${response.current_weather.temperature}',\n windspeed: '${response.current_weather.windspeed}',\n weathercode: '${response.current_weather.weathercode}',\n },\n },\n },\n });\n\nconst brief = connector('weather_brief')\n .version('1.0.0')\n .compute('summarize', {\n type: 'read',\n input: z.object({\n place: z.string(),\n country: z.string().optional(),\n temperature: z.number(),\n windspeed: z.number(),\n weathercode: z.number(),\n }),\n output: z.object({\n conditions: z.string(),\n headline: z.string(),\n advice: z.string(),\n }),\n // A real function — type-checked here, serialized to source and run in the sandbox. It must be\n // self-contained: no imports, no closure over outer variables, synchronous.\n run: (input) => {\n const codes: Record<number, string> = {\n 0: 'clear sky',\n 1: 'mainly clear',\n 2: 'partly cloudy',\n 3: 'overcast',\n 45: 'fog',\n 48: 'depositing rime fog',\n 51: 'light drizzle',\n 53: 'moderate drizzle',\n 55: 'dense drizzle',\n 61: 'slight rain',\n 63: 'moderate rain',\n 65: 'heavy rain',\n 71: 'slight snow',\n 73: 'moderate snow',\n 75: 'heavy snow',\n 77: 'snow grains',\n 80: 'slight rain showers',\n 81: 'moderate rain showers',\n 82: 'violent rain showers',\n 85: 'slight snow showers',\n 86: 'heavy snow showers',\n 95: 'thunderstorm',\n 96: 'thunderstorm with hail',\n 99: 'thunderstorm with heavy hail',\n };\n const code = Number(input.weathercode);\n const conditions = codes[code] || 'unknown conditions';\n const temp = Math.round(Number(input.temperature));\n const wind = Math.round(Number(input.windspeed));\n const where = input.country ? `${input.place}, ${input.country}` : input.place;\n const headline = `${where}: ${temp}°C, ${conditions}.`;\n const tips: string[] = [];\n if (temp <= 0) tips.push(\"bundle up, it's freezing\");\n else if (temp <= 10) tips.push('wear a warm coat');\n else if (temp >= 28) tips.push(\"stay hydrated, it's hot\");\n if (code >= 95) tips.push('thunderstorms expected — seek shelter');\n else if (code >= 71 && code <= 86 && code !== 80 && code !== 81 && code !== 82)\n tips.push('snow — dress warm and tread carefully');\n else if (code >= 51 && code <= 82) tips.push('bring an umbrella');\n if (wind >= 30) tips.push('expect strong winds');\n const advice = tips.length\n ? `${tips.join('; ')}.`\n : 'Comfortable conditions — no special prep needed.';\n return { conditions, headline, advice };\n },\n });\n\n// Narrowing a live list to `{ id, label }` summaries is the ONE reshape a response mapping cannot do\n// (the `${...}` language has no per-item iteration) and a tool's Zod output does not enforce at runtime\n// — so it happens here, in a sandboxed compute connector (a connector is HTTP or compute, not both).\n// This also normalizes the no-results case (Open-Meteo omits `results` when nothing matches) to `[]`.\nconst placeNarrow = connector('geo_places')\n .version('1.0.0')\n .compute('narrow', {\n type: 'read',\n input: z.object({ results: z.unknown().optional() }),\n output: z.object({ places: z.array(z.unknown()) }),\n // Self-contained: no imports, no closure over outer variables, synchronous.\n run: (input) => {\n const raw = input.results;\n const list = Array.isArray(raw) ? raw : [];\n const places = list.map((entry) => {\n const parts = [entry.name, entry.admin1, entry.country].filter(\n (part) => typeof part === 'string' && part.length > 0,\n );\n const id =\n entry.id !== undefined && entry.id !== null\n ? String(entry.id)\n : `${entry.latitude},${entry.longitude}`;\n return { id, label: parts.join(', ') };\n });\n return { places };\n },\n });\n\nexport default server(\n 'weather_briefing',\n {\n title: 'Weather Briefing',\n version: '1.0.0',\n use: { geo: geocoding, forecast, brief, places: placeNarrow },\n branding: {\n name: 'Weather Briefing',\n accent: '#0284C7',\n radius: 'md',\n density: 'comfortable',\n },\n },\n [\n tool('weather_briefing', {\n title: 'Weather briefing',\n description:\n 'Look up a city, fetch its current weather, and return a human-readable briefing. Runs a ' +\n 'three-step flow: geocode the city, fetch the forecast, then derive the briefing in a sandboxed compute step.',\n input: z.object({\n city: z.string(),\n }),\n output: z.object({\n place: z.string(),\n country: z.string(),\n temperature_c: z.number(),\n windspeed_kmh: z.number(),\n conditions: z.string(),\n headline: z.string(),\n advice: z.string(),\n }),\n fulfil: ({ input, connectors }) => {\n const located = connectors.geo.search({ name: input.city });\n const weather = connectors.forecast.current({\n latitude: located.latitude,\n longitude: located.longitude,\n });\n const briefing = connectors.brief.summarize({\n place: located.place,\n country: located.country,\n temperature: weather.temperature,\n windspeed: weather.windspeed,\n weathercode: weather.weathercode,\n });\n return {\n place: located.place,\n country: located.country,\n temperature_c: weather.temperature,\n windspeed_kmh: weather.windspeed,\n conditions: briefing.conditions,\n headline: briefing.headline,\n advice: briefing.advice,\n };\n },\n }),\n // A connector that returns a live, variable-length LIST: search a place name, get back the\n // matching locations as `{ id, label }` options the model can resolve against. The HTTP op binds\n // the whole array; the compute connector narrows each element to the two fields the model speaks\n // from. Append new tools AFTER existing ones so `tools[0]` stays stable for host harnesses.\n tool('search_places', {\n title: 'Search places',\n description:\n 'Search a place name and return the matching locations as a list of { id, label } options.',\n // Bound the list at the source: `limit` is capped in the schema and passed through to the\n // upstream `count` parameter, so the model can never pull an unbounded page into its context.\n // `noodle check` reports an unbounded array output as `tool_design_output_bounds`.\n input: z.object({\n query: z.string(),\n limit: z.number().int().min(1).max(10).default(5),\n }),\n output: z.object({\n places: z.array(z.object({ id: z.string(), label: z.string() })),\n }),\n fulfil: ({ input, connectors }) => {\n const found = connectors.geo.search_list({ name: input.query, count: input.limit });\n const narrowed = connectors.places.narrow({ results: found.results });\n return { places: narrowed.places };\n },\n }),\n ],\n);\n" },
88
88
  { relPath: "examples/weather/test/server.test.ts", content: "import { describe, expect, it } from 'vitest';\nimport app from '../src/server.js';\n\ndescribe('weather example', () => {\n it('exports a Noodle server definition', () => {\n expect(typeof app.toManifest).toBe('function');\n });\n\n it('adds a list-returning search_places tool without disturbing tool order', async () => {\n const manifest = (await app.toManifest()) as {\n tools: ReadonlyArray<{ name: string }>;\n connectors?: Record<string, { id: string; version: string }>;\n };\n const toolNames = manifest.tools.map((t) => t.name);\n // The original briefing tool stays first — host harnesses (the online e2e) read `tools[0]`,\n // so new tools are appended, never prepended.\n expect(toolNames[0]).toBe('weather_briefing');\n expect(toolNames).toContain('search_places');\n expect(toolNames.indexOf('search_places')).toBeGreaterThan(\n toolNames.indexOf('weather_briefing'),\n );\n // The list is produced by a whole-array-bind HTTP op (`search_list`) narrowed to {id,label}\n // through a separate compute connector (`geo_places`).\n expect(manifest.connectors?.places).toEqual({ id: 'geo_places', version: '1.0.0' });\n const wire = JSON.stringify(manifest);\n expect(wire).toContain('search_list');\n expect(wire).toContain('narrow');\n });\n});\n" },
89
89
  ];
90
90
  //# sourceMappingURL=example-files.js.map