@noodleseed/one 0.186.1 → 0.188.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.
- package/dist/commands/solutions-conversations.js +1 -1
- package/dist/commands/solutions-conversations.js.map +1 -1
- package/dist/commands/solutions-history.d.ts.map +1 -1
- package/dist/commands/solutions-history.js +5 -2
- package/dist/commands/solutions-history.js.map +1 -1
- package/dist/commands/whatsapp-ops.d.ts +1 -1
- package/dist/commands/whatsapp-ops.d.ts.map +1 -1
- package/dist/commands/whatsapp-ops.js +31 -9
- package/dist/commands/whatsapp-ops.js.map +1 -1
- package/dist/commands/whatsapp-output.d.ts +1 -1
- package/dist/commands/whatsapp-output.d.ts.map +1 -1
- package/dist/commands/whatsapp-output.js +8 -6
- package/dist/commands/whatsapp-output.js.map +1 -1
- package/node_modules/@noodle-borg/agent-kit/dist/curated/error-fixes.js +1 -0
- package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +3 -3
- package/node_modules/@noodle-borg/agent-kit/dist/generated/surface.js +1 -0
- package/node_modules/@noodle-borg/agent-kit/dist/skill-authoring-auth-ref.js +1 -0
- package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-ref.js +3 -1
- package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-model-context.js +1 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-coordinator.d.ts +89 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-coordinator.js +20 -1
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-inbox.js +16 -1
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-store.d.ts +1 -1
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-types.d.ts +34 -3
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-types.js +10 -4
- package/node_modules/@noodle-borg/authoring/dist/assistant.d.ts +1 -1
- package/node_modules/@noodle-borg/authoring/dist/assistant.js +1 -1
- package/node_modules/@noodle-borg/authoring/dist/server.d.ts +6 -0
- package/node_modules/@noodle-borg/authoring/dist/server.js +1 -0
- package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-channels.js +26 -0
- package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-solution-conversations.js +1 -1
- package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-solution-history.js +1 -0
- package/node_modules/@noodle-borg/compiler/dist/artifact/catalog-emission.js +3 -0
- package/node_modules/@noodle-borg/compiler/dist/artifact/types.d.ts +5 -0
- package/node_modules/@noodle-borg/compiler/dist/artifact/version.d.ts +2 -1
- package/node_modules/@noodle-borg/compiler/dist/artifact/version.js +2 -1
- package/node_modules/@noodle-borg/compiler/dist/compile.js +2 -1
- package/node_modules/@noodle-borg/compiler/dist/errors.d.ts +1 -1
- package/node_modules/@noodle-borg/compiler/dist/manifest/schema.d.ts +6 -0
- package/node_modules/@noodle-borg/compiler/dist/manifest/schema.js +4 -1
- package/node_modules/@noodle-borg/compiler/dist/manifest/website-projection.d.ts +3 -0
- package/node_modules/@noodle-borg/compiler/dist/manifest/website-projection.js +23 -2
- package/node_modules/@noodle-borg/runtime/dist/connector/types.d.ts +1 -1
- package/node_modules/@noodle-borg/service/dist/application-history-settings.js +2 -0
- package/node_modules/@noodle-borg/service/dist/build-info.js +1 -0
- package/node_modules/@noodle-borg/service/dist/business-information/managed-solution-executable.js +2 -0
- package/node_modules/@noodle-borg/service/dist/channels/composition.js +3 -2
- package/node_modules/@noodle-borg/service/dist/channels/messaging-eligibility.js +6 -1
- package/node_modules/@noodle-borg/service/dist/channels/reply-bound.js +4 -0
- package/node_modules/@noodle-borg/service/dist/channels/runtime.js +38 -10
- package/node_modules/@noodle-borg/service/dist/channels/twilio-sms.js +188 -0
- package/node_modules/@noodle-borg/service/dist/channels/twilio-webhook.js +16 -0
- package/node_modules/@noodle-borg/service/dist/conversation-history/contracts.js +1 -0
- package/node_modules/@noodle-borg/service/dist/routes/business-information-channels.js +3 -1
- package/node_modules/@noodle-borg/service/dist/routes/service-probes.js +1 -0
- package/node_modules/@noodle-borg/service/dist/routes/sms-twilio.js +66 -0
- package/node_modules/@noodle-borg/service/dist/routes/whatsapp.js +31 -21
- package/node_modules/@noodle-borg/service/dist/serve-local.js +2 -0
- package/node_modules/@noodle-borg/service/dist/service.js +3 -2
- package/node_modules/@noodle-borg/service/package.json +1 -1
- package/node_modules/@noodle-borg/transport-http/dist/handler.d.ts +5 -0
- package/node_modules/@noodle-borg/transport-http/dist/handler.js +2 -0
- package/node_modules/@noodle-borg/transport-http/dist/serve-request.js +3 -1
- package/node_modules/@noodle-borg/transport-http/dist/tool-authorization.js +25 -3
- package/node_modules/@noodle-borg/wire-contracts/dist/application-channels.d.ts +33 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/application-channels.js +3 -1
- package/node_modules/@noodle-borg/wire-contracts/dist/application-history-settings.d.ts +7 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/application-history-settings.js +2 -1
- package/node_modules/@noodle-borg/wire-contracts/dist/conversation-history.d.ts +12 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/conversation-history.js +1 -1
- package/node_modules/@noodle-borg/wire-contracts/dist/index.d.ts +1 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/index.js +1 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/service-info.d.ts +2 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/service-info.js +2 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/sms.d.ts +176 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/sms.js +41 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/whatsapp.d.ts +10 -2
- package/node_modules/@noodle-borg/wire-contracts/dist/whatsapp.js +2 -1
- package/package.json +2 -2
|
@@ -23,10 +23,10 @@ export const BUNDLED_EXAMPLE_FILES = [
|
|
|
23
23
|
{ relPath: "examples/acme-bistro/noodle.json", content: "{\n \"entrypoint\": \"src/server.ts\",\n \"name\": \"acme-bistro\",\n \"template\": \"widget\"\n}\n" },
|
|
24
24
|
{ 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" },
|
|
25
25
|
{ 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 { useBranding, useCallTool, useLayout, useOpenExternal, useToolInfo, useViewState } =\n generateHelpers<AppType>();\n" },
|
|
26
|
-
{ relPath: "examples/acme-bistro/src/server.ts", content: "import {\n annotations,\n managedCollection,\n noodlePlatform,\n server,\n tool,\n variable,\n z,\n} from '@noodleseed/one';\n\n// Fictional ordering app. Payment uses a checkout link; native guest requests require installation.\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\n// Operators configure the notice without redeploying.\nconst guestExperience = variable('GUEST_EXPERIENCE', {\n schema: z.object({ notice: z.string().max(500) }),\n default: { notice: 'Ask us about dietary requirements before placing your order.' },\n portal: { label: 'Guest experience', group: 'Guest experience' },\n requiredFor: ['show_menu'],\n});\n\nconst menuItemOutput = z.object({\n id: z.string(),\n name: z.string(),\n price: z.number(),\n kind: z.string(),\n});\n\nconst guestRequestRecord = z.object({\n locationReference: z.string().min(1).max(120),\n requestType: z.enum(['reservation_help', 'accessibility', 'dietary_question', 'other']),\n summary: z.string().min(1).max(1000),\n guestReference: z.string().max(120).optional(),\n progress: z.enum(['received', 'reviewing', 'handled']).default('received'),\n});\n\nconst guestRequests = managedCollection('guest_requests', {\n title: 'Guest requests',\n description: 'Guest service requests that restaurant staff can review and resolve.',\n schemaVersion: 1,\n // No source is declared, so Noodle is authoritative. Outside-owned records would name an exact\n // connector scan contract here; changes in that outside system would remain ordinary tools.\n record: guestRequestRecord,\n management: { notes: true },\n publicFields: ['locationReference', 'requestType', 'summary'],\n editableFields: ['locationReference', 'requestType', 'summary', 'guestReference', 'progress'],\n fields: { progress: { label: 'Progress' }, summary: { label: 'Guest request' } },\n summaryFields: ['requestType', 'summary', 'progress'],\n filterFields: ['progress'],\n sortFields: ['progress'],\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 // Reusable intent only. Operators review native record preservation separately from short-lived\n // assistant history; tools must not select expiry or claim a saved request confirms a reservation.\n collections: [guestRequests],\n use: { records: noodlePlatform.records.v1 },\n variables: [guestExperience],\n },\n [\n tool('submit_guest_request', {\n title: 'Submit a guest request',\n description: 'Record a guest request for staff review; this does not confirm a reservation.',\n annotations: annotations.localAction({ destructive: false, confirm: true }),\n input: guestRequestRecord.pick({ locationReference: true, requestType: true, summary: true }),\n output: z.object({ recordId: z.string() }),\n fulfil: ({ input, connectors }) => {\n const receipt = connectors.records.submitRecord({\n collection: 'guest_requests',\n payload: {\n locationReference: input.locationReference,\n requestType: input.requestType,\n summary: input.summary,\n },\n });\n return { recordId: receipt.recordId };\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 serviceNotice: z.string().max(500),\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 serviceNotice: guestExperience.field('notice'),\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
|
+
{ relPath: "examples/acme-bistro/src/server.ts", content: "import {\n annotations,\n managedCollection,\n noodlePlatform,\n server,\n tool,\n variable,\n z,\n} from '@noodleseed/one';\n\n// Fictional ordering app. Payment uses a checkout link; native guest requests require installation.\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\n// Operators configure the notice without redeploying.\nconst guestExperience = variable('GUEST_EXPERIENCE', {\n schema: z.object({ notice: z.string().max(500) }),\n default: { notice: 'Ask us about dietary requirements before placing your order.' },\n portal: { label: 'Guest experience', group: 'Guest experience' },\n requiredFor: ['show_menu'],\n});\n\nconst menuItemOutput = z.object({\n id: z.string(),\n name: z.string(),\n price: z.number(),\n kind: z.string(),\n});\n\nconst guestRequestRecord = z.object({\n locationReference: z.string().min(1).max(120),\n requestType: z.enum(['reservation_help', 'accessibility', 'dietary_question', 'other']),\n summary: z.string().min(1).max(1000),\n guestReference: z.string().max(120).optional(),\n progress: z.enum(['received', 'reviewing', 'handled']).default('received'),\n});\n\nconst guestRequests = managedCollection('guest_requests', {\n title: 'Guest requests',\n description: 'Guest service requests that restaurant staff can review and resolve.',\n schemaVersion: 1,\n // No source is declared, so Noodle is authoritative. Outside-owned records would name an exact\n // connector scan contract here; changes in that outside system would remain ordinary tools.\n record: guestRequestRecord,\n management: { notes: true },\n publicFields: ['locationReference', 'requestType', 'summary'],\n editableFields: ['locationReference', 'requestType', 'summary', 'guestReference', 'progress'],\n fields: { progress: { label: 'Progress' }, summary: { label: 'Guest request' } },\n summaryFields: ['requestType', 'summary', 'progress'],\n filterFields: ['progress'],\n sortFields: ['progress'],\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 // Reusable intent only. Operators review native record preservation separately from short-lived\n // assistant history; tools must not select expiry or claim a saved request confirms a reservation.\n collections: [guestRequests],\n use: { records: noodlePlatform.records.v1 },\n variables: [guestExperience],\n },\n [\n tool('submit_guest_request', {\n title: 'Submit a guest request',\n description: 'Record a guest request for staff review; this does not confirm a reservation.',\n annotations: annotations.localAction({ destructive: false, confirm: true }),\n // A low-risk request, so anonymous ChatGPT and Claude guests may send it without signing in.\n anonymous: 'allowed',\n input: guestRequestRecord.pick({ locationReference: true, requestType: true, summary: true }),\n output: z.object({ recordId: z.string() }),\n fulfil: ({ input, connectors }) => {\n const receipt = connectors.records.submitRecord({\n collection: 'guest_requests',\n payload: {\n locationReference: input.locationReference,\n requestType: input.requestType,\n summary: input.summary,\n },\n });\n return { recordId: receipt.recordId };\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 serviceNotice: z.string().max(500),\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 serviceNotice: guestExperience.field('notice'),\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" },
|
|
27
27
|
{ relPath: "examples/acme-bistro/src/views/menu-cart.tsx", content: "import { type CSSProperties, useMemo, useState } from 'react';\nimport {\n useBranding,\n useCallTool,\n useLayout,\n useOpenExternal,\n useToolInfo,\n useViewState,\n} 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 // Widget CSS is ours, so nothing applies `server.branding` for us. Map the one value this widget\n // cares about onto its own custom property; widget-style.css keeps a default for local dev.\n const branding = useBranding();\n const brandStyle = branding.accent\n ? ({ '--nw-accent': branding.accent } as CSSProperties)\n : undefined;\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 style={brandStyle}\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" },
|
|
28
28
|
{ 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 /* Local-dev default. At runtime menu-cart.tsx overrides this from server.branding.accent. */\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" },
|
|
29
|
-
{ 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 it('declares guest records and explicitly authors the native submission tool', async () => {\n const manifest = await app.toManifest();\n expect(manifest.server.collections).toEqual([\n expect.objectContaining({ name: 'guest_requests', schemaVersion: 1 }),\n ]);\n expect(\n manifest.tools.find((tool) => tool.name === 'submit_guest_request')?.fulfilment.steps,\n ).toMatchObject([{ use: 'records.submit_record' }]);\n });\n});\n" },
|
|
29
|
+
{ 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 it('declares guest records and explicitly authors the native submission tool', async () => {\n const manifest = await app.toManifest();\n expect(manifest.server.collections).toEqual([\n expect.objectContaining({ name: 'guest_requests', schemaVersion: 1 }),\n ]);\n expect(\n manifest.tools.find((tool) => tool.name === 'submit_guest_request')?.fulfilment.steps,\n ).toMatchObject([{ use: 'records.submit_record' }]);\n });\n\n it('opens only the guest request, of its write tools, to anonymous MCP callers', async () => {\n const manifest = await app.toManifest();\n const anonymousWrites = manifest.tools\n .filter((tool) => tool.anonymous === 'allowed')\n .map((tool) => tool.name);\n expect(anonymousWrites).toEqual(['submit_guest_request']);\n });\n});\n" },
|
|
30
30
|
{ relPath: "examples/acme-bistro/vitest.config.ts", content: "import { defineConfig } from 'vitest/config';\n\n// Local config so `npm test` (vitest run) discovers this example's own tests instead of inheriting a\n// parent monorepo config's include globs.\nexport default defineConfig({\n test: { include: ['test/**/*.test.ts'] },\n});\n" },
|
|
31
31
|
{ relPath: "examples/acme-discovery/README.md", content: "# Acme Getaways — top-of-funnel discovery → handoff\n\nA Noodle MCP App for **Acme Getaways**, a fictional travel brand. It is the flagship for the\n**top-of-funnel discovery → handoff** pattern: discovery and configuration happen inside ChatGPT; the\nbooking/transaction happens off-app on Acme's own site, reached through a signed, attributable handoff\ndeep link. It pairs a `tool` discovery carousel with a model-visible `create_handoff` tool and\nserver-level `handoff.allowedDomains`.\n\nCapability slots: top-of-funnel funnel discipline, discovery carousel widget, `create_handoff` deep-link\nhandoff with attribution, `handoff.allowedDomains`, the **public website assistant surface** with its\n**WebMCP browser-agent bridge** on a real demo page (`site/index.html`), and a worked\n**design-first** artifact (`design/UX-Document.md` and `design/wireframe.html`). It shows the \"design the experience, then build\nit\" flow the `noodle-seed` skill's `references/experience-design.md` teaches.\n\n## The same tools on Acme's own website\n\nThe funnel does not only start in ChatGPT. The `assistant` block projects these same tools onto\nAcme's marketing site for a visitor with **no account and no session backend** — as a **mixed**\nsurface, so the visitor can also sign in mid-conversation:\n\n```ts\naccess: [\n publicWebsite({\n origins: ['https://getaways.acme.example'],\n capabilities: [destinations, discoverGetaways, createHandoff, shortlistGetaway, offerLeadCapture, captureLead, myTrips],\n signIn: true, // my_trips reads ${user}; reaching it raises the sign-in card\n instructions:\n 'Be a friendly, consultative travel guide, never pushy. Help visitors narrow a getaway before suggesting the next useful step.',\n }),\n authenticatedWebsite({\n origins: ['https://account.acme.example'],\n capabilities: [destinations, discoverGetaways, createHandoff, myTrips],\n instructions: 'The traveler is signed in. Help them plan from their saved trips.',\n }),\n],\n```\n\nThere is no second tool set and no second app — one `server.ts`, projected onto its front doors.\nThe surface `instructions` add only the website-specific voice and goal; shared product truth stays in\n`server.instructions`. This public guidance is injected into that surface's assistant turns, never MCP\n`initialize` or another assistant surface.\n`capabilities` is the entire externally reachable surface per front door, so it stays short enough to\nreview at a glance and closed by default: a tool added to this server later is unreachable from the\nwebsite until someone lists it.\n\n## Sign in mid-conversation, land in the account\n\n`signIn: true` makes the marketing surface **mixed**: `my_trips` stays visible so the assistant can\noffer it, and an anonymous visitor who reaches for it sees a branded card — *Sign in* plus, because\n`labels.signUpAction` is authored, *Create free account*. Both raise `assistant-sign-in-requested`\nwith a single-use `signInTicket`; the detail's `intent` tells the page whether to route its login or\nits registration. Acme's page signs the visitor in exactly as it already does, its backend spends the\nticket with `createAssistantSession({ ..., signInTicket })`, and the **same conversation continues**\non whichever origin the backend designates:\n\n- landing back on `getaways.acme.example` keeps the mixed surface's projection with identity attached;\n- landing on `account.acme.example` rebinds the conversation to the authenticated surface — its\n capabilities and its voice — and the widget repaints the visible transcript and auto-answers the\n intercepted `my_trips` ask under the new identity.\n\nThe ticket spend after account creation is identical to the one after sign-in; the service never\noperates a login of its own.\n\n## Browser agents on Acme's page\n\n`site/index.html` is the marketing page itself: static markup, four listings, and the one line a\ncustomer pastes.\n\n```html\n<script src=\"https://cloud.noodleseed.dev/v1/assistant/embed.js\" data-embed-id=\"pub_…\"></script>\n```\n\nBecause the marketing surface sets `webmcp: { enabled: true }`, that same line does a second job in a\nbrowser that supports WebMCP: the embed registers the session's projected tools with\n`document.modelContext`, so a browser agent — Gemini-in-Chrome, Claude-in-Chrome — can call\n`discover_getaways` or `create_handoff` without a human typing in the panel.\n\nWhat it does **not** do is widen anything. A bridged call carries exactly the embed session's\nauthority: the surface's six-capability allowlist, the same policy and budgets, the same audit trail,\nand the same confirmation card on `capture_lead` — the visitor still approves the lead in the panel,\nbecause a browser agent's consent is not the visitor's. The switch governs *discovery*, not\npermission: it decides whether an agent learns the tools are there. The signed-in account surface\nbelow leaves it off, which is the point of setting it per surface.\n\nBrowsers without `document.modelContext` are unaffected; the page and the panel behave exactly as\nthey did before.\n\nTo run it:\n\n```sh\nnoodle dev # the server, in one terminal\nnpx serve site # the page, in another (any static server works)\n```\n\n`noodle dev` serves the MCP endpoint, not HTML, so the page needs its own server. For a real\nend-to-end run, `noodle deploy` this example, paste the minted `pub_…` id into `site/index.html`, and\nadd the origin you serve the page from to the `publicWebsite` `origins` list — the session exchange\nrefuses any origin that is not listed. WebMCP itself ships behind an origin trial in Chrome 149+, so\na browser without the trial enabled shows the assistant panel and no bridge.\n\n## The consultative sales gateway\n\nWhen a visitor's plans firm up but they would rather not sign up, the assistant may — with explicit\nconfirmation — take their details and deliver them to Acme's own sink. The recipe is a composition of\nexisting primitives, not a platform feature:\n\n- `capture_lead` is an ordinary tool with `annotations.action({ confirm: true })`: the confirmation\n card, showing every field, is the visitor's consent moment.\n- `offer_lead_capture` is its read-only opener, declaring one `collect` `interaction` (ADR 0240):\n the fields to collect, the work email marked `private`, the trip note seeded from the opener's\n output and optional, `review: 'all'`, and the success sentence. A form on the website and natural\n conversation on a messaging channel both save through the same confirmed `capture_lead`;\n `noodle validate` checks every field against the action's input schema.\n- Delivery is a declarative HTTP connector whose endpoint is `variable('LEAD_SINK_URL')` and whose\n credential is `secret('LEAD_SINK_TOKEN')` — the operator supplies values with\n `noodle variables set` / `noodle secrets set`; the example stays credential-free and one authored\n class serves any business.\n- The request mapping sets `source: 'website-assistant'` itself, so Acme's sink can trust the\n attribution; the model never supplies it.\n- The lead rests only in Acme's own system. The platform stores no lead, and a vendor sink is just\n different data: Resend/Postmark are `auth: { kind: 'apiKey', … }`, a HubSpot private app is\n `auth: { kind: 'bearer', … }` — never a named vendor package.\n\n## Design spec and wireframe (write these before the code)\n\nThe house-style UX Document (funnel boundary, personas, prioritized flows, tool and widget spec,\nhandoff domains) is `design/UX-Document.md`; the single-file wireframe with its embedded Apps SDK\naudit is `design/wireframe.html`.\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 discover_getaways --args '{\"vibe\":\"beach\",\"month\":\"June\",\"travelers\":2}'\nnoodle tools call create_handoff --args '{\"destination\":\"coral_bay\",\"destinationName\":\"Coral Bay\",\"month\":\"June\",\"travelers\":2}'\nnoodle check --target chatgpt\n```\n\n## Deploy\n\n```sh\nnoodle link --org demo --app acme-discovery\nnoodle deploy --access owner-only\nnoodle open\n```\n\nThis example has no connector or model secrets and does not include tokens, caller-key mechanisms, or\n`.env.noodle` values. Hosted `noodleManaged()` inference is available by default to billing-attributed\ndeployments and remains subject to sponsored daily limits; local validation and tool calls do not use the\nhosted model, and `openAICompatible(...)` is the BYO alternative. All destinations, prices, and URLs are\nfictional.\n" },
|
|
32
32
|
{ relPath: "examples/acme-discovery/design/UX-Document.md", content: "# Acme Getaways ChatGPT App — User Flow & Experience Document\n\n**Prepared by:** Noodle Seed\n**Scope:** Top-of-funnel destination discovery & trip-shaping inside ChatGPT → signed, attributable handoff to finish booking on Acme's own site\n**Status:** Design specification (v1)\n**Funnel boundary:** Everything up to \"this is the getaway I want and roughly when I'm going.\" Discovery, shortlisting, and shaping the trip (vibe · month · travelers) happen inside ChatGPT; **booking, dates, and payment happen off-app at `book.acme.example`** via a signed deep link that carries the trip. No per-user OAuth in this app.\n\n> The funnel-boundary line above is the contract. Every scope debate resolves against it: **shape the trip in chat, transact on Acme.**\n\n---\n\n## 0. The One-Paragraph Thesis\n\nA traveler deciding *where* to go doesn't start on a booking site — they start with a fuzzy feeling: \"somewhere warm and walkable in June, just the two of us, not too expensive.\" That deliberation increasingly happens in ChatGPT, where they can think out loud and be talked through options. But base ChatGPT can only guess at places and prices; it doesn't know Acme's actual catalog, what a trip *starts from*, or which months are right. The Acme Getaways app brings Acme's **own curated destinations** — Coral Bay, Monte Alto, Old Quarter, Harbor City, each with a real starting price, best-months window, and an honest one-line reason it fits — into that same conversation, rendered as a discovery carousel the traveler can scan, shortlist, and shape. We own the **discovery and configuration** loop; Acme owns the **booking, dates, inventory, and payment** that happen on `book.acme.example`. The handoff *is* the product: the moment the traveler is excited about a specific place, one tap carries the shaped trip to Acme's site with a `src=chatgpt` attribution tag — so Acme measures, and pays for, exactly the demand ChatGPT sent. Acme never has to build or maintain a chat surface; Noodle Seed never has to touch payments.\n\n---\n\n## 1. Acme Getaways Product Overview (Knowledge Base)\n\n### 1.1 What is Acme Getaways?\n\n**Acme Getaways** is a fictional curated-travel brand that sells a small, hand-picked catalog of getaway destinations rather than an infinite metasearch index. Its edge is *curation*: every destination is chosen, described, and priced by Acme, and every trip is booked and fulfilled on Acme's own platform at `acme.example` (booking flow at `book.acme.example`). Because the catalog is small and Acme-owned, it is the ideal thing to ground a ChatGPT app on — the app can be authoritative about every place it shows, because Acme is the source of truth for all of them.\n\n### 1.2 The Catalog (what the app helps discover)\n\nThe curated catalog is **static, Acme-owned data the app returns verbatim** — the app never invents a destination, a price, or a best-months window.\n\n| Destination | Region | Vibe | From (per person) | Best months | Why (Acme's own line) |\n|-------------|--------|------|-------------------|-------------|------------------------|\n| **Coral Bay** | Adriatic coast | beach | **$890** | May–Sep | Calm swimming coves and a walkable old town — easy for a relaxed first trip. |\n| **Monte Alto** | Northern Alps | mountains | **$1,120** | Dec–Mar | Ski-in village with beginner slopes and long groomed runs. |\n| **Old Quarter** | Central Europe | culture | **$640** | Apr–Oct | Dense museum district and food halls, all reachable on foot. |\n| **Harbor City** | Pacific rim | city | **$980** | Sep–Nov | Waterfront nightlife and day-trip islands a short ferry away. |\n\n**\"From\" prices are per-person starting figures**, not quotes — the real, dated price is computed on Acme's site once the traveler picks dates and party size. The app is careful to say \"from $X,\" never \"$X.\"\n\n### 1.3 The Trip Shape (the three inputs the app configures)\n\nDiscovery is parameterized by three model-fillable inputs the traveler expresses in natural language: **vibe** (`beach` · `mountains` · `culture` · `city`), **month** (a calendar month, defaulting to June), and **travelers** (party size, default 2). These are exactly the values that survive the handoff, so the trip a traveler shapes in chat is the trip Acme's site opens to. Nothing else is configured in chat — no dates, no rooms, no payment; those belong to Acme.\n\n### 1.4 The Booking Platform (what lives after the handoff)\n\n`book.acme.example` is Acme's real booking flow: live inventory, exact dates, party pricing, and checkout. It is **off-app by design** — it needs the traveler's account, real availability, and a payment method, none of which belong in a chat surface. The ChatGPT app's job ends the instant the traveler is ready to book; it hands the shaped trip across a signed deep link and never sees a card number.\n\n### 1.5 Business Model / Why Acme Wants This\n\nAcme earns on completed bookings. Its bottleneck is **top-of-funnel demand** — reaching travelers at the \"where should we go?\" moment, before they've defaulted to a metasearch site. That moment now happens in ChatGPT. This app moves Acme **upstream** into the deliberation itself, and — critically — makes the demand **attributable**: every handoff carries `src=chatgpt`, so Acme can measure ChatGPT-sourced sessions, handoffs, and downstream bookings, and Noodle Seed can be paid for the funnel it fills. Acme gets qualified, pre-shaped travelers landing on its booking flow; it does not have to build, staff, or moderate a conversational surface.\n\n---\n\n## 2. Competitive Landscape — Travel Discovery on ChatGPT\n\n### 2.1 What exists today\n\nTravel discovery inside ChatGPT today is **ungrounded**: the model will happily suggest destinations, but it can't tell you what *Acme* actually sells, what a trip starts from, or which months are right for a specific place — and it can't hand you off to book. Travelers bounce out to metasearch tabs, lose the context they built up in chat, and re-explain everything. Generic travel apps that do exist optimize for the *booking* transaction, not the *deliberation* — they assume you already know where you're going.\n\n### 2.2 Acme's unique position in ChatGPT\n\nThe wedge is **\"a fuzzy feeling in, a specific shortlisted getaway out — grounded in a real catalog, ready to book in one tap.\"** Differentiators:\n\n- **Grounded catalog.** Every place, price, best-month, and reason comes from Acme's own data, shown in the carousel — never the model's guess.\n- **Shaped, not just suggested.** The trip carries a vibe, a month, and a party size, so the handoff opens Acme's site to *this* trip, not a blank search.\n- **Honest top-of-funnel.** The app is explicit that booking and payment happen on Acme, never in chat — no fake in-chat checkout, no invented availability.\n- **Attributable by construction.** The `src=chatgpt` tag on the handoff makes the funnel measurable from day one.\n\n---\n\n## 3. Target User Personas (traveler-centric)\n\n**Persona A — \"The Weekend Deliberator.\"** Knows the vibe and the rough month, not the place. \"Somewhere warm and walkable, early June, two of us.\" Wants 3–4 credible options with a reason each, fast. High intent, low patience. Handoff target: **book the shortlisted place on Acme.**\n\n**Persona B — \"The Budget-First Traveler.\"** Starts from a number, not a place. \"What's the cheapest getaway that isn't miserable?\" Scans on the \"from\" price, reads the reason, shortlists. Handoff target: **Acme, once the price/place feels right.**\n\n**Persona C — \"The Season Chaser.\"** Has a fixed window and wants the place that's *right then*. \"Where's good in December?\" Cares most about the best-months fit. Handoff target: **Acme, for the in-season pick.**\n\n**Persona D — \"The Vibe Switcher.\"** Came in for the beach, talks themselves into culture or a city break mid-conversation. Re-runs discovery with a new vibe; the carousel re-renders. Handoff target: **Acme, once the vibe settles.**\n\n**Persona E — \"The Group Coordinator.\"** (Edge.) Shaping a trip for 4–6 people; party size matters for how the trip reads and what carries across. Same loop; the `travelers` value is the one they care about surviving the handoff.\n\n> **The single adaptive behavior:** the app infers vibe · month · travelers from natural language, re-runs discovery when any of them changes, and only ever offers **one deliberate exit — book on Acme.** There is no second track; the handoff is singular.\n\n---\n\n## 4. Conversational User Flow\n\n### 4.1 Entry Points\n\n1. **Vibe + month:** \"Where should we go for a beach trip in June?\"\n2. **Budget-first:** \"Cheapest getaway you'd actually recommend for two?\"\n3. **Season-first:** \"Somewhere good in December?\"\n4. **Named vibe switch:** \"Actually, more of a city break — options?\"\n5. **Party-shaped:** \"A culture trip for four in October.\"\n\n### 4.2 Flow Architecture\n\n```\n ┌─────────────────────────────────────────┐\n │ ENTRY / INTENT │\n │ vibe · month · travelers (from language) │\n └──────────────────────┬────────────────────┘\n │\n discover_getaways ★\n │\n ┌─────────────▼──────────────┐\n │ DiscoveryCarousel │\n │ 4 grounded options: name, │\n │ from-price, best-months, │\n │ region, Acme's \"why\" line │\n └─────────────┬──────────────┘\n │\n ┌──────────────────────────┼──────────────────────────┐\n │ │ │\n shortlist_getaway re-run discovery (browse / narrate)\n (tap a card → mark) (change vibe/month/pax) model explains fit\n │ │ │\n └──────────────────────────┼───────────────────────────┘\n │\n ┌─────────────▼──────────────┐\n │ \"Continue on Acme\" CTA │\n │ create_handoff ★ │\n └─────────────┬──────────────┘\n │\n ┌──────────────────────────▼───────────────────────────┐\n │ HANDOFF (the funnel boundary) │\n │ book.acme.example/plan?dest=…&month=…&pax=…&src=chatgpt│\n │ (dates · inventory · payment happen OFF-APP) │\n └────────────────────────────────────────────────────────┘\n```\n\nThe defining property: **the discovery loop is fast, grounded, and account-free; the handoff is a single deliberate exit to Acme.** Nothing is sold, dated, or paid for inside ChatGPT.\n\n### 4.3 Detailed Conversational Scenarios\n\n#### Scenario 1 — \"Beach trip in June\" (vibe + month → carousel)\n\n> **Traveler:** Where should we go for a beach trip in June? Just the two of us.\n\nApp calls `discover_getaways(vibe=\"beach\", month=\"June\", travelers=2)`.\n\n> **ChatGPT:** Here are Acme's getaways shaped for a beach trip in June, for two —\n> *[renders `DiscoveryCarousel`]*\n> **Coral Bay** stands out for a relaxed June trip: calm swimming coves and a walkable old town, from **$890**, and June sits right in its May–Sep window. Tap **Shortlist** on any you like, and I'll hold it. When you're ready, **Continue on Acme** to pick dates and book.\n\n**Subtlety:** the model *narrates which fit* (Coral Bay for beach), but the carousel shows Acme's full catalog — the tool returns all four; it never filters on the input (that's the honest, grounded behavior, §6). Prices are shown \"from,\" never as quotes.\n\n#### Scenario 2 — \"Cheapest you'd recommend\" (budget-first)\n\n> **Traveler:** Honestly, what's the cheapest one you'd actually recommend for two?\n\nApp calls `discover_getaways(vibe=\"culture\", month=\"June\", travelers=2)` (model maps \"cheapest, recommend\" to browsing the catalog).\n\n> **ChatGPT:** *[renders `DiscoveryCarousel`]*\n> On price, **Old Quarter** starts lowest at **from $640** — a dense museum district and food halls, all walkable, best Apr–Oct so June works well. It's the value pick without feeling like a compromise. Shortlist it, or want me to lean warmer/beachier?\n\n**Subtlety:** the recommendation is tied to Acme's real starting prices ($640 Old Quarter < $890 Coral Bay < $980 Harbor City < $1,120 Monte Alto), stated honestly as \"from,\" and the model offers to re-shape the vibe rather than pretend to filter.\n\n#### Scenario 3 — \"Somewhere good in December\" (season-first)\n\n> **Traveler:** We've got a week in December — where's actually good then?\n\nApp calls `discover_getaways(vibe=\"mountains\", month=\"December\", travelers=2)`.\n\n> **ChatGPT:** *[renders `DiscoveryCarousel`]*\n> December points straight at **Monte Alto** — it's the one destination whose best-months window is **Dec–Mar** (ski-in village, beginner slopes, long groomed runs), from **$1,120**. The others read better in warmer months. Want me to shortlist Monte Alto and get you to Acme to check December dates?\n\n**Subtlety:** the app grounds the seasonal call in the catalog's actual `bestMonths` values; it doesn't invent weather claims. Real availability for December is Acme's to confirm — the app says so.\n\n#### Scenario 4 — \"Shortlist that one\" (widget-only write)\n\n> **Traveler:** Shortlist Coral Bay.\n\nInside the widget, tapping **Shortlist** calls `shortlist_getaway(destination=\"Coral Bay\")`.\n\n> **ChatGPT:** *[carousel updates the card to \"Shortlisted\"; status line reads \"Shortlisted Coral Bay.\"]*\n> Held. Coral Bay's your pick — shortlist another to compare, or **Continue on Acme** when you're ready to book it for June, for two.\n\n**Subtlety:** shortlisting is a **local, non-destructive write** (widget state), not a booking or an account action. It's the \"I like this one\" gesture that sets up the handoff; nothing leaves the app.\n\n#### Scenario 5 — \"Actually, a city break\" (vibe switch → re-run)\n\n> **Traveler:** Hmm, actually more of a city break. What've you got?\n\nApp re-calls `discover_getaways(vibe=\"city\", month=\"June\", travelers=2)`; the carousel re-renders.\n\n> **ChatGPT:** *[renders `DiscoveryCarousel`]*\n> Switching to a city vibe — **Harbor City** is the fit: waterfront nightlife and day-trip islands a short ferry away, from **$980**, best Sep–Nov (so June's shoulder-season, still lively). Shortlist it, or keep exploring?\n\n**Subtlety:** the trip **re-shapes on a natural-language change** — the model updates the `vibe` input and re-invokes discovery. Month and party size persist so the traveler isn't re-asked.\n\n#### Scenario 6 — \"Continue on Acme\" (HANDOFF — the funnel boundary)\n\n> **Traveler:** Let's do Coral Bay. Book it.\n\nInside the widget, **Continue on Acme** calls `create_handoff(destination=\"coral_bay\", destinationName=\"Coral Bay\", month=\"June\", travelers=2)`, then opens the returned URL.\n\n> **ChatGPT:** *[opens `https://book.acme.example/plan?dest=coral_bay&month=June&pax=2&src=chatgpt`]*\n> Opening Acme with Coral Bay, June, two travelers pre-filled — pick your exact dates and finish booking there. Booking and payment happen on Acme, never in chat.\n\n**This is the funnel boundary.** The deep link carries the shaped trip (`dest` · `month` · `pax`) plus the `src=chatgpt` attribution. Acme's site takes it from there: real dates, live inventory, payment. The app's job — turning a fuzzy feeling into a specific, shaped, ready-to-book getaway — is done.\n\n---\n\n## 5. UI Widget Specifications (OpenAI Apps SDK Compliant)\n\n> The app has **one** widget, `DiscoveryCarousel`, authored as a React `view` on the `discover_getaways` `tool`. It follows the OpenAI Apps SDK UI Guidelines (system fonts, monochrome outlined icons, WCAG AA, ≤2 actions per card, no nested scroll). Styling uses Noodle Seed's server-level `branding` tokens and CSS cascade layers — not app-specific global CSS. Acme's accent teal is restricted to the primary CTA, the logo mark, and the \"Shortlisted\" state only.\n\n### 5.1 Design System Compliance\n\nBranding is declared once, server-side, and the runtime injects it into the widget as semantic tokens:\n\n```ts\nbranding: {\n name: 'Acme Getaways',\n accent: '#0EA5A4', // teal — CTAs / logo / \"Shortlisted\" ONLY\n surface: '#F0FDFA', // light surface tint\n surfaceDark: '#0B1B1B', // dark-mode surface\n radius: 'lg',\n density: 'comfortable',\n}\n```\n\n| Category | Source | Notes |\n|----------|--------|-------|\n| Text / background / border | Host system tokens (light + dark) | Neutral ChatGPT surface; the widget reads `theme` from `useLayout()` |\n| Accent | `branding.accent` (`#0EA5A4`) | **Primary CTA + logo mark + \"Shortlisted\" badge ONLY** |\n| Surface | `branding.surface` / `surfaceDark` | Light/dark card tint; no full-bleed brand gradient |\n| Radius / density | `branding.radius: lg` / `density: comfortable` | System scale, comfortable spacing |\n| Icons | Monochrome outlined (compass, external-link) | Inline SVG, single stroke, no fills |\n\n**Rules enforced:** system fonts only; monochrome outlined icons; WCAG AA contrast in light *and* dark; no nested scroll (the carousel is a single horizontal track, no scroll-within-scroll); **≤2 actions per card** (each card has one **Shortlist** toggle; the shell has one **Continue on Acme** primary); prices always shown as **\"from $X\"**, never as a quote; the standing line **\"Booking and payment happen on acme.example — never inside chat.\"** is always visible. Verify all of this with `noodle check --target chatgpt` before submission.\n\n### 5.2 Display Mode Strategy\n\n| User Intent | Display Mode | Compliance Note |\n|-------------|-------------|-----------------|\n| Discover / shortlist getaways | **Inline Carousel** (`DiscoveryCarousel`) | Horizontal track of ≤4 cards; 1 action/card; auto-fit inline |\n| Scan the full catalog at once | **Fullscreen** (same component, `displayMode=\"fullscreen\"`) | The component reads `displayMode` and shows a \"Fullscreen\" chip; same data, roomier layout |\n\n> **No Picture-in-Picture, no in-chat checkout.** There is no live session to pin and no transaction in-app, so PiP is unnecessary and a payment/checkout widget is deliberately **not** built — booking is off-app by design. Fullscreen is the same carousel component, not a second widget.\n\n### 5.3 Widget Specification\n\n#### `DiscoveryCarousel` — Inline Carousel ★ core (the only widget)\n**Purpose:** Turn a shaped trip (vibe · month · travelers) into a scannable set of grounded Acme destinations the traveler can shortlist and then carry to Acme to book.\n\n| Spec | Value |\n|------|-------|\n| Header | Compass logo mark, \"Acme Getaways\" title, status subtitle (e.g. \"Shortlisted Coral Bay.\"), a mode chip (\"Discover\" / \"Fullscreen\") |\n| Per card | Destination **name**, **from $price**, **region · best {months}**, Acme's **\"why\"** line (grounded copy), one **Shortlist** toggle (→ \"Shortlisted\" when active) |\n| Shell action | One primary **Continue on Acme · {selected}** CTA (opens the handoff), disabled while pending (\"Opening Acme…\") |\n| Actions per card | **1** (Shortlist) — well within the ≤2 inline limit |\n| Standing note | \"Booking and payment happen on acme.example — never inside chat.\" (always shown) |\n| Model context | A `data-llm` summary line (\"Acme Getaways discovery: N options for {month}, {travelers} traveler(s); shortlisted {name}\") so the model can narrate accurately |\n| Edge states | Empty catalog → status prompts \"Pick a getaway to continue.\"; pending handoff → CTA shows \"Opening Acme…\"; dark mode → `surfaceDark` tint |\n\n---\n\n## 6. Tool Definitions (App Backend)\n\nThree tools, all thin and deterministic over Acme's **own** catalog data. No tool requires a user credential, and **no tool invents a place, price, or best-month** — the catalog is returned verbatim.\n\n### Tool 1: `discover_getaways` ★ (tool with widget)\n- **In:** `vibe (\"beach\"|\"mountains\"|\"culture\"|\"city\", default \"beach\")`, `month (calendar month, default \"June\")`, `travelers (int ≥1, default 2)`\n- **Out:** `{ status, vibe, month, travelers, options[] }` where each option is `{ id, name, region, vibe, priceFrom, bestMonths, why }`\n- **Behavior:** returns the **full curated catalog** verbatim and renders `DiscoveryCarousel`; the model narrates which options fit the stated vibe. It deliberately does **not** filter on the input — filtering a returned catalog on an input value is connector/flow work, not a tool's job, and returning everything keeps the app honest and lets the traveler switch vibe without a dead end. `read-only` annotation. Host status copy: \"Finding getaways…\" → \"Getaways ready\".\n\n### Tool 2: `shortlist_getaway` (tool for widget)\n- **In:** `destination (string)`, `note (string, default \"\")`\n- **Out:** `{ status, destination, note }`\n- **Behavior:** records the traveler's shortlisted destination from inside the carousel. Widget-only (called by the view, not opened by the model). `local-action`, non-destructive — it's a \"hold this one\" gesture in widget state, not a booking or an account write.\n\n### Tool 3: `create_handoff` ★ (open-link tool)\n- **In:** `destination (url-safe id slug, e.g. \"coral_bay\")`, `destinationName (display name)`, `month (calendar month)`, `travelers (int ≥1)`\n- **Out:** `{ status, destination, summary, handoffUrl }`\n- **Behavior:** builds the signed Acme booking deep link carrying the shaped trip:\n `https://book.acme.example/plan?dest=${destination}&month=${month}&pax=${travelers}&src=chatgpt`\n Every value is already URL-safe (id slug · month enum · integer), so it is substituted directly — the tool never URL-encodes, transforms, or filters an input. `open-action` annotation; the domain is declared in `handoff.allowedDomains` so ChatGPT opens it without a safe-link warning. See §9.\n\n> **No live inventory, no pricing math, no AI in the tool path.** Discovery returns static curated data; the handoff is string substitution. The model does the language and the routing; the tools supply grounded truth and the signed link.\n\n---\n\n## 7. Conversation Design Principles\n\n### 7.1 Tone of Voice\nWarm, concise, and honest — a well-traveled friend who knows Acme's catalog cold. It makes a real recommendation (\"Old Quarter is the value pick\"), names the trade-off, and is candid about what it *can't* do (confirm December dates, quote an exact price) because that's Acme's job. No hype, no invented superlatives.\n\n### 7.2 Guardrails (non-negotiable)\n- **Never invent a destination, price, or best-month.** Only the four catalog entries exist; all values are Acme's, shown verbatim.\n- **Always say \"from $X,\" never \"$X.\"** Starting prices are not quotes; the dated price is computed on Acme.\n- **Never imply an in-chat booking, date, or payment.** The standing note is always visible; the CTA always says \"Continue on Acme.\"\n- **Ground seasonal claims in `bestMonths` only** — no fabricated weather or crowd claims.\n- **Attribution is honest, not hidden.** The `src=chatgpt` tag measures the funnel; it carries no PII.\n\n### 7.3 Memory Strategy\nLightweight, per-conversation: the current trip shape (vibe · month · travelers, so suggestions stay consistent), the shortlisted destination, and rejected vibes. No PII, no account linkage. The `chosen` selection lives in widget view-state so it survives re-renders within the session.\n\n### 7.4 Multi-Turn Intelligence\nDiscovery is iterative. The app holds the trip shape across turns; **re-runs `discover_getaways` when vibe, month, or party size changes**; persists the values the traveler didn't change; and proactively offers the next step (shortlist → continue on Acme). The model routes and phrases; the tools supply the catalog and the link.\n\n---\n\n## 8. End-to-End User Journey Map\n\n**Phase 1 — Frame the feeling (first 10–20s).** Traveler states a fuzzy intent; `discover_getaways` returns four grounded options. *Emotional beat: \"these are real places, with real reasons.\"*\n\n**Phase 2 — Compare & shortlist (20–60s).** Traveler scans on price / best-months / reason, switches vibe if the mood changes, taps **Shortlist**. *Emotional beat: \"this one — I like this one.\"*\n\n**Phase 3 — Decide (10–20s).** The shortlisted getaway, shaped with month and party size, is the thing to act on. *Emotional beat: confidence in the pick.*\n\n**Phase 4 — Handoff (1 tap).** **Continue on Acme** opens the booking flow pre-filled. *Emotional beat: \"and now I just pick dates.\"*\n\n**Phase 5 — Off-app.** Dates, inventory, and payment on `book.acme.example`. **Outside our scope by design.**\n\n**Phase 6 — Return (next trip).** A new conversation re-frames a new feeling; the loop repeats for the next getaway.\n\n---\n\n## 9. Handoff Architecture (Deep Dive) ★\n\nThe handoff *is* the product boundary — and here it is a **single, signed, attributable deep link**, not a two-track decision. Simplicity is the point.\n\n### 9.1 What must be true of the handoff\n\n1. **Zero credentials cross the boundary.** ChatGPT never holds an Acme login or a payment method. The traveler authenticates and pays on Acme.\n2. **The shaped trip is preserved.** `dest` (id slug), `month`, and `pax` (party size) travel in the URL so nothing is re-typed on Acme.\n3. **Attribution is attached.** `src=chatgpt` lets Acme credit the session, the handoff, and the downstream booking to the ChatGPT funnel — the commercial heart of the deal, and it carries no PII.\n4. **The domain is allow-listed.** `book.acme.example` (and `acme.example`) are declared in `handoff.allowedDomains`; the compiler derives ChatGPT's redirect domains from that list, so the link opens without a safe-link interstitial.\n5. **Values are already URL-safe.** The id slug, month enum, and integer party size need no encoding — the tool substitutes them directly (encoding an input would break substitution).\n\n### 9.2 The URL pattern\n\n```\nhttps://book.acme.example/plan?dest=coral_bay&month=June&pax=2&src=chatgpt\n```\n\n`dest` = catalog id slug · `month` = calendar month · `pax` = party size · `src=chatgpt` = attribution. One pattern, every destination.\n\n### 9.3 Recommendation & open questions for Acme\n\nShip against Acme's **existing** `book.acme.example/plan` entry point so nothing blocks launch. In parallel, confirm with Acme:\n\n- The **attribution parameter** name/format (we assume `src=chatgpt`) and whether a finer campaign/session tag is wanted.\n- Whether `book.acme.example/plan` should **pre-select dates** from `month` or just default the month filter (today the app passes the month; Acme owns exact dates).\n- Whether a **deep-linked destination page** (`/plan/coral_bay`) is preferred over a query param — a config change, not a UX rework.\n\n> **Design stance:** `create_handoff` emits a `url` + `summary`. Upgrading the query param to a path, or adding a session tag, is a config change — the unknowns don't block the build.\n\n### 9.4 Edge cases at the boundary\n\n- **Destination sold out / month unavailable:** Acme's site owns this; the app never asserts availability, only \"from\" pricing and best-months.\n- **Mobile:** the deep link opens Acme's site/app; the shaped trip carries regardless.\n- **No shortlist yet:** the CTA continues with the currently-selected (first) card, so the handoff always has a destination.\n\n---\n\n## 10. Demo Scope Recommendation\n\n### 10.1 MVP demo features (priority order)\n1. `discover_getaways` + `DiscoveryCarousel` — fuzzy feeling → four grounded options (the \"these are real\" moment).\n2. `shortlist_getaway` — tap to hold a getaway (the \"this one\" moment).\n3. Re-run discovery on a vibe switch — beach → city, carousel re-renders (the \"it adapts\" moment).\n4. `create_handoff` — Continue on Acme, signed link with `src=chatgpt` (the funnel boundary).\n\n### 10.2 Demo script (≈90 seconds)\n1. \"Beach trip in June, two of us.\" → `DiscoveryCarousel`: Coral Bay leads, from $890, May–Sep fit. *(discovery)*\n2. \"What's the cheapest you'd recommend?\" → model points to Old Quarter, from $640. *(grounded recommendation)*\n3. \"Actually, a city break.\" → carousel re-renders to feature Harbor City. *(vibe switch)*\n4. Tap **Shortlist** on Harbor City → status \"Shortlisted Harbor City.\" *(the pick)*\n5. **Continue on Acme** → opens `book.acme.example/plan?dest=harbor_city&month=June&pax=2&src=chatgpt`. *(the handoff — the whole point)*\n\nThe demo's arc: *a vague mood → four real, priced, reasoned getaways → one shortlisted → one tap to book on Acme.*\n\n---\n\n## 11. Technical Architecture (High Level)\n\n```\nChatGPT ──tool calls──► Noodle Seed runtime (server 'acme_discovery')\n │ app-owned curated catalog (static data)\n ├──► discover_getaways (tool → DiscoveryCarousel)\n ├──► shortlist_getaway (tool, local write)\n ├──► create_handoff (open-link → signed Acme deep link)\n └──► React view bundle (DiscoveryCarousel, branding tokens, CSP)\n │\n (traveler taps Continue) ──► book.acme.example/plan?…&src=chatgpt\n (dates · inventory · payment on Acme)\n```\n\n- **Stateless hot path.** Discovery returns static curated data; the handoff is string substitution. No AI in the tool path.\n- **Grounding discipline.** Every place/price/best-month is Acme's own data, returned verbatim; the model narrates, it never invents.\n- **CSP.** The widget's `connectDomains` / `resourceDomains` / `frameDomains` are scoped to `acme.example`; the handoff domains are declared in `handoff.allowedDomains`.\n- **Attribution.** `src=chatgpt` injected at `create_handoff`, logged (PII-free) for funnel analytics.\n\n---\n\n## 12. Success Metrics\n\n| Metric | What it tells us |\n|--------|------------------|\n| **Discovery rate** (session → carousel rendered) | Top-of-funnel reach |\n| **Shortlists per session** | Engagement with the core gesture |\n| **Vibe re-runs per session** | Depth of deliberation (the loop working) |\n| **Handoff rate** (session → Continue on Acme) | In-app funnel conversion |\n| **`src=chatgpt` bookings on Acme** | The revenue number — bookings attributed to the ChatGPT funnel |\n| **Handoff→booking rate** (Acme-side) | Quality of the shaped demand we send |\n\nThe cleanest experiment: measure **ChatGPT-attributed handoffs → completed Acme bookings** — the conversion that justifies the app and prices the funnel.\n\n---\n\n## 13. Future Enhancements (Post-Launch)\n\n- **Deep-linked destination pages** (`/plan/coral_bay`) if Acme prefers a path over a query param (§9.3).\n- **Month → date pre-selection** on Acme once the booking flow accepts a target window.\n- **Richer shortlist** — hold multiple getaways and compare them side by side before the handoff.\n- **Live catalog feed** — swap the static catalog for an Acme feed so new destinations and \"from\" prices update without a redeploy.\n- **Seasonal nudges** — surface the in-season destination first when the stated month maps cleanly to one `bestMonths` window.\n- **Fullscreen catalog browse** — a roomier grid of the full catalog (same component, `displayMode=\"fullscreen\"`) for \"show me everything.\"\n\n---\n\n## Appendix A — Funnel Boundary Cheat-Sheet\n\n| Stage | Where it happens | Auth needed? |\n|-------|------------------|--------------|\n| Frame the feeling (vibe · month · travelers) | ChatGPT (model) | No |\n| Discover getaways | App → `discover_getaways` | No |\n| Shortlist a getaway | App → `shortlist_getaway` (widget state) | No |\n| Re-shape the trip (change vibe/month/pax) | App → re-run `discover_getaways` | No |\n| **Handoff** | App → `create_handoff` (signed deep link) | No |\n| Pick dates / check availability | **Acme** (`book.acme.example`) | **Yes (at Acme)** |\n| Pay & confirm booking | **Acme** | **Yes (at Acme)** |\n\nEverything above the bold line is ours and runs without a single user credential. Everything below is Acme's. The **Continue on Acme** CTA is the line — and it points one way: *book it on Acme.*\n\n---\n\n## Appendix B — Source Notes (for the build team)\n\nAcme Getaways is a **fictional** brand; the catalog, prices, best-months, and regions in this document are the app's own curated data and are internally consistent with `src/server.ts` and the `DiscoveryCarousel` view. \"From\" prices are per-person starting figures, not quotes — the `create_handoff` deep link exists precisely so exact dates and pricing are always resolved on Acme's own booking flow, never asserted in chat. Tool names (`discover_getaways`, `shortlist_getaway`, `create_handoff`), the widget name (`DiscoveryCarousel`), and the handoff domain (`book.acme.example`) match the implementation exactly; verify Apps SDK compliance with `noodle check --target chatgpt` at build time.\n" },
|
|
@@ -58,7 +58,7 @@ export const BUNDLED_EXAMPLE_FILES = [
|
|
|
58
58
|
{ relPath: "examples/customer-auth/README.md", content: "# Customer Auth - OIDC identity and customer-routed APIs\n\nFor a visitor who starts before signup, use the [Stateful Draft reference](../stateful-draft/README.md)\nalongside this authenticated backend integration.\n\nThis curated example owns the customer/end-user authentication capability slot. It proves that a SaaS app\ncan protect an MCP endpoint with direct OIDC, retain role/scope-based tool authorization, and route ordinary\nreads and confirmed actions to the API origin selected by the verified customer's identity provider.\n\nDirect MCP calls obtain the private route from verified OIDC claims; embedded sessions obtain it from the authenticated backend. Both keep routes out of model and browser state.\n\nThe public developer entrypoint is [`src/server.ts`](src/server.ts). It exposes a deliberately small MCP\nsurface for organization discovery and app lifecycle operations:\n\n- `help` explains the product without customer identity.\n- `list_my_organizations` publicly advertises its descriptor but requires `organizations:read` to list the signed-in customer’s organizations.\n- `list_org_apps` uses `authorization.discovery: 'public'` to expose its descriptor on an anonymously\n accessible endpoint. Execution still requires `org_apps:read` and `org_admin` or `org_member`. Visibility\n grants no permissions, records, role disclosure, or product-guide eligibility. ChatGPT sign-in is unproven.\n- `archive_org_app` archives one app only after exact runtime confirmation. It requires the\n `org_apps:write` scope and `org_admin` role.\n\nThe tools chain: `list_my_organizations` surfaces the `org_id`s the customer can act on,\n`list_org_apps` takes one of those ids, and `archive_org_app` accepts the selected app id. Tool code remains\nindependent of the selected origin.\n\nThe typed `agentGuide` retains only complete workflows the verified caller can execute. Public descriptor discovery does not grant workflow access. See the [runtime guide](https://docs.noodleseed.dev/docs/guides/product-agent-guides#use-the-guide-at-runtime) for embedded behavior and the draft MCP Skills preview.\n\n## Declare the customer endpoint\n\n`customerEndpoint` names one private routing authority and bounds the origins an IdP may select:\n\n```ts\nconst customerApi = customerEndpoint('customer_api', {\n allowedHttpsHostSuffixes: ['api.noodleseed.dev'],\n});\n```\n\nUse either non-empty `allowedHttpsHostSuffixes` or non-empty `allowedHttpsOrigins`, never both. Exact-origin\npolicies may include a non-default port. Suffix policies match only the exact hostname or dot-boundary\nsubdomains on port 443. A routed connector must not add `allowedOrigins`; its endpoint policy is the egress\nallowlist.\n\nThe connector uses that declaration as its normal base URL. Its token endpoint remains a fixed, independently\nvalidated HTTPS URL:\n\n```ts\nconst api = connector('noodleseed_app_api')\n .version('1.0.0')\n .http({\n baseUrl: customerApi,\n auth: {\n kind: 'delegatedTokenExchange',\n tokenUrl: 'https://id.noodleseed.dev/oauth/token',\n clientId: variable('CUSTOMER_API_CLIENT_ID'),\n clientSecret: secret('CUSTOMER_API_CLIENT_SECRET'),\n scopes: ['organizations:read', 'org_apps:read', 'org_apps:write'],\n audience: 'noodleseed-customer-api',\n },\n operations: {\n // read and action operations...\n },\n });\n```\n\n`delegatedTokenExchange` consumes a verified customer caller; an MCP access mode does not create one. Declare\n`customerAuth.*(...)` or `embeddedAssistant(...)` to establish its subject, issuer, and audience, or\n`noodle validate`, `noodle auth doctor`, and deploy fail with `delegated_token_exchange_identity_required`\nbefore resolving secrets or any egress. Hosted deployment never accepts the loopback-only Devtools identity.\nA successful local Devtools exchange is not evidence that the hosted server has an identity source.\n\nAt both connector and operation level, auth must be omitted or use `delegatedTokenExchange`. The compiler\nchecks the emitted connector, including defaults and operation overrides, and reports the failing auth path\nand kind. Keep no bearer, API-key, client-credentials, or managed-provider fallback for local mode; use\noperation fakes.\n\n## Map the endpoint from verified OIDC\n\nThe IdP claim contains the complete base URL, including an optional base path. Routing is separate from the\npublic `${user}` expression scope:\n\n```ts\nauth: customerAuth.oidc({\n issuer: 'https://id.noodleseed.dev',\n audience: 'noodleseed-customer-auth-prod',\n claims: {\n id: 'sub',\n email: 'email',\n name: 'name',\n orgs: 'permissions.orgs',\n roles: 'permissions.roles',\n scopes: 'permissions.scopes',\n },\n routing: {\n endpoints: {\n customer_api: { claim: 'tenant.api_base_url' },\n },\n },\n}),\n```\n\nFor federated OIDC, put the same endpoint map on every issuer. Claim paths may differ, but each issuer must\nmap every endpoint the app uses:\n\n```ts\nauth: customerAuth.federatedOidc({\n issuers: [\n {\n issuer: 'https://id.customer-a.com',\n audience: 'noodleseed-customer-auth-prod',\n routing: {\n endpoints: {\n customer_api: { claim: 'tenant.api_base_url' },\n },\n },\n },\n {\n issuer: 'https://login.customer-b.com',\n audience: 'noodleseed-customer-auth-prod',\n routing: {\n endpoints: {\n customer_api: { claim: 'organization.routes.customer_api' },\n },\n },\n },\n ],\n}),\n```\n\nAt runtime, Noodle Seed validates the configured stable audience, associates the caller with the exact\ntransport-derived MCP resource, projects the route into private request state, applies its policy, and\nfreezes it for the call. Missing, malformed, or\ndisallowed claims return `connector_route_unavailable` before credential lookup or connector egress.\nResolved URLs never enter artifacts, `${user}`, logs, model output, widgets, public confirmation review,\nbroker cache keys, or delegated exchange assertions.\n\nRouted reads work in tools, including declared nested calls. Routed actions require exact\n`annotations.confirm: true`; otherwise they fail with `customer_endpoint_action_unsupported`. Routed\nresources, prompts, and ambient context fail with `customer_endpoint_surface_unsupported`.\n\nThe flagship's routed action uses the normal TypeScript action helper:\n\n```ts\ntool('archive_org_app', {\n authorization: {\n requiredScopes: ['org_apps:write'],\n allowedRoles: ['org_admin'],\n },\n annotations: annotations.openAction({ destructive: false, confirm: true }),\n // input, output, and the normal connectors.app_api.archiveOrgApp(...) call...\n});\n```\n\nThe flagship also opts into the current stateless hosted MCP path:\n\n```ts\ninteractions: {\n confirmationFallback: 'host',\n},\n```\n\nA bidirectional client that negotiated form elicitation can complete the standard confirmation exchange\ninstead. The explicit host fallback trusts the MCP host to have collected native write approval before the\ntool call reaches Noodle Seed; it is never inferred from client identity and does not replace auth, policy,\nor accurate action/destructive annotations. Omit the fallback when connected hosts are not trusted to\nprovide that approval. If neither standard confirmation nor the fallback is available, the action fails\nclosed with `interaction_unavailable`.\n\nPreparation stores only sorted route `{ key, fingerprint }` bindings in its private server-held\ncontinuation; the public review exposes none of them. Acceptance re-resolves the current request route and\nreturns `invalid_continuation` if it is missing or changed, before policy, credentials, or egress. A match\nreuses the current frozen snapshot for the action and all nested or later reads.\n\nThe application developer owns the direct/federated authorization server. It must publish its path-inserted\nRFC 8414 document as direct HTTP 200 JSON with exact issuer and HTTPS authorization/token/registration/JWKS\nendpoints, authorization-code and refresh grants, PKCE S256, public-client auth method `none`, RFC 8707\nresource handling, and public signing keys. It validates each exact MCP resource on authorize, code exchange,\nand refresh, then maps approved versions of this app/environment to `noodleseed-customer-auth-prod`. Other\napps and environments use distinct audiences.\n\nRun `noodle auth doctor src/server.ts` before sharing. Its bounded, read-only probes never register a client.\nAdding the embedded assistant does not choose or rewrite MCP customer auth. Its authenticated backend may\nbind `routing.endpoints.customer_api` during assistant-session exchange from server-owned membership data;\ndirect MCP requests continue to resolve the same endpoint from the configured verified OIDC claim.\n\n## Per-tool authorization remains independent\n\nThe mapped `roles` and `scopes` paths are read only after OIDC verification. The restricted tool declares its\nrule beside the rest of its public contract:\n\n```ts\ntool('list_org_apps', {\n authorization: {\n discovery: 'public',\n requiredScopes: ['org_apps:read'],\n allowedRoles: ['org_admin', 'org_member'],\n },\n // input, output, and fulfilment...\n});\n```\n\nEvery required scope must be present and at least one allowed role must match. When both lists are declared,\nboth conditions apply. In mixed customer mode, `discovery: 'public'` exposes this descriptor before sign-in;\nexecution still requires those scopes and roles. Other restricted tools remain filtered by authorization.\nRoute availability never changes discovery, and unauthorized direct calls fail closed.\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\nThe broker exchanges a short-lived, platform-signed assertion at the fixed token endpoint and caches the\nresult by caller, connector, scopes, and a route fingerprint. The assertion carries only the route key and\nfingerprint, never the URL. The MCP access token is never forwarded to the customer API. Wire contract:\ndocs/spec/connectors.md.\n\nFirebase and Microsoft remain supported managed adapters (docs/spec/auth-and-policy.md and the SharePoint\nflagship).\n\n## Supabase direct-OIDC access-token hook\n\nDynamic Client Registration lets any OAuth client register, so the presence of `client_id` is not approval.\nKeep an operator-controlled client-to-audience map and rewrite `aud` only for an exact mapped client. For a\ndynamically registered client, review its generated client ID, name, and exact redirect URIs in the consent\nflow before adding the mapping. Each new registration needs its own row; never approve by name or prefix.\n\nReplace `<approved-oauth-client-id>` with the reviewed client ID and `<stable-mcp-audience>` with the exact\nvalue configured in `customerAuth.oidc`:\n\n```sql\ncreate table if not exists public.mcp_oauth_client_audiences (\n client_id text primary key check (btrim(client_id) <> ''),\n audience text not null check (btrim(audience) <> '')\n);\n\nrevoke all on table public.mcp_oauth_client_audiences from authenticated, anon, public;\ngrant usage on schema public to supabase_auth_admin;\ngrant select on table public.mcp_oauth_client_audiences to supabase_auth_admin;\n\ninsert into public.mcp_oauth_client_audiences (client_id, audience)\nvalues ('<approved-oauth-client-id>', '<stable-mcp-audience>')\non conflict (client_id) do update set audience = excluded.audience;\n\ncreate or replace function public.mcp_access_token_hook(event jsonb)\nreturns jsonb\nlanguage plpgsql\nstable\nas $$\ndeclare\n claims jsonb := coalesce(event->'claims', '{}'::jsonb);\n oauth_client_id text := nullif(btrim(claims->>'client_id'), '');\n mapped_audience text;\nbegin\n if oauth_client_id is not null then\n select mapping.audience\n into mapped_audience\n from public.mcp_oauth_client_audiences as mapping\n where mapping.client_id = oauth_client_id;\n end if;\n\n if mapped_audience is not null then\n claims := jsonb_set(\n claims,\n '{aud}',\n to_jsonb(mapped_audience),\n true\n );\n end if;\n\n return jsonb_build_object('claims', claims);\nend;\n$$;\n\ngrant execute on function public.mcp_access_token_hook(jsonb) to supabase_auth_admin;\nrevoke execute on function public.mcp_access_token_hook(jsonb) from authenticated, anon, public;\n```\n\n| Token source | Mapping | Resulting `aud` |\n| --- | --- | --- |\n| Approved OAuth client | Exact client row | Mapped stable MCP audience |\n| Unrelated or unknown OAuth client | No row | Original Supabase audience |\n| Browser session | No `client_id` | Original Supabase audience |\n\nSelect this function under Supabase Auth Hooks before completing the interactive verification below.\n\n## Validate\n\n```bash\nnoodle validate examples/customer-auth/src/server.ts --json\nnoodle auth doctor examples/customer-auth/src/server.ts --json\nnoodle test examples/customer-auth/src/server.ts --json\n```\n\nThe doctor proves metadata and JWKS readiness without registering a client. For this protected app,\n`noodle test` proves the anonymous 401 plus exact protected-resource metadata boundary and reports\n`interactiveRequired: true`; neither command proves token issuance or audience verification.\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 any business tool. Add `--version 1` when testing a pinned version;\nthe reported customer resource must match that versioned MCP endpoint.\n\n## Run locally\n\n```bash\nnoodle devtools examples/customer-auth/src/server.ts\n```\n\nComplete sign-in in Devtools and load the tool list. That authenticated request is the local proof that DCR,\nPKCE, token issuance, issuer/signature verification, the stable audience, and exact-resource binding work\ntogether. Invoke a representative safe read when the configured customer API is available.\n\n### Test delegated exchange locally\n\nLocal customer OIDC sign-in and delegated-exchange assertion trust are two distinct boundaries. OIDC proves\nthe caller to the local MCP server; Devtools uses a separate local issuer only for the RFC 8693 assertion\nsent to the downstream token endpoint. This is the canonical local path and requires no `server.ts` change,\nflag, environment variable, or config surface.\n\n1. Configure the OIDC authorization server for the exact loopback callback and RFC 8707 resource. Do not add\n the Devtools assertion key to OIDC issuer metadata or change its signing keys.\n2. Start Devtools, complete customer sign-in, and copy the displayed `{ issuer, jwks }` from **Local delegated exchange**.\n3. Pin both values only in the customer-owned development RFC 8693 token endpoint.\n4. Restrict that trust to development client credentials, audience, API, and data.\n5. Invoke the delegated `list_org_apps` tool until its binding reads **Exchange verified**.\n6. Use hosted preview or `noodle auth doctor --live` to prove the production platform issuer.\n\n**Never trust the Devtools issuer in production: anyone holding the local private key could impersonate a customer.**\n\n## Configuration\n\nThe embedded assistant uses a customer-supplied Responses-compatible endpoint, selected explicitly with\n`transport: 'responses'` in `src/server.ts`. Use `transport: 'chat-completions'` or omit the field for a\nChat Completions endpoint. Noodle never falls back between them. Configure its managed values at the Noodle\ndeployment environment; none of these values belongs in the customer web application environment, and the\nAPI key never reaches the browser:\n\nThe assistant session carries a verified user, tenant, deployment, roles, and scopes. For this flagship's\nrouted tools, the embedding backend resolves the signed-in user's cluster from server-owned membership data\nand passes `routing: { endpoints: { customer_api: cluster.apiBaseUrl } }` to\n`createAssistantSession`. Noodle validates and privately stores that route; it is not returned to the\nbrowser. Do not copy the route into page context, session claims, tool input, or model instructions.\n\nThe authenticated assistant surface also declares `accountTier` as a model-visible session claim. Pass it\nfrom the same backend-owned account record as `claims: { accountTier: account.tier }`; undeclared claims are\ndropped. This is personalization context, not authorization: the verified roles/scopes beside each tool and\nthe server-owned customer route remain the enforcement boundaries. The public\n[runtime guide](https://docs.noodleseed.dev/docs/guides/product-agent-guides#use-the-guide-at-runtime)\nexplains how verified session claims constrain the guide content available to the model.\n\n```bash\nnoodle variables set ASSISTANT_ORIGIN https://app.example.com --scope env\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 CUSTOMER_API_CLIENT_ID your-broker-client-id --scope env\nnoodle secrets set CUSTOMER_API_CLIENT_SECRET --scope env\nnoodle check --target embedded-assistant src/server.ts\n```\n\n`ASSISTANT_ORIGIN` is the operator-owned production embedding origin, so one source can serve every customer\nwithout an application fork. Assistant 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 bounded `presentation` object configures the panel, launcher, header, composer, and messages. Its\nprimitives derive colors from shared server `branding`; raw HTML, CSS, inline SVG, renderer classes, and\ncallbacks are not accepted. This example omits `presentation.panel.surface`, so the renderer keeps the\nopaque default panel treatment while the example's light/dark `branding` surfaces provide its customer colors;\nset the bounded surface to `glass` only when translucency is intentional.\n\nThese TypeScript values remain the reusable developer defaults. After deployment, an environment operator\ncan adjust theme, logo, launcher style, position, and the bounded color palette from the Console's\n**Assistant** tab or `noodle assistant appearance` without changing the customer's embed code. See the\n[embedded assistant guide](https://docs.noodleseed.dev/docs/guides/embedded-assistant) for precedence and reset\nbehavior.\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={resolvedTheme}\n onSessionExpired={() => console.info('Assistant session renewed')}\n/>;\n```\n\n`resolvedTheme` is the application's current `'light' | 'dark'` value. Use `theme=\"auto\"` only when the\nbrowser operating-system preference is intentionally authoritative.\n\nThe backend's account deletion also erases the user's assistant history by the `user.id` it exchanges\n(idempotent; a no-op while the admin surface declares `history: false`):\n\n```ts\nimport { forgetUser } from '@noodleseed/assistant/server';\n\nawait forgetUser({ serviceUrl, clientId, clientSecret, user: { id: user.id } });\n```\n\n### Minimal fail-closed custom renderer skeleton\n\nUse the renderer-free hook only when the product must own the conversation UI and accepts every obligation\nbelow; otherwise start with the managed renderer. This skeleton keeps the canonical client and App host but\nrefuses confirmation and input acceptance until the application implements their schema-aware presentation.\n\n```tsx\n'use client';\n\nimport { useEffect, useState } from 'react';\nimport { NoodleAppView } from '@noodleseed/assistant/react';\nimport { useNoodleAssistant } from '@noodleseed/assistant/react/client';\n\nexport function CustomerAssistant({\n principalKey,\n resolvedTheme,\n onSignInRequested,\n}: {\n principalKey: string;\n resolvedTheme: 'light' | 'dark';\n onSignInRequested: (request: {\n signInTicket: string;\n expiresAt: string;\n }) => Promise<'started' | 'cancelled'>;\n}) {\n const [draft, setDraft] = useState('');\n const [sessionNotice, setSessionNotice] = useState('');\n const [turnNotice, setTurnNotice] = useState('');\n const [pendingSignInTicket, setPendingSignInTicket] = useState<string>();\n const { client, messages, suggestions, 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 useEffect(\n () =>\n client.subscribe((event) => {\n if (event.event === 'session_expired') setSessionNotice('Session expired.');\n if (event.event === 'session_started' || event.event === 'session_reset') {\n setSessionNotice('');\n }\n }),\n [client],\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 <p>This custom renderer has not implemented a complete schema-aware review.</p>\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 <button\n disabled={busy || review.status !== 'pending'}\n onClick={() => settle(client.respond(review.id, { action: 'cancel' }))}\n >\n Cancel\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 {/* request.requestedSchema is the sole input-form contract. */}\n <p>This custom renderer has not implemented the requested schema form.</p>\n <button\n disabled={busy || request.status !== 'pending'}\n onClick={() => settle(client.respond(request.id, { action: 'decline' }))}\n >\n Don't proceed\n </button>\n <button\n disabled={busy || request.status !== 'pending'}\n onClick={() => settle(client.respond(request.id, { action: 'cancel' }))}\n >\n Cancel\n </button>\n </section>\n );\n }\n if (part.type === 'data-tool-result') {\n return (\n <p key={part.data.id} role=\"status\">\n A result is available, but this renderer has no trusted presentation for it.\n </p>\n );\n }\n if (part.type === 'data-view') {\n return (\n <NoodleAppView\n key={`${part.data.id}:${part.data.resourceUri}`}\n client={client}\n view={part.data}\n theme={resolvedTheme}\n />\n );\n }\n if (part.type === 'data-sign-in') {\n const request = part.data;\n return (\n <section key={request.id} aria-label=\"Sign in required\">\n <p>Continue with your account to use this capability.</p>\n <button\n disabled={busy || pendingSignInTicket !== undefined}\n onClick={() => {\n setPendingSignInTicket(request.signInTicket);\n void Promise.resolve()\n .then(() =>\n onSignInRequested({\n signInTicket: request.signInTicket,\n expiresAt: request.expiresAt,\n }),\n )\n .then(\n (result) => {\n if (result === 'cancelled') setPendingSignInTicket(undefined);\n },\n () => setPendingSignInTicket(undefined),\n );\n }}\n >\n Sign in\n </button>\n </section>\n );\n }\n return <p key={index}>Unsupported assistant content.</p>;\n })}\n </article>\n ))}\n <p role=\"status\" aria-live=\"polite\">\n {sessionNotice || turnNotice || (busy ? 'Assistant is working' : '')}\n </p>\n {suggestions?.prompts.length ? (\n <nav aria-label=\"Suggested messages\">\n {suggestions.prompts.map((prompt) => (\n <button\n key={prompt}\n type=\"button\"\n disabled={busy}\n onClick={() => {\n setTurnNotice('');\n settle(client.sendMessage(prompt));\n }}\n >\n {prompt}\n </button>\n ))}\n </nav>\n ) : null}\n {error ? <p role=\"alert\">The assistant could not complete that request.</p> : null}\n <form\n onSubmit={(event) => {\n event.preventDefault();\n const message = draft.trim();\n if (!message) return;\n setDraft('');\n setTurnNotice('');\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\n type=\"button\"\n onClick={() => {\n client.abort();\n setTurnNotice('Response stopped. This does not undo a started action.');\n }}\n >\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 does not render Confirm until the host\nimplements a complete schema-aware review, and it does not accept elicitation until a portable form covers\n`requestedSchema`; use the managed renderer instead of shipping either unsupported branch. Suggestions are\nhook-owned and submit ordinary messages through `client.sendMessage`.\n\n`data-sign-in` has no status and is never passed to `client.respond`. Bind `signInTicket` to the host's\nshort-lived login transaction without putting it in URLs, logs, analytics, or durable browser storage. Resolve\nthe callback as `started` only after the host owns one active transaction; return `cancelled` or reject when no\ntransaction started so the renderer restores the sign-in affordance. A\nmixed renderer uses separate public and authenticated clients: the public shell uses `embedId`, `serviceUrl`,\nand a visitor principal key; after login, the destination mounts a new client against the same-origin session\nendpoint under the user/tenant principal key. Never pass both source options or mutate the public client's\nsource in place. `session_expired` is observational: the client owns its single safe pre-execution re-exchange.\nNever add a generic Retry button or replay an interaction decision automatically.\n\nFor `data-tool-result`, do not expose technical tool names or raw JSON. Prefer the linked `data-view`;\notherwise map a known bounded result to application-trusted UI or keep the explicit unsupported state. For\n`data-view`, use `<noodle-app-view>` or its React `NoodleAppView` adapter. The element's semantic lifecycle\nidentity is the client plus `view.id` plus `view.resourceUri`, so payload/callback rerenders keep the iframe\nand only a different view, disconnect, or App teardown request retires the bridge.\nApp views remain inline by default: the host advertises only inline presentation and rejects a widget's\nfullscreen request. A customer-owned renderer may opt in explicitly with `allowFullscreen` on\n`NoodleAppView` or `allow-fullscreen` on `<noodle-app-view>` only when fullscreen is part of its intended\nexperience. When fullscreen is accepted, the shared host adds a top-right exit control that returns the same\nmounted App to inline mode without discarding its state.\nNever inject `part.data.html`, assign it to `srcdoc`, fetch a `ui://` URI, or reproduce the bridge directly. Pages with a\nContent-Security-Policy must include the Noodle service origin in both `connect-src` and `frame-src`.\n\nBefore the production-equivalent host build, run the presence-only handoff check:\n\n```sh\nnoodle assistant embed --check --json\n```\n\nAdd application-owned delegated-exchange requirements with repeatable `--require-env NAME` flags. The JSON\nreports required and missing names, CSP status, and post-deploy probes without returning environment values\nor writing scaffold files. Map the names through the production secret manager, CI environment, and any\nsecret allowlist; regenerate existing framework-owned environment binding types before the build. Default\nDevtools/model exercises to synthetic data, and obtain approval before sending real connector data to an\nexternal model.\n\nRead `evidence.levels` in order: static host, local contract, hosted session, production browser, then\noperations. Stop at `evidence.firstUnproven`. A static result may be `passed`, `partial`, or `failed`; even\ntop-level `ready: true` means only that no static blocker was detected. It never proves the local session\ncontract or a production-browser flow. Value-free diagnostics call out an MCP endpoint used as the service\nURL, an HTML redirect risk, an SSR mount risk, and a cross-origin session endpoint. The command has no live\nor browser flag; its `postDeployProbes` are next actions, not executed evidence.\n\nAfter deployment, use the assistant doctor to verify the embed client, exact model transport, and static\nsession boundary:\n\n```sh\nnoodle assistant doctor --user-id <real-test-user> --origin \"$PUBLIC_APP_ORIGIN\" --org <org> --app <app> --env <env>\n```\n\nThe doctor makes one bounded synthetic model request without business tools or customer conversation data;\nfailures show only a redacted category, status, and retryability. It does not invent or test an\napplication-specific customer route. Prove routed assistant tools by\nhaving the authenticated embedding backend pass the user's server-verified endpoint during session\nexchange, then invoke one representative safe read.\n\nDo not send a first turn on mount by default. React effect cleanup can suppress one provisional Strict Mode\neffect, but it cannot make a remount, dependency change, or client replacement idempotent. Require an\nexplicit user action unless the host owns durable one-shot state and an application idempotency key that\nmakes repeated sends safe.\n\nFor a chat-first custom host, raw `tool_started` supplies the direct call `id` and technical tool name. Map\nknown tools to concise application copy and use a neutral fallback. Reserve a stable `role=\"status\"` region\nfor thinking, tool activity, and the view skeleton; switch to the ready `<noodle-app-view>` (or React\n`NoodleAppView`) on `view_available` or to `role=\"alert\"` on error. Decorative skeleton shapes stay hidden from assistive technology, and shimmer\nor transition motion is disabled under `prefers-reduced-motion`.\n\nUse `${view.id}:${view.resourceUri}` as transport identity. Different call IDs are distinct invocations and\nmust not be deduplicated generically. If this application intentionally owns one current panel for a known\nresource, declare an application-owned slot for that resource and replace only that slot.\n\n### Framework-neutral DOM client\n\nSubscribe to the DOM-free client directly without a component wrapper and use the isolated App host.\nIt exposes the same conversation as headless AI SDK `UIMessage` state, including typed confirmation, input,\ntool-result, and linked-view parts:\n\n```html\n<div id=\"assistant-app-views\"></div>\n```\n\n```ts\nimport '@noodleseed/assistant/app-view';\nimport {\n type AssistantViewAvailableDetail,\n type NoodleAppViewElement,\n} from '@noodleseed/assistant/app-view';\nimport { createAssistantClient } from '@noodleseed/assistant/client';\n\nconst assistant = createAssistantClient({\n sessionEndpoint: '/api/noodle-assistant/session',\n});\nconst appViews = document.querySelector('#assistant-app-views');\nif (!appViews) throw new Error('Missing App views host');\nconst mountedViews = new Map<string, NoodleAppViewElement>();\nconst readResolvedTheme = (): 'light' | 'dark' =>\n document.documentElement.classList.contains('dark') ? 'dark' : 'light';\nlet resolvedTheme: 'light' | 'dark' = readResolvedTheme();\nconst syncResolvedTheme = () => {\n resolvedTheme = readResolvedTheme();\n for (const mountedView of mountedViews.values()) mountedView.theme = resolvedTheme;\n};\nnew MutationObserver(syncResolvedTheme).observe(document.documentElement, {\n attributes: true,\n attributeFilter: ['class'],\n});\nconst appViewFor = (view: AssistantViewAvailableDetail) => {\n const key = `${view.id}:${view.resourceUri}`;\n let appView = mountedViews.get(key);\n if (!appView) {\n appView = document.createElement('noodle-app-view') as NoodleAppViewElement;\n appView.client = assistant;\n mountedViews.set(key, appView);\n appViews.append(appView);\n }\n appView.theme = resolvedTheme;\n return appView;\n};\n\nassistant.subscribeChat((state) => {\n renderUIMessageState(state, {\n respond: (id, response) => assistant.respond(id, response),\n });\n const activeViewKeys = new Set<string>();\n for (const message of state.messages) {\n for (const part of message.parts) {\n if (part.type === 'data-view') {\n const key = `${part.data.id}:${part.data.resourceUri}`;\n activeViewKeys.add(key);\n appViewFor(part.data).view = part.data;\n }\n }\n }\n for (const [key, mountedView] of mountedViews) {\n if (!activeViewKeys.has(key)) {\n mountedView.remove();\n mountedViews.delete(key);\n }\n }\n});\n```\n\n`renderUIMessageState` is application code. It must present a complete schema-aware confirmation or input\nform and require an explicit user gesture before using `respond`; never call `respond` while scanning a\ntranscript snapshot.\n\n`theme=\"auto\"` follows the operating-system preference, not a SaaS-owned toggle. Pass the resolved\n`light`/`dark` theme to `NoodleAssistant` and `<noodle-app-view>`/`NoodleAppView`; updates reach mounted MCP Apps without a\nremount. CSS custom properties inherit through the host, and documented `--ns-assistant-*` variables remain\nthe final integration escape hatch. Server `branding` is shared by widgets and the assistant; there is no\nsecond branding declaration. Text streams progressively. Expired turns re-exchange and retry once;\nconfirmations never replay automatically.\n\nThe customer IdP must place the full tenant API base URL in `tenant.api_base_url`. For example, one verified\ncustomer may receive `https://customer-a.api.noodleseed.dev/v1` and another\n`https://customer-b.api.noodleseed.dev/v1`; both satisfy the declared suffix policy. Application code,\ndeployment variables, and connector arguments do not select the tenant route.\n\n`CUSTOMER_API_CLIENT_ID` and `CUSTOMER_API_CLIENT_SECRET` authenticate only the broker to the fixed exchange\nendpoint. They are not customer API bearer tokens. The exchange endpoint verifies the platform-signed\nsubject assertion and mints a short-lived token scoped to the signed-in user and route binding.\n\n## Launch and qualified-usage proof\n\nFor launch, browser verification, and qualified usage, follow the [embedded assistant guide](https://docs.noodleseed.dev/docs/guides/embedded-assistant).\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## Preview anonymous Help with customer sign-in\n\nUse `noodle dev examples/customer-auth/src/server.ts --access mixed`. Help remains available before sign-in;\nprotected reads advertise descriptors and require their verified scopes/roles to execute. Customer mode\nremains the default. Local Devtools retries a protected call after successful sign-in; cancellation executes\nnothing and leaves Help available. With a hosted service, a new target may use\n`--access mixed` only after every customer tool has an authorization rule. For an existing customer-only\ntarget, preserve its org/app/env, endpoint, issuer, and audience. Follow the public guide: verify an inactive\n`customers` record for the exact version, creating it by unchanged secured redeploy if absent; add and\nvalidate every tool rule; deploy the prepared source as `customers`; recheck the rollback; then adopt mixed\naccess. On pilot failure, use\n`noodle rollback <deployment-id>` with the same org/app/env. This app-history rollback is separate from the\ncompatible hosted service-release floor. Keep the public Help endpoint through the post-deployment ChatGPT\nand customer API pilot. Local behavior and wire checks do not prove that host\njourney. See the\n[customer auth rollback and adoption procedure](https://docs.noodleseed.dev/docs/guides/customer-auth#preserve-an-app-rollback-target-and-adopt)\nfor the exact commands and checks.\n\n## Auth boundary\n\nNoodle Seed verifies the configured OIDC issuer and stable audience, then binds the exact transport-derived\nMCP resource before reading identity or routing claims. Public caller identity contains the user/role/scope\nprojection; the customer route remains private request state.\n\nConnector-backed tools ask the broker for a route-bound delegated credential; only the endpoint key and\nfingerprint enter broker cache/single-flight state or the assertion. The route claim and inbound MCP bearer\ntoken never reach tools, connectors, widgets, model output, or downstream systems. Confirmed actions keep\nthe same URL-blind binding only in private continuation state and reject acceptance-time drift.\n" },
|
|
59
59
|
{ relPath: "examples/customer-auth/noodle.json", content: "{\n \"entrypoint\": \"src/server.ts\",\n \"name\": \"customer-auth\"\n}\n" },
|
|
60
60
|
{ 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" },
|
|
61
|
-
{ relPath: "examples/customer-auth/src/server.ts", content: "import {\n annotations,\n authenticatedWebsite,\n connector,\n customerAuth,\n customerEndpoint,\n embeddedAssistant,\n openAICompatible,\n publicMessaging,\n secret,\n server,\n tool,\n variable,\n z,\n} from '@noodleseed/one';\n\nconst publicHelp = tool('help', {\n title: 'Help with organizations and apps',\n description: 'Explain what customers can do before they sign in.',\n input: z.object({}),\n output: z.object({ help: z.string() }),\n annotations: annotations.readOnly(),\n fulfil() {\n return {\n help: 'Sign in to browse your organizations and apps. Archiving an app requires an administrator and confirmation.',\n };\n },\n});\n\nconst customerApi = customerEndpoint('customer_api', {\n allowedHttpsHostSuffixes: ['api.noodleseed.dev'],\n});\nconst assistantOrigin = variable('ASSISTANT_ORIGIN');\n\nconst noodleseedApi = connector('noodleseed_app_api')\n .version('1.0.0')\n .http({\n baseUrl: customerApi,\n auth: {\n kind: 'delegatedTokenExchange',\n tokenUrl: 'https://id.noodleseed.dev/oauth/token',\n clientId: variable('CUSTOMER_API_CLIENT_ID'),\n clientSecret: secret('CUSTOMER_API_CLIENT_SECRET'),\n scopes: ['organizations:read', 'org_apps:read', 'org_apps:write'],\n audience: 'noodleseed-customer-api',\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 archive_org_app: {\n type: 'action',\n method: 'POST',\n path: '/api/organizations/${args.org_id}/apps/${args.app_id}/archive',\n input: z.object({\n org_id: z.string(),\n app_id: z.string(),\n }),\n output: z.object({ archived: z.boolean() }),\n response: {\n archived: '${response.archived}',\n },\n },\n },\n });\n\nconst CUSTOMER_AUTH_AGENT_GUIDE = {\n description:\n 'Use the signed-in customer context to discover organizations, review their Noodle Seed apps, and archive a selected app when authorized.',\n useWhen: [\n 'A signed-in customer asks which organizations or apps they can access.',\n 'An organization administrator asks to archive one selected app.',\n ],\n workflows: [\n {\n id: 'find_organizations',\n title: 'Find the customer organizations',\n intent: 'Ground later organization-scoped work in the verified customer membership.',\n steps: [\n {\n capability: { kind: 'tool', name: 'list_my_organizations' },\n guidance: 'Use an organization identifier returned by this read in later steps.',\n },\n ],\n },\n {\n id: 'review_organization_apps',\n title: 'Review apps in one organization',\n steps: [\n { capability: { kind: 'tool', name: 'list_my_organizations' } },\n {\n capability: { kind: 'tool', name: 'list_org_apps' },\n guidance: 'List apps only for an organization returned for the signed-in customer.',\n },\n ],\n },\n {\n id: 'archive_organization_app',\n title: 'Archive one organization app',\n steps: [\n { capability: { kind: 'tool', name: 'list_my_organizations' } },\n { capability: { kind: 'tool', name: 'list_org_apps' } },\n {\n capability: { kind: 'tool', name: 'archive_org_app' },\n guidance: 'Archive only the exact app the customer selected after confirmation.',\n },\n ],\n },\n ],\n boundaries: [\n 'Never infer an organization or app identifier that was not returned for the signed-in customer.',\n 'Never claim an app was archived until the confirmed action succeeds.',\n ],\n examples: [\n { prompt: 'Which organizations can I access?', workflow: 'find_organizations' },\n { prompt: 'Show me the apps in this organization.', workflow: 'review_organization_apps' },\n { prompt: 'Archive the app I selected.', workflow: 'archive_organization_app' },\n ],\n} as const;\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 agentGuide: CUSTOMER_AUTH_AGENT_GUIDE,\n interactions: { confirmationFallback: 'host' },\n auth: customerAuth.oidc({\n issuer: 'https://id.noodleseed.dev',\n audience: 'noodleseed-customer-auth-prod',\n claims: {\n id: 'sub',\n email: 'email',\n name: 'name',\n orgs: 'permissions.orgs',\n roles: 'permissions.roles',\n scopes: 'permissions.scopes',\n },\n routing: {\n endpoints: {\n customer_api: { claim: 'tenant.api_base_url' },\n },\n },\n }),\n instructions:\n 'Direct/federated MCP OIDC demo. The customer IdP proves identity and privately selects the tenant API base URL, while the broker supplies delegated credentials and confirmed actions stay bound to the reviewed route.',\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 transport: 'responses',\n }),\n // Exact HTTPS origins; http://localhost:<port> for local dev.\n access: [\n authenticatedWebsite({\n origins: [assistantOrigin, 'https://dev.noodleseed.com', 'http://localhost:3000'],\n sessionClaims: {\n accountTier: { exposeToModel: true },\n },\n history: false, // admin chats are never kept\n }),\n publicMessaging({ channel: 'whatsapp', capabilities: [publicHelp] }),\n publicMessaging({ channel: 'instagram', capabilities: [publicHelp] }),\n ],\n theme: 'auto',\n layout: { mode: 'floating', position: 'bottom-center', panelWidth: 970 },\n behavior: { showPoweredBy: true, showConfirmationDetails: false },\n labels: {\n welcomeHeading: 'How can I help with Noodle Seed?',\n launcherPlaceholder: 'Ask Noodle Seed anything',\n composerPlaceholder: 'Ask about your apps…',\n },\n historyNotice: 'Chats are kept for {days} day(s)',\n presentation: {\n panel: { elevation: 'soft', border: 'subtle' },\n launcher: {\n style: 'pill',\n icon: 'brand-mark',\n status: 'session',\n effect: 'pulse',\n },\n header: {\n mark: 'status',\n badge: { text: 'Workspace online', tone: 'success', indicator: true },\n },\n composer: { leadingIcon: 'brand-mark', shape: 'pill' },\n },\n }),\n },\n [\n publicHelp,\n tool('list_org_apps', {\n title: 'List organization apps',\n description: 'List NoodleSeed.com apps for an organization from its customer API.',\n authorization: {\n discovery: 'public',\n requiredScopes: ['org_apps:read'],\n allowedRoles: ['org_admin', 'org_member'],\n },\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 authorization: { discovery: 'public', requiredScopes: ['organizations:read'] },\n contextProvider: true,\n input: z.object({}),\n // The API returns all of the caller's organizations without pagination; bound the output shape.\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 tool('archive_org_app', {\n title: 'Archive organization app',\n description: 'Archive one NoodleSeed.com app through its customer API after confirmation.',\n authorization: {\n requiredScopes: ['org_apps:write'],\n allowedRoles: ['org_admin'],\n },\n input: z.object({\n org_id: z.string().meta({ title: 'Organization' }),\n app_id: z.string().meta({ title: 'App' }),\n }),\n output: z.object({\n archived: z.boolean(),\n }),\n annotations: annotations.openAction({ destructive: false, confirm: true }),\n fulfil({ input, connectors }) {\n const result = connectors.app_api.archiveOrgApp({\n org_id: input.org_id,\n app_id: input.app_id,\n });\n return {\n archived: result.archived,\n };\n },\n }),\n ],\n);\n" },
|
|
61
|
+
{ relPath: "examples/customer-auth/src/server.ts", content: "import {\n annotations,\n authenticatedWebsite,\n connector,\n customerAuth,\n customerEndpoint,\n embeddedAssistant,\n openAICompatible,\n publicMessaging,\n secret,\n server,\n tool,\n variable,\n z,\n} from '@noodleseed/one';\n\nconst publicHelp = tool('help', {\n title: 'Help with organizations and apps',\n description: 'Explain what customers can do before they sign in.',\n input: z.object({}),\n output: z.object({ help: z.string() }),\n annotations: annotations.readOnly(),\n fulfil() {\n return {\n help: 'Sign in to browse your organizations and apps. Archiving an app requires an administrator and confirmation.',\n };\n },\n});\n\nconst customerApi = customerEndpoint('customer_api', {\n allowedHttpsHostSuffixes: ['api.noodleseed.dev'],\n});\nconst assistantOrigin = variable('ASSISTANT_ORIGIN');\n\nconst noodleseedApi = connector('noodleseed_app_api')\n .version('1.0.0')\n .http({\n baseUrl: customerApi,\n auth: {\n kind: 'delegatedTokenExchange',\n tokenUrl: 'https://id.noodleseed.dev/oauth/token',\n clientId: variable('CUSTOMER_API_CLIENT_ID'),\n clientSecret: secret('CUSTOMER_API_CLIENT_SECRET'),\n scopes: ['organizations:read', 'org_apps:read', 'org_apps:write'],\n audience: 'noodleseed-customer-api',\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 archive_org_app: {\n type: 'action',\n method: 'POST',\n path: '/api/organizations/${args.org_id}/apps/${args.app_id}/archive',\n input: z.object({\n org_id: z.string(),\n app_id: z.string(),\n }),\n output: z.object({ archived: z.boolean() }),\n response: {\n archived: '${response.archived}',\n },\n },\n },\n });\n\nconst CUSTOMER_AUTH_AGENT_GUIDE = {\n description:\n 'Use the signed-in customer context to discover organizations, review their Noodle Seed apps, and archive a selected app when authorized.',\n useWhen: [\n 'A signed-in customer asks which organizations or apps they can access.',\n 'An organization administrator asks to archive one selected app.',\n ],\n workflows: [\n {\n id: 'find_organizations',\n title: 'Find the customer organizations',\n intent: 'Ground later organization-scoped work in the verified customer membership.',\n steps: [\n {\n capability: { kind: 'tool', name: 'list_my_organizations' },\n guidance: 'Use an organization identifier returned by this read in later steps.',\n },\n ],\n },\n {\n id: 'review_organization_apps',\n title: 'Review apps in one organization',\n steps: [\n { capability: { kind: 'tool', name: 'list_my_organizations' } },\n {\n capability: { kind: 'tool', name: 'list_org_apps' },\n guidance: 'List apps only for an organization returned for the signed-in customer.',\n },\n ],\n },\n {\n id: 'archive_organization_app',\n title: 'Archive one organization app',\n steps: [\n { capability: { kind: 'tool', name: 'list_my_organizations' } },\n { capability: { kind: 'tool', name: 'list_org_apps' } },\n {\n capability: { kind: 'tool', name: 'archive_org_app' },\n guidance: 'Archive only the exact app the customer selected after confirmation.',\n },\n ],\n },\n ],\n boundaries: [\n 'Never infer an organization or app identifier that was not returned for the signed-in customer.',\n 'Never claim an app was archived until the confirmed action succeeds.',\n ],\n examples: [\n { prompt: 'Which organizations can I access?', workflow: 'find_organizations' },\n { prompt: 'Show me the apps in this organization.', workflow: 'review_organization_apps' },\n { prompt: 'Archive the app I selected.', workflow: 'archive_organization_app' },\n ],\n} as const;\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 agentGuide: CUSTOMER_AUTH_AGENT_GUIDE,\n interactions: { confirmationFallback: 'host' },\n auth: customerAuth.oidc({\n issuer: 'https://id.noodleseed.dev',\n audience: 'noodleseed-customer-auth-prod',\n claims: {\n id: 'sub',\n email: 'email',\n name: 'name',\n orgs: 'permissions.orgs',\n roles: 'permissions.roles',\n scopes: 'permissions.scopes',\n },\n routing: {\n endpoints: {\n customer_api: { claim: 'tenant.api_base_url' },\n },\n },\n }),\n instructions:\n 'Direct/federated MCP OIDC demo. The customer IdP proves identity and privately selects the tenant API base URL, while the broker supplies delegated credentials and confirmed actions stay bound to the reviewed route.',\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 transport: 'responses',\n }),\n // Exact HTTPS origins; http://localhost:<port> for local dev.\n access: [\n authenticatedWebsite({\n origins: [assistantOrigin, 'https://dev.noodleseed.com', 'http://localhost:3000'],\n sessionClaims: {\n accountTier: { exposeToModel: true },\n },\n history: false, // admin chats are never kept\n }),\n publicMessaging({ channel: 'whatsapp', capabilities: [publicHelp] }),\n publicMessaging({ channel: 'instagram', capabilities: [publicHelp] }),\n publicMessaging({ channel: 'sms', capabilities: [publicHelp] }),\n ],\n theme: 'auto',\n layout: { mode: 'floating', position: 'bottom-center', panelWidth: 970 },\n behavior: { showPoweredBy: true, showConfirmationDetails: false },\n labels: {\n welcomeHeading: 'How can I help with Noodle Seed?',\n launcherPlaceholder: 'Ask Noodle Seed anything',\n composerPlaceholder: 'Ask about your apps…',\n },\n historyNotice: 'Chats are kept for {days} day(s)',\n presentation: {\n panel: { elevation: 'soft', border: 'subtle' },\n launcher: {\n style: 'pill',\n icon: 'brand-mark',\n status: 'session',\n effect: 'pulse',\n },\n header: {\n mark: 'status',\n badge: { text: 'Workspace online', tone: 'success', indicator: true },\n },\n composer: { leadingIcon: 'brand-mark', shape: 'pill' },\n },\n }),\n },\n [\n publicHelp,\n tool('list_org_apps', {\n title: 'List organization apps',\n description: 'List NoodleSeed.com apps for an organization from its customer API.',\n authorization: {\n discovery: 'public',\n requiredScopes: ['org_apps:read'],\n allowedRoles: ['org_admin', 'org_member'],\n },\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 authorization: { discovery: 'public', requiredScopes: ['organizations:read'] },\n contextProvider: true,\n input: z.object({}),\n // The API returns all of the caller's organizations without pagination; bound the output shape.\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 tool('archive_org_app', {\n title: 'Archive organization app',\n description: 'Archive one NoodleSeed.com app through its customer API after confirmation.',\n authorization: {\n requiredScopes: ['org_apps:write'],\n allowedRoles: ['org_admin'],\n },\n input: z.object({\n org_id: z.string().meta({ title: 'Organization' }),\n app_id: z.string().meta({ title: 'App' }),\n }),\n output: z.object({\n archived: z.boolean(),\n }),\n annotations: annotations.openAction({ destructive: false, confirm: true }),\n fulfil({ input, connectors }) {\n const result = connectors.app_api.archiveOrgApp({\n org_id: input.org_id,\n app_id: input.app_id,\n });\n return {\n archived: result.archived,\n };\n },\n }),\n ],\n);\n" },
|
|
62
62
|
{ 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: { 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(manifest.server.assistant?.allowedOrigins).toEqual([\n '${env.ASSISTANT_ORIGIN}',\n 'https://dev.noodleseed.com',\n 'http://localhost:3000',\n ]);\n expect(manifest.server.assistant?.sessionClaims).toEqual({\n accountTier: { exposeToModel: true },\n });\n expect(manifest.server.branding).toMatchObject({\n name: 'Noodle Seed Assistant',\n colorScheme: 'auto',\n });\n expect(manifest.server.auth).toEqual({\n kind: 'oidc',\n issuer: 'https://id.noodleseed.dev',\n audience: 'noodleseed-customer-auth-prod',\n claims: {\n id: 'sub',\n email: 'email',\n name: 'name',\n orgs: 'permissions.orgs',\n roles: 'permissions.roles',\n scopes: 'permissions.scopes',\n },\n routing: {\n endpoints: {\n customer_api: { claim: 'tenant.api_base_url' },\n },\n },\n });\n expect(manifest.server.interactions).toEqual({ confirmationFallback: 'host' });\n expect(manifest.server.agentGuide?.workflows.map((workflow) => workflow.id)).toEqual([\n 'find_organizations',\n 'review_organization_apps',\n 'archive_organization_app',\n ]);\n expect(\n manifest.server.agentGuide?.workflows.find(\n (workflow) => workflow.id === 'archive_organization_app',\n )?.steps,\n ).toEqual([\n { capability: { kind: 'tool', name: 'list_my_organizations' } },\n { capability: { kind: 'tool', name: 'list_org_apps' } },\n {\n capability: { kind: 'tool', name: 'archive_org_app' },\n guidance: 'Archive only the exact app the customer selected after confirmation.',\n },\n ]);\n const catalog = app.toConnectorCatalog();\n expect(catalog?.connectors).toHaveLength(1);\n expect(catalog?.connectors[0]?.http).toMatchObject({\n baseUrl: {\n kind: 'customerEndpoint',\n name: 'customer_api',\n policy: { allowedHttpsHostSuffixes: ['api.noodleseed.dev'] },\n },\n auth: {\n kind: 'delegatedTokenExchange',\n tokenUrl: 'https://id.noodleseed.dev/oauth/token',\n clientId: '${env.CUSTOMER_API_CLIENT_ID}',\n clientSecret: 'CUSTOMER_API_CLIENT_SECRET',\n },\n });\n expect(catalog?.connectors[0]?.operations).toMatchObject({\n list_org_apps: { type: 'read' },\n list_organizations: { type: 'read' },\n archive_org_app: { type: 'action', method: 'POST' },\n });\n expect(catalog?.connectors[0]?.http).not.toHaveProperty('allowedOrigins');\n expect(JSON.stringify({ manifest, catalog })).not.toContain('tenant-a.api.noodleseed.dev');\n expect(manifest.tools.find((tool) => tool.name === 'list_org_apps')?.authorization).toEqual({\n discovery: 'public',\n requiredScopes: ['org_apps:read'],\n allowedRoles: ['org_admin', 'org_member'],\n });\n expect(\n manifest.tools.find((tool) => tool.name === 'list_my_organizations')?.authorization,\n ).toEqual({ discovery: 'public', requiredScopes: ['organizations:read'] });\n const help = manifest.tools.find((tool) => tool.name === 'help');\n expect(help).toBeDefined();\n expect(help?.authorization).toBeUndefined();\n expect(help?.annotations?.readOnlyHint).toBe(true);\n expect(manifest.tools.find((tool) => tool.name === 'archive_org_app')).toMatchObject({\n authorization: {\n requiredScopes: ['org_apps:write'],\n allowedRoles: ['org_admin'],\n },\n annotations: {\n readOnlyHint: false,\n destructiveHint: false,\n openWorldHint: true,\n confirm: true,\n },\n });\n expect(\n manifest.tools.find((tool) => tool.name === 'list_org_apps')?.annotations?.readOnlyHint,\n ).toBe(true);\n });\n});\n" },
|
|
63
63
|
{ relPath: "examples/customer-auth/vitest.config.ts", content: "import { defineConfig } from 'vitest/config';\n\n// Keep the flagship's own contract test executable instead of inheriting the monorepo package-only glob.\nexport default defineConfig({\n test: { include: ['test/**/*.test.ts'] },\n});\n" },
|
|
64
64
|
{ 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,\nproduct-agent guidance, host-neutral distribution metadata, and 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| Product and distribution projections | `agentGuide` supplies grounded cross-capability guidance; `distribution` supplies listing, publisher, legal, image, and review facts separately from the runtime manifest |\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, revision checks, and explicit ticket-bound adoption when an anonymous visitor authenticates |\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 exposes optional host-supplied coordinates to tools and the reserved `noodle_context` MCP adapter; location is an untrusted convenience hint, never an authorization signal or a substitute for explicit input |\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| Fail-closed hydration | The React view treats only the unhydrated, pre-result `{}` envelope as pending; a hydrated empty success remains distinct. It surfaces `isError`, validates required records and identifiers, and withholds ordering actions from malformed results |\n| Upstream MCP composition | This synthetic example keeps its data local. For the canonical frozen-tool import, governed upstream invocation, response normalization, and Noodle-owned widget pattern, use the repository's `shopify-storefront` flagship rather than copying another composition surface here |\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\nThe same `server.ts` declares `distribution` metadata for host adapters. It references real packaged images\nand keeps listing copy, support/legal URLs, and positive/negative review scenarios outside the canonical App\nPackage and Runtime Artifact. Explicit OpenAI and Claude adapters project those facts with the generated\nproduct skill; installable plugin archives and directory-submission dossiers remain separate outputs.\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\nWhen a developer finalizes visual feedback in the local Design experience, a coding agent can inspect the\nlatest project-local brief without a path or session id:\n\n```sh\nnoodle design inspect --latest --json\n```\n\nThe agent should locate the captured elements in this example's authored React source, preserve the listed\nbehavior and accessibility constraints, and verify every acceptance check before changing unrelated UI.\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## Export an OpenAI plugin\n\nThis flagship includes the guided workflows, listing metadata, review cases, and image assets needed to test\nOpenAI export. See the public [product-agent guide](https://docs.noodleseed.dev/docs/guides/product-agent-guides#export-an-openai-package)\nfor the current package workflow and boundaries.\n\nAgainst its deployed MCP URL, generate the Food Ordering submission candidate with:\n\n```sh\nnoodle export plugin openai \\\n --state submission \\\n --mcp-url https://food-ordering.noodleseed.app/mcp \\\n --category \"Food & Drink\" \\\n --output food-ordering-openai.zip\n```\n\nExtract `food-ordering-openai.zip` before using the portal. Upload\n`submission/chatgpt-app-submission.json` to the Codex-assisted import field and\n`submission/food-ordering-skill.zip` to **With MCP → Skills**. The outer ZIP is the complete review kit and\nis not itself a valid skill upload; `submission/README.md` repeats the portal steps.\n\nAfter registering that same URL in ChatGPT developer mode, substitute its real technical ID to generate the\nFood Ordering local test package:\n\n```sh\nnoodle export plugin openai \\\n --state local \\\n --mcp-url https://food-ordering.noodleseed.app/mcp \\\n --category \"Food & Drink\" \\\n --registered-app-id plugin_asdk_app_0123456789abcdef0123456789abcdef \\\n --output food-ordering-openai-local.zip\n```\n\n## Export for Claude\n\nClaude Code plugin packaging and Anthropic Connector Directory review are separate outputs. Generate the\ninstallable plugin repository with:\n\n```sh\nnoodle export plugin claude \\\n --mcp-url https://food-ordering.noodleseed.app/mcp \\\n --output food-ordering-claude.zip\n```\n\nGenerate the credential-free operator dossier for the remote Connector Directory with:\n\n```sh\nnoodle export connector claude \\\n --mcp-url https://food-ordering.noodleseed.app/mcp \\\n --auth none \\\n --category \"Food & Drink\" \\\n --output food-ordering-anthropic-connector.zip\n```\n\nThe dossier is deliberately marked `portalUploadable: false`: it gathers the listing, tool annotations,\nuse cases, allowed-link candidates, test-account guidance, and MCP App screenshot evidence, but a human must\nverify ownership/compliance and enter the final answers in Anthropic's portal. The plugin ZIP does not\ncontain this dossier.\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 deploy --org demo --app food-ordering --env prod --access owner-only\nnoodle open\n```\n\nThat one deploy command preflights the complete target, creates a missing app/environment, and verifies\nhosted readiness. If it is interrupted, rerun the same command to resume the unfinished operation without a\nduplicate deployment. Use `--access org-members` for an org-wide internal demo. This example has no\nconnector secrets and does not include tokens, caller-key mechanisms, or `.env.noodle` values.\n\n### Publish an immutable host archive\n\nOnly when this demo is intentionally being prepared for an external directory, deploy it with exact public\naccess and use the returned deployment ID to publish the matching local source:\n\n```sh\nnoodle deploy --org demo --app food-ordering --env prod --access public\nnoodle distributions publish <deployment-id> src/server.ts --target openai --category \"Food & Drink\"\nnoodle distributions list <deployment-id> --target openai\nnoodle distributions readiness <distribution-id> --status ready --note \"Archive and review evidence checked\"\nnoodle distributions release <distribution-id> --visibility private\nnoodle distributions grant <distribution-id> --expires-in 900\nnoodle distributions download <distribution-id> --output food-ordering-openai.zip\n```\n\nPublish fails if local `src/server.ts` no longer compiles to that deployment's package snapshot. Readiness is\nan explicit operator claim, the private release keeps anonymous discovery off, and the grant prints one\nsensitive exact-version reviewer URL. Record `noodle distributions review` only after a human observes the\nreal portal state. Public Noodle delivery, rollback, deprecation, and terminal revocation are separate explicit\nactions; none submits to a directory or claims acceptance.\n\n## Demo Assets\n\nThe packaged demo images live under `assets/`. The current app uses `assets/noodle-bowl.jpg` as the server\nbranding image. Its three distribution screenshots are real, response-only MCP App captures from Noodle\nDevtools at 2× device scale; each is 1640×970 PNG and has the producing user prompt next to its `asset(...)`\nreference in `server.ts`.\n\nImage sources:\n\n- `assets/noodle-bowl.jpg` — Unsplash photo\n [`IRv8V9Hb8gI`](https://unsplash.com/photos/IRv8V9Hb8gI), downloaded from Unsplash.\n- `assets/food-ordering-stores.png` — store-discovery state produced by “Help me build a noodle order for\n pickup.”\n- `assets/food-ordering-menu.png` — Harbor Noodles menu state produced by “Show me the Harbor Noodles\n menu.”\n- `assets/food-ordering-handoff.png` — checkout-handoff state produced by “Review my spicy miso bowl order\n before checkout.”\n\nThe Unsplash branding photo is free to use under the [Unsplash License](https://unsplash.com/license);\nattribution is not required, but the source note is kept here for provenance.\n" },
|
|
@@ -176,6 +176,7 @@ export const COMPILE_ERROR_CODES = [
|
|
|
176
176
|
'assistant_capability_unknown',
|
|
177
177
|
'assistant_public_user_reference',
|
|
178
178
|
'assistant_public_effect_unconfirmed',
|
|
179
|
+
'tool_anonymous_requires_identity',
|
|
179
180
|
'interaction_action_invalid',
|
|
180
181
|
'interaction_field_unknown',
|
|
181
182
|
'interaction_field_control_mismatch',
|
|
@@ -122,6 +122,7 @@ export function renderCustomerAuthAuthoringSections() {
|
|
|
122
122
|
'```',
|
|
123
123
|
'',
|
|
124
124
|
"Use `authorization.discovery: 'public'` on a nonempty scope/role rule to expose its MCP descriptor before sign-in. Omission and explicit 'authorized' preserve filtered discovery and compile identically. Visibility grants no execution or product-skill eligibility; scopes remain ALL and roles ANY. Noodle derives securitySchemes; never author them.",
|
|
125
|
+
"On `public` and `mixed` MCP, anonymous callers may run only tools marked `annotations.readOnly()` or declared `anonymous: 'allowed'`; any other tool answers them with a 401 sign-in challenge in `mixed` and 403 in `public`. Local `noodle dev` treats you as the caller and does not apply this rule, so check the hosted app before release. Add `anonymous: 'allowed'` only to a low-risk handoff such as a contact or callback request, never to a tool that reads `user` or declares `authorization` (compile error `tool_anonymous_requires_identity`). The website assistant keeps its own `publicWebsite({ capabilities })` allow-list.",
|
|
125
126
|
'For mixed customer preview, keep server customerAuth and adopt on the same org/app/env, endpoint, issuer and audience; keep the separate public Help endpoint during the pilot, and never create overlapping active issuer/audience ownership. Before any mixed deploy or access change, leave only Help unrestricted, add authorization to every customer tool, and validate plus preview with `noodle dev --access mixed`. For an existing customer-only app, first run `noodle deployments list --org <org> --app <app> --env <env> --json` and inspect an inactive `customers` record for the exact server version. If none exists, record the active ID, redeploy the unchanged secured source to the same version with `--access customers`, then list and inspect again to prove that ID is now inactive; recording it alone preserves nothing. Next deploy the policy-prepared source with `--access customers`, verify the original rollback record remains inactive, and only then run `noodle access set mixed ... --version <version>`. On pilot failure use `noodle rollback <deployment-id> --org <org> --app <app> --env <env> --reason <text>`. This app-history rollback differs from the compatible hosted service-release floor. Invalid supplied credentials fail, with no platform-human fallback. Broker exchange remains required. Local Devtools retries after successful sign-in and leaves Help available after cancellation without executing the protected tool. After deployment, test anonymous discovery, sign-in, cancellation, authenticated retry and expiry in the actual host before customer cutover; local/SDK checks do not prove ChatGPT or another host.',
|
|
126
127
|
'',
|
|
127
128
|
'Role values are trusted only from the explicitly configured claim path (or the platform-private bridge role claim). Direct OIDC scopes default to standard `scope`, `scp`, or `scopes` claims unless `claims.scopes` is configured. Embedded-assistant backends pass verified `user.roles` and `user.scopes` separately during `createAssistantSession(...)`; page context never grants either. Claim values must be a string or string array; malformed or oversized values fail closed. Tools retain authored order in `tools/list`; protected descriptors are omitted unless explicitly public, and an unauthorized direct call is still denied before argument validation or connector execution. Scope denials use MCP OAuth step-up metadata without disclosing role names.',
|
|
@@ -29,10 +29,12 @@ export function renderEmbeddedAssistantReference() {
|
|
|
29
29
|
'- Verify the boundary',
|
|
30
30
|
'- Troubleshooting: symptom to diagnosis',
|
|
31
31
|
'',
|
|
32
|
-
'## Public messaging: WhatsApp
|
|
32
|
+
'## Public messaging: WhatsApp, Instagram DMs and SMS',
|
|
33
33
|
'',
|
|
34
34
|
'Reuse the same server and public read-only capabilities with `publicMessaging({ channel: "whatsapp", capabilities: [product, siteIdentity] })` in `embeddedAssistant.access`. Import `publicMessaging` from `@noodleseed/one`. This projection is explicit: knowledge, pure public reads, and a `collect` opener listed with its confirmed native-record action. The platform’s natural collection gathers the opener’s fields in conversation, asks the consent question, renders the review verbatim and confirms; a field marked `private: true` never reaches the conversation model or the transcript. Other writes, connector-backed lookups, customer identity and browser widgets without an interaction are not supported. A WhatsApp participant is anonymous; never infer a customer account from its phone number. Declare Instagram direct messages the same way with `publicMessaging({ channel: "instagram", capabilities })`: at most one surface per channel, each with its own capabilities and instructions. The surface answers once an operator connects the professional account and its linked Facebook Page with `noodle channels instagram configure`, `webhook configure`, `doctor` and `enable` on a service advertising `features.instagram: 1`; replies are plain text of at most 1000 bytes, and an Instagram participant is anonymous too.',
|
|
35
35
|
'',
|
|
36
|
+
'For SMS, declare `publicMessaging({ channel: "sms", capabilities: [product, siteIdentity] })`. The first Twilio profile supports knowledge and pure public reads only; collection, writes, widgets and customer identity are unavailable. An operator uses `noodle channels sms configure`, `webhook configure`, `doctor` and `enable` with an SMS-capable Twilio number, account/number SIDs and an Auth Token secret reference on a service advertising `features.sms: 1`. Use concise plain text (aim for 300 characters; the platform bounds replies to 600). STOP/START/HELP are provider controls, never model prompts. Registration, number rental and per-segment charges remain with the Twilio account owner; technical readiness does not prove carrier delivery.',
|
|
37
|
+
'',
|
|
36
38
|
'Operator setup uses `noodle channels whatsapp configure`, managed secret references, `webhook configure`, `doctor`, and `enable`. Start paused. Only a service advertising `features.whatsapp: 1`, durable storage, a running worker, a verified cost bound and matching provider webhook can enable. Use `noodle channels whatsapp --help` for the complete operator command catalog. Keep real keys in `noodle secrets`, never in source, flags or messages. ChatGPT and Claude continue to use the same app MCP endpoint with their own host configuration.',
|
|
37
39
|
'',
|
|
38
40
|
'## Collect interactions',
|
|
@@ -107,6 +107,7 @@ function invocationContextMessages(context) {
|
|
|
107
107
|
}
|
|
108
108
|
/** What each messaging app renders; Instagram caps a reply at 1000 bytes, so the model aims well under. */
|
|
109
109
|
const MESSAGING_PROFILES = {
|
|
110
|
+
sms: 'SMS surface: concise plain text, no Markdown, at most 300 characters. Use bare URLs and answer one question at a time.',
|
|
110
111
|
whatsapp: 'WhatsApp surface: concise plain text, at most 3500 characters.',
|
|
111
112
|
instagram: 'Instagram DM surface: plain text only, no Markdown or lists, at most 600 characters, bare URLs.',
|
|
112
113
|
};
|
|
@@ -36,6 +36,27 @@ export declare class ChannelCoordinator {
|
|
|
36
36
|
actor: string;
|
|
37
37
|
readyRevision?: number | undefined;
|
|
38
38
|
readyAt?: number | undefined;
|
|
39
|
+
} | {
|
|
40
|
+
channel: "sms";
|
|
41
|
+
provider: "twilio";
|
|
42
|
+
accountSid: string;
|
|
43
|
+
phoneNumberSid: string;
|
|
44
|
+
phoneNumber: string;
|
|
45
|
+
id: string;
|
|
46
|
+
tenant: TenantRef;
|
|
47
|
+
apiKeySecret: string;
|
|
48
|
+
deploymentId: string;
|
|
49
|
+
capabilities: readonly import("./channel-types.js").ChannelCapability[];
|
|
50
|
+
supportEmail: string;
|
|
51
|
+
revision: number;
|
|
52
|
+
generation: number;
|
|
53
|
+
state: "paused" | "enabled" | "disconnected";
|
|
54
|
+
limits: import("./channel-types.js").ChannelLimits;
|
|
55
|
+
createdAt: number;
|
|
56
|
+
updatedAt: number;
|
|
57
|
+
actor: string;
|
|
58
|
+
readyRevision?: number | undefined;
|
|
59
|
+
readyAt?: number | undefined;
|
|
39
60
|
} | {
|
|
40
61
|
provider: import("./channel-types.js").ChannelProvider;
|
|
41
62
|
phoneNumberId: string;
|
|
@@ -84,6 +105,27 @@ export declare class ChannelCoordinator {
|
|
|
84
105
|
actor: string;
|
|
85
106
|
readyRevision?: number | undefined;
|
|
86
107
|
readyAt?: number | undefined;
|
|
108
|
+
} | {
|
|
109
|
+
channel: "sms";
|
|
110
|
+
provider: "twilio";
|
|
111
|
+
accountSid: string;
|
|
112
|
+
phoneNumberSid: string;
|
|
113
|
+
phoneNumber: string;
|
|
114
|
+
id: string;
|
|
115
|
+
tenant: TenantRef;
|
|
116
|
+
apiKeySecret: string;
|
|
117
|
+
deploymentId: string;
|
|
118
|
+
capabilities: readonly import("./channel-types.js").ChannelCapability[];
|
|
119
|
+
supportEmail: string;
|
|
120
|
+
revision: number;
|
|
121
|
+
generation: number;
|
|
122
|
+
state: "paused" | "enabled" | "disconnected";
|
|
123
|
+
limits: import("./channel-types.js").ChannelLimits;
|
|
124
|
+
createdAt: number;
|
|
125
|
+
updatedAt: number;
|
|
126
|
+
actor: string;
|
|
127
|
+
readyRevision?: number | undefined;
|
|
128
|
+
readyAt?: number | undefined;
|
|
87
129
|
} | {
|
|
88
130
|
provider: import("./channel-types.js").ChannelProvider;
|
|
89
131
|
phoneNumberId: string;
|
|
@@ -127,6 +169,27 @@ export declare class ChannelCoordinator {
|
|
|
127
169
|
actor: string;
|
|
128
170
|
readyRevision?: number | undefined;
|
|
129
171
|
readyAt?: number | undefined;
|
|
172
|
+
} | {
|
|
173
|
+
channel: "sms";
|
|
174
|
+
provider: "twilio";
|
|
175
|
+
accountSid: string;
|
|
176
|
+
phoneNumberSid: string;
|
|
177
|
+
phoneNumber: string;
|
|
178
|
+
id: string;
|
|
179
|
+
tenant: TenantRef;
|
|
180
|
+
apiKeySecret: string;
|
|
181
|
+
deploymentId: string;
|
|
182
|
+
capabilities: readonly import("./channel-types.js").ChannelCapability[];
|
|
183
|
+
supportEmail: string;
|
|
184
|
+
revision: number;
|
|
185
|
+
generation: number;
|
|
186
|
+
state: "paused" | "enabled" | "disconnected";
|
|
187
|
+
limits: import("./channel-types.js").ChannelLimits;
|
|
188
|
+
createdAt: number;
|
|
189
|
+
updatedAt: number;
|
|
190
|
+
actor: string;
|
|
191
|
+
readyRevision?: number | undefined;
|
|
192
|
+
readyAt?: number | undefined;
|
|
130
193
|
} | {
|
|
131
194
|
provider: import("./channel-types.js").ChannelProvider;
|
|
132
195
|
phoneNumberId: string;
|
|
@@ -151,6 +214,11 @@ export declare class ChannelCoordinator {
|
|
|
151
214
|
block(id: string, participantId: string, actor: string, key: string, until: number | null): Promise<void>;
|
|
152
215
|
unblock(id: string, participantId: string, actor: string, key: string): Promise<void>;
|
|
153
216
|
blocks(id: string, after?: string): Promise<ChannelBlock[]>;
|
|
217
|
+
optouts(id: string, after?: string): Promise<{
|
|
218
|
+
at: number;
|
|
219
|
+
blocked: boolean;
|
|
220
|
+
participantId: string;
|
|
221
|
+
}[]>;
|
|
154
222
|
events(id: string, after?: string): Promise<ChannelEvent[]>;
|
|
155
223
|
forget(id: string, participantId: string, actor: string, key: string): Promise<void>;
|
|
156
224
|
updateLimits(id: string, input: ChannelLimitsInput, actor: string, key: string, revision: number): Promise<{
|
|
@@ -173,6 +241,27 @@ export declare class ChannelCoordinator {
|
|
|
173
241
|
actor: string;
|
|
174
242
|
readyRevision?: number | undefined;
|
|
175
243
|
readyAt?: number | undefined;
|
|
244
|
+
} | {
|
|
245
|
+
channel: "sms";
|
|
246
|
+
provider: "twilio";
|
|
247
|
+
accountSid: string;
|
|
248
|
+
phoneNumberSid: string;
|
|
249
|
+
phoneNumber: string;
|
|
250
|
+
id: string;
|
|
251
|
+
tenant: TenantRef;
|
|
252
|
+
apiKeySecret: string;
|
|
253
|
+
deploymentId: string;
|
|
254
|
+
capabilities: readonly import("./channel-types.js").ChannelCapability[];
|
|
255
|
+
supportEmail: string;
|
|
256
|
+
revision: number;
|
|
257
|
+
generation: number;
|
|
258
|
+
state: "paused" | "enabled" | "disconnected";
|
|
259
|
+
limits: import("./channel-types.js").ChannelLimits;
|
|
260
|
+
createdAt: number;
|
|
261
|
+
updatedAt: number;
|
|
262
|
+
actor: string;
|
|
263
|
+
readyRevision?: number | undefined;
|
|
264
|
+
readyAt?: number | undefined;
|
|
176
265
|
} | {
|
|
177
266
|
provider: import("./channel-types.js").ChannelProvider;
|
|
178
267
|
phoneNumberId: string;
|