@noodleseed/one 0.136.0 → 0.138.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/assistant-ops.d.ts.map +1 -1
- package/dist/commands/assistant-ops.js +30 -1
- package/dist/commands/assistant-ops.js.map +1 -1
- package/dist/commands/deploy-first-flow.d.ts +2 -0
- package/dist/commands/deploy-first-flow.d.ts.map +1 -1
- package/dist/commands/deploy-first-flow.js +46 -14
- package/dist/commands/deploy-first-flow.js.map +1 -1
- package/dist/commands/deploy-ops.d.ts.map +1 -1
- package/dist/commands/deploy-ops.js +27 -1
- package/dist/commands/deploy-ops.js.map +1 -1
- package/dist/commands/deploy-preflight.d.ts +1 -0
- package/dist/commands/deploy-preflight.d.ts.map +1 -1
- package/dist/commands/deploy-preflight.js +1 -0
- package/dist/commands/deploy-preflight.js.map +1 -1
- package/dist/commands/deploy-status-ops.d.ts +1 -0
- package/dist/commands/deploy-status-ops.d.ts.map +1 -1
- package/dist/commands/deploy-status-ops.js +59 -4
- package/dist/commands/deploy-status-ops.js.map +1 -1
- package/dist/commands/deployments-ops.d.ts.map +1 -1
- package/dist/commands/deployments-ops.js +2 -0
- package/dist/commands/deployments-ops.js.map +1 -1
- package/dist/commands/diagnostics-ops.d.ts +1 -0
- package/dist/commands/diagnostics-ops.d.ts.map +1 -1
- package/dist/commands/diagnostics-ops.js +3 -0
- package/dist/commands/diagnostics-ops.js.map +1 -1
- package/dist/deploy.d.ts +3 -0
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js +4 -0
- package/dist/deploy.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/node_modules/@noodle-borg/admission-limits/dist/envelope.js +1 -1
- package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +4 -4
- package/node_modules/@noodle-borg/agent-kit/dist/generated/surface.js +1 -0
- package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-ref.js +22 -13
- package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-store.d.ts +6 -2
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-store.js +5 -2
- package/node_modules/@noodle-borg/assistant-gateway/dist/model-request.js +37 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/model-runtime.js +4 -0
- package/node_modules/@noodle-borg/{service/dist/assistant-model-stream.js → assistant-gateway/dist/model-stream.js} +34 -2
- package/node_modules/@noodle-borg/assistant-gateway/dist/public-session.d.ts +3 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/public-session.js +2 -1
- package/node_modules/@noodle-borg/assistant-gateway/dist/public-turn.d.ts +4 -1
- package/node_modules/@noodle-borg/assistant-gateway/dist/public-turn.js +2 -1
- package/node_modules/@noodle-borg/assistant-gateway/dist/surface-budget.d.ts +6 -1
- package/node_modules/@noodle-borg/assistant-gateway/dist/surface-budget.js +11 -5
- package/node_modules/@noodle-borg/assistant-gateway/package.json +5 -1
- package/node_modules/@noodle-borg/authoring/dist/assistant.d.ts +10 -2
- package/node_modules/@noodle-borg/authoring/dist/assistant.js +4 -0
- package/node_modules/@noodle-borg/authoring/dist/index.d.ts +1 -1
- package/node_modules/@noodle-borg/authoring/dist/index.js +1 -1
- package/node_modules/@noodle-borg/authoring/dist/load-authored.js +1 -1
- package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-assistant.js +17 -0
- package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-hosted-deployment.js +14 -0
- package/node_modules/@noodle-borg/compiler/dist/artifact/types.d.ts +1 -7
- package/node_modules/@noodle-borg/compiler/dist/manifest/schema.d.ts +20 -8
- package/node_modules/@noodle-borg/compiler/dist/manifest/schema.js +12 -8
- package/node_modules/@noodle-borg/control-plane/dist/portable.d.ts +2 -0
- package/node_modules/@noodle-borg/control-plane/dist/portable.js +1 -0
- package/node_modules/@noodle-borg/control-plane/dist/rollback-deployment.d.ts +61 -0
- package/node_modules/@noodle-borg/{service/dist/operations → control-plane/dist}/rollback-deployment.js +3 -2
- package/node_modules/@noodle-borg/control-plane/dist/rollback-result.d.ts +19 -0
- package/node_modules/@noodle-borg/control-plane/package.json +3 -1
- package/node_modules/@noodle-borg/developer-mcp/dist/contracts.js +4 -0
- package/node_modules/@noodle-borg/developer-mcp/dist/widgets/app-overview.js +1 -1
- package/node_modules/@noodle-borg/developer-mcp/dist/widgets/deployment-detail.js +1 -1
- package/node_modules/@noodle-borg/module/dist/contract.d.ts +1 -0
- package/node_modules/@noodle-borg/module/dist/deployment-automation.d.ts +1 -1
- package/node_modules/@noodle-borg/module/dist/endpoint-url.d.ts +11 -0
- package/node_modules/@noodle-borg/module/dist/endpoint-url.js +20 -0
- package/node_modules/@noodle-borg/module/dist/index.d.ts +1 -0
- package/node_modules/@noodle-borg/module/dist/index.js +1 -0
- package/node_modules/@noodle-borg/observability/dist/assistant-usage.js +217 -0
- package/node_modules/@noodle-borg/observability/dist/index.js +2 -0
- package/node_modules/@noodle-borg/{service → observability}/dist/request-event-metrics.js +5 -2
- package/node_modules/@noodle-borg/observability/dist/request-events-postgres.js +6 -0
- package/node_modules/@noodle-borg/observability/dist/request-events.js +2 -0
- package/node_modules/@noodle-borg/service/dist/assistant-bindings.js +3 -1
- package/node_modules/@noodle-borg/service/dist/developer-mcp/control-plane-adapter.js +3 -3
- package/node_modules/@noodle-borg/service/dist/developer-mcp/inspection.js +2 -1
- package/node_modules/@noodle-borg/service/dist/registry-access.js +73 -24
- package/node_modules/@noodle-borg/service/dist/registry-deploy-transaction.js +5 -2
- package/node_modules/@noodle-borg/service/dist/registry-helpers.d.ts +5 -9
- package/node_modules/@noodle-borg/service/dist/registry-helpers.js +4 -18
- package/node_modules/@noodle-borg/service/dist/registry-rollback.js +3 -1
- package/node_modules/@noodle-borg/service/dist/registry-status.js +4 -1
- package/node_modules/@noodle-borg/service/dist/registry-targets.js +4 -4
- package/node_modules/@noodle-borg/service/dist/registry-types.d.ts +11 -16
- package/node_modules/@noodle-borg/service/dist/registry.d.ts +8 -5
- package/node_modules/@noodle-borg/service/dist/registry.js +8 -5
- package/node_modules/@noodle-borg/service/dist/routes/access.js +19 -1
- package/node_modules/@noodle-borg/service/dist/routes/analytics-dispatch.js +17 -1
- package/node_modules/@noodle-borg/service/dist/routes/analytics.js +1 -1
- package/node_modules/@noodle-borg/service/dist/routes/assistant-agent.js +73 -59
- package/node_modules/@noodle-borg/service/dist/routes/assistant-doctor.js +1 -1
- package/node_modules/@noodle-borg/service/dist/routes/assistant-model-binding.js +28 -0
- package/node_modules/@noodle-borg/service/dist/routes/assistant-public-session.js +18 -0
- package/node_modules/@noodle-borg/service/dist/routes/assistant-public-turn.js +6 -0
- package/node_modules/@noodle-borg/service/dist/routes/assistant-usage.js +42 -0
- package/node_modules/@noodle-borg/service/dist/routes/assistant.js +20 -3
- package/node_modules/@noodle-borg/service/dist/routes/auth-doctor-live.js +1 -2
- package/node_modules/@noodle-borg/service/dist/routes/control-plane.js +49 -4
- package/node_modules/@noodle-borg/service/dist/routes/deploy-preflight.js +11 -1
- package/node_modules/@noodle-borg/service/dist/routes/endpoint-enrichment.js +2 -1
- package/node_modules/@noodle-borg/service/dist/routes/paths.js +4 -0
- package/node_modules/@noodle-borg/service/dist/routes/rollback.js +1 -1
- package/node_modules/@noodle-borg/service/dist/service.js +6 -0
- package/node_modules/@noodle-borg/service/dist/store/in-memory.d.ts +2 -5
- package/node_modules/@noodle-borg/service/dist/store/in-memory.js +9 -21
- package/node_modules/@noodle-borg/service/dist/store/json-file.d.ts +2 -5
- package/node_modules/@noodle-borg/service/dist/store/json-file.js +8 -20
- package/node_modules/@noodle-borg/service/dist/store/records.js +39 -0
- package/node_modules/@noodle-borg/service/dist/store.d.ts +21 -10
- package/node_modules/@noodle-borg/service/package.json +5 -1
- package/node_modules/@noodle-borg/wire-contracts/dist/control-plane.d.ts +120 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/control-plane.js +123 -1
- package/node_modules/@noodle-borg/wire-contracts/dist/index.d.ts +4 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/index.js +15 -1
- package/node_modules/@noodleseed/assistant/README.md +43 -8
- package/node_modules/@noodleseed/assistant/package.json +16 -2
- package/package.json +2 -2
|
@@ -27,7 +27,7 @@ export const BUNDLED_EXAMPLE_FILES = [
|
|
|
27
27
|
{ relPath: "examples/acme-bistro/src/views/widget-style.css", content: ":root {\n color-scheme: light dark;\n font-family:\n Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n --nw-bg: #ffffff;\n --nw-surface: #fdf4f3;\n --nw-text: #1f1413;\n --nw-muted: #7a5f5c;\n --nw-border: #efd9d6;\n /* 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" },
|
|
28
28
|
{ relPath: "examples/acme-bistro/test/server.test.ts", content: "import { describe, expect, it } from 'vitest';\nimport app from '../src/server.js';\n\ndescribe('acme-bistro example', () => {\n it('exports a Noodle server definition', () => {\n expect(typeof app.toManifest).toBe('function');\n });\n\n it('declares the payment-only handoff domain', async () => {\n // End-to-end: the order completes in chat; only payment hands off to Acme's checkout.\n const text = JSON.stringify(await app.toManifest());\n expect(text).toContain('https://pay.acme.example');\n });\n\n it('exposes the menu widget, cart helpers, and checkout tool', async () => {\n const text = JSON.stringify(await app.toManifest());\n expect(text).toContain('show_menu');\n expect(text).toContain('add_to_cart');\n expect(text).toContain('create_checkout');\n });\n});\n" },
|
|
29
29
|
{ 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" },
|
|
30
|
-
{ 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**, and a worked\n**design-first** artifact (the UX spec + wireframe below). 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 three tools onto\nAcme's marketing site for a visitor with **no account and no session backend**:\n\n```ts\naccess: publicWebsite({\n origins: ['https://getaways.acme.example'],\n capabilities: [discoverGetaways, createHandoff, shortlistGetaway],\n instructions:\n 'Be a friendly, consultative travel guide, never pushy. Help visitors narrow a getaway before suggesting the next useful step.',\n}),\n```\n\nThere is no second tool set and no second app — one `server.ts`, projected onto another front door.\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 public assistant turns, never MCP\n`initialize` or another assistant surface.\n`capabilities` is the entire externally reachable surface, so it stays short enough to review at a glance\nand closed by default: a tool added to this server later is unreachable from the website until someone\nlists it. A tool that needed a signed-in user could not be listed here at all (the compiler rejects it);\nserve those from `authenticatedWebsite({ origins })` instead, where Acme's own backend proves who the\nvisitor is, or add `signIn: true` so visitors sign in mid-conversation through Acme own login.\n\n## Design spec (write this before the code)\n\n- **Funnel boundary** — Discover and shortlist a getaway in ChatGPT. Booking, payment, and the account\n live on `acme.example`, reached only after the handoff. No payment or per-user auth in chat.\n- **Personas** — the undecided browser (\"somewhere warm in June?\"), the near-decided planner (has a vibe\n and month, wants options and a fast handoff).\n- **Top-3 prioritized user flows**\n 1. **Discover** — \"warm beach trip in June for 2\" → `discover_getaways` renders the carousel.\n 2. **Shortlist** — pick a destination in the widget → `shortlist_getaway` (widget-only) records it.\n 3. **Handoff** — \"Continue on Acme\" → `create_handoff` emits the deep link → opens off-app.\n- **Tools** — `discover_getaways` (model-visible, renders the widget), `create_handoff` (model-visible,\n emits the deep link), `shortlist_getaway` (widget-only helper, hidden from the model).\n- **Widgets + display modes** — `DiscoveryCarousel` as an inline card that expands to fullscreen for\n browsing. No picture-in-picture (nothing is live/ongoing).\n- **Grounding sources** — the destination catalog in `src/server.ts` is Acme's own data; the widget never\n invents a place, price, or best-month.\n- **Handoff domains** — `https://book.acme.example`, `https://acme.example` (the server\n `handoff.allowedDomains`; the deep link carries `dest`, `month`, `pax`, and `src=chatgpt` for\n attribution).\n\n## Wireframe (one screen, then the off-app destination)\n\nThe carousel screen is in-app (solid frame); the booking screen is off-app (dashed frame) and reached\nonly after the handoff — it is Acme's own page, never wireframed as if it were in chat.\n\n```html\n<div class=\"phone\"> <!-- in-app: solid frame -->\n <div class=\"chatgpt-header\">ChatGPT · Acme Getaways</div>\n <div class=\"msg user\">somewhere warm in June, 2 of us</div>\n <div class=\"tool-call\">discover_getaways { vibe: \"beach\", month: \"June\", travelers: 2 }</div>\n <div class=\"wcard\">\n <div class=\"wcard-head\">DiscoveryCarousel</div> <!-- component name = code + spec -->\n <div class=\"wcard-body\">\n <div class=\"dest\">Coral Bay · from $890 · best May–Sep [Shortlist]</div>\n <div class=\"dest\">Harbor City · from $980 · best Sep–Nov [Shortlist]</div>\n <a class=\"cta\">Continue on Acme · Coral Bay</a> <!-- one primary action -->\n </div>\n </div>\n</div>\n<div class=\"phone offapp\"> <!-- off-app: dashed frame -->\n <div class=\"browser-header\">book.acme.example/plan?dest=coral_bay&month=June&pax=2&src=chatgpt</div>\n <div class=\"offapp-body\">Acme booking — payment & account live here.</div>\n</div>\n```\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 secrets and does not include tokens, caller-key mechanisms, or\n`.env.noodle` values. All destinations, prices, and URLs are fictional.\n" },
|
|
30
|
+
{ 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**, and a worked\n**design-first** artifact (the UX spec + wireframe below). 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 three tools onto\nAcme's marketing site for a visitor with **no account and no session backend**:\n\n```ts\naccess: publicWebsite({\n origins: ['https://getaways.acme.example'],\n capabilities: [discoverGetaways, createHandoff, shortlistGetaway],\n instructions:\n 'Be a friendly, consultative travel guide, never pushy. Help visitors narrow a getaway before suggesting the next useful step.',\n}),\n```\n\nThere is no second tool set and no second app — one `server.ts`, projected onto another front door.\nThe flagship selects `model: noodleManaged()`, so an enrolled hosted deployment needs no customer model\nendpoint, name, or key and its artifact remains provider-neutral. Sponsored beta enrollment is exact to the\norg/app/environment and fails closed outside that cohort; `openAICompatible(...)` remains the BYO alternative.\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 public assistant turns, never MCP\n`initialize` or another assistant surface.\n`capabilities` is the entire externally reachable surface, so it stays short enough to review at a glance\nand closed by default: a tool added to this server later is unreachable from the website until someone\nlists it. A tool that needed a signed-in user could not be listed here at all (the compiler rejects it);\nserve those from `authenticatedWebsite({ origins })` instead, where Acme's own backend proves who the\nvisitor is, or add `signIn: true` so visitors sign in mid-conversation through Acme own login.\n\n## Design spec (write this before the code)\n\n- **Funnel boundary** — Discover and shortlist a getaway in ChatGPT. Booking, payment, and the account\n live on `acme.example`, reached only after the handoff. No payment or per-user auth in chat.\n- **Personas** — the undecided browser (\"somewhere warm in June?\"), the near-decided planner (has a vibe\n and month, wants options and a fast handoff).\n- **Top-3 prioritized user flows**\n 1. **Discover** — \"warm beach trip in June for 2\" → `discover_getaways` renders the carousel.\n 2. **Shortlist** — pick a destination in the widget → `shortlist_getaway` (widget-only) records it.\n 3. **Handoff** — \"Continue on Acme\" → `create_handoff` emits the deep link → opens off-app.\n- **Tools** — `discover_getaways` (model-visible, renders the widget), `create_handoff` (model-visible,\n emits the deep link), `shortlist_getaway` (widget-only helper, hidden from the model).\n- **Widgets + display modes** — `DiscoveryCarousel` as an inline card that expands to fullscreen for\n browsing. No picture-in-picture (nothing is live/ongoing).\n- **Grounding sources** — the destination catalog in `src/server.ts` is Acme's own data; the widget never\n invents a place, price, or best-month.\n- **Handoff domains** — `https://book.acme.example`, `https://acme.example` (the server\n `handoff.allowedDomains`; the deep link carries `dest`, `month`, `pax`, and `src=chatgpt` for\n attribution).\n\n## Wireframe (one screen, then the off-app destination)\n\nThe carousel screen is in-app (solid frame); the booking screen is off-app (dashed frame) and reached\nonly after the handoff — it is Acme's own page, never wireframed as if it were in chat.\n\n```html\n<div class=\"phone\"> <!-- in-app: solid frame -->\n <div class=\"chatgpt-header\">ChatGPT · Acme Getaways</div>\n <div class=\"msg user\">somewhere warm in June, 2 of us</div>\n <div class=\"tool-call\">discover_getaways { vibe: \"beach\", month: \"June\", travelers: 2 }</div>\n <div class=\"wcard\">\n <div class=\"wcard-head\">DiscoveryCarousel</div> <!-- component name = code + spec -->\n <div class=\"wcard-body\">\n <div class=\"dest\">Coral Bay · from $890 · best May–Sep [Shortlist]</div>\n <div class=\"dest\">Harbor City · from $980 · best Sep–Nov [Shortlist]</div>\n <a class=\"cta\">Continue on Acme · Coral Bay</a> <!-- one primary action -->\n </div>\n </div>\n</div>\n<div class=\"phone offapp\"> <!-- off-app: dashed frame -->\n <div class=\"browser-header\">book.acme.example/plan?dest=coral_bay&month=June&pax=2&src=chatgpt</div>\n <div class=\"offapp-body\">Acme booking — payment & account live here.</div>\n</div>\n```\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 requires Noodle to enroll the exact target before\nserving; local validation and tool calls do not. All destinations, prices, and URLs are fictional.\n" },
|
|
31
31
|
{ 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" },
|
|
32
32
|
{ relPath: "examples/acme-discovery/design/wireframe.html", content: "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>Acme Getaways × ChatGPT — Discovery Top-of-Funnel Wireframes</title>\n<style>\n @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;700&display=swap');\n * { margin: 0; padding: 0; box-sizing: border-box; }\n body { font-family: 'Inter', -apple-system, sans-serif; background: #f4f5f7; color: #1a1d22; line-height: 1.55; }\n .mono { font-family: 'JetBrains Mono', monospace; }\n\n /* ── Acme Getaways brand tokens (from server branding) ── */\n :root {\n --ac-teal: #0EA5A4;\n --ac-teal-soft: #E4FAF8;\n --ac-teal-border: #9CE6E3;\n --ac-ink: #0B1B1B;\n --ac-slate: #234A48;\n --ac-green: #1F9D6B;\n --ac-green-soft: #E5F6EE;\n --ac-green-border: #A7E2C7;\n --ac-amber: #C2710C;\n --ac-amber-soft: #FBF1DF;\n --ac-amber-border: #F0D49A;\n --ac-blue: #2563C9;\n --ac-blue-soft: #E8EEFB;\n }\n\n /* ── Page Header ── */\n .page-header { background: var(--ac-ink); color: #fff; border-bottom: 3px solid var(--ac-teal); padding: 30px 48px; position: sticky; top: 0; z-index: 100; }\n .page-header h1 { font-size: 22px; font-weight: 800; letter-spacing: -0.4px; }\n .page-header h1 .brand { color: var(--ac-teal); }\n .page-header p { font-size: 13px; color: #9db3b1; margin-top: 4px; }\n .page-header .scope { display: inline-block; margin-top: 10px; font-size: 11px; font-weight: 600; letter-spacing: 0.5px; padding: 4px 12px; background: var(--ac-teal); color: #04201f; border-radius: 4px; }\n\n /* ── Section Nav ── */\n .section-nav { background: #fff; border-bottom: 1px solid #e6e8ec; padding: 12px 48px; display: flex; gap: 22px; font-size: 12px; font-weight: 600; position: sticky; top: 100px; z-index: 99; overflow-x: auto; }\n .section-nav a { color: #8a929c; text-decoration: none; white-space: nowrap; }\n .section-nav a:hover { color: var(--ac-teal); }\n\n .container { max-width: 1480px; margin: 0 auto; padding: 40px 48px 90px; }\n\n /* ── Visual Vocabulary ── */\n .vocab { display: flex; gap: 18px; flex-wrap: wrap; margin: 0 0 26px; padding: 14px 18px; background: #fff; border: 1px solid #e6e8ec; border-radius: 12px; font-size: 12px; color: #555; }\n .vocab-item { display: flex; align-items: center; gap: 8px; }\n .vocab-sw { width: 16px; height: 16px; border-radius: 4px; border: 1px solid rgba(0,0,0,0.1); }\n .vocab-sw.teal { background: var(--ac-teal); }\n .vocab-sw.green { background: var(--ac-green); }\n .vocab-sw.amber { background: var(--ac-amber); }\n .vocab-sw.grey { background: #cdd2d8; }\n .vocab-sw.ink { background: var(--ac-ink); }\n\n /* ── Section ── */\n .section { margin-bottom: 68px; }\n .section-label { font-size: 11px; font-weight: 700; letter-spacing: 1.5px; text-transform: uppercase; color: var(--ac-teal); margin-bottom: 8px; display: block; }\n .section-title { font-size: 26px; font-weight: 800; letter-spacing: -0.5px; margin-bottom: 6px; color: var(--ac-ink); }\n .section-subtitle { font-size: 14px; color: #5c6570; margin-bottom: 22px; max-width: 900px; }\n\n /* ── Rationale Block ── */\n .rationale { background: #fff; border: 1px solid #e6e8ec; border-left: 3px solid var(--ac-teal); border-radius: 10px; padding: 16px 20px; margin-bottom: 22px; max-width: 960px; }\n .rationale h4 { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px; color: #8a929c; margin-bottom: 8px; }\n .rationale p { font-size: 13px; color: #3d454e; line-height: 1.6; margin-bottom: 8px; }\n .rationale p:last-child { margin-bottom: 0; }\n .r-tag { display: inline-block; font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 4px; margin-right: 4px; }\n .r-tag.ux { background: var(--ac-green-soft); color: #15734d; }\n .r-tag.ui { background: var(--ac-blue-soft); color: #1d4fa0; }\n .r-tag.acme { background: var(--ac-teal-soft); color: #067e7c; }\n .r-tag.trust { background: var(--ac-amber-soft); color: #92560a; }\n\n /* ── Phone Row ── */\n .phones-row { display: flex; gap: 26px; overflow-x: auto; padding-bottom: 16px; align-items: stretch; }\n .phone-step { flex-shrink: 0; display: flex; flex-direction: column; align-items: center; }\n .step-label { font-size: 11px; font-weight: 600; color: #99a1ab; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px; text-align: center; max-width: 320px; }\n .step-label small { font-weight: 400; letter-spacing: 0; text-transform: none; color: #b3bac2; display: block; margin-top: 2px; }\n .phone { width: 322px; min-height: 660px; background: #fff; border: 2px solid var(--ac-ink); border-radius: 32px; overflow: hidden; display: flex; flex-direction: column; }\n .phone.offapp { border-color: #b9c0c8; border-style: dashed; }\n .phone-notch { width: 100px; height: 24px; background: var(--ac-ink); border-radius: 0 0 14px 14px; margin: 0 auto; flex-shrink: 0; }\n .phone.offapp .phone-notch { background: #b9c0c8; }\n .phone-screen { padding: 16px; display: flex; flex-direction: column; gap: 12px; flex: 1; }\n .step-arrow { display: flex; align-items: center; justify-content: center; flex-shrink: 0; align-self: center; width: 34px; font-size: 24px; color: #c6ccd3; }\n\n /* ── ChatGPT / Browser Chrome ── */\n .chatgpt-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 0 10px; border-bottom: 1px solid #eef0f2; }\n .chatgpt-header .model-name { font-size: 14px; font-weight: 600; }\n .chatgpt-header .dots { font-size: 18px; color: #aab; letter-spacing: 2px; }\n .browser-header { display: flex; align-items: center; gap: 8px; padding: 8px 0 10px; border-bottom: 1px solid #eef0f2; }\n .browser-header .url { flex: 1; font-size: 9.5px; color: #8a929c; background: #f1f3f5; border-radius: 12px; padding: 6px 10px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }\n .browser-header .lock { color: var(--ac-green); font-size: 11px; }\n\n /* ── Messages ── */\n .msg { max-width: 94%; font-size: 13px; line-height: 1.55; }\n .msg.user { align-self: flex-end; background: var(--ac-ink); color: #fff; padding: 10px 14px; border-radius: 18px 18px 4px 18px; margin-left: auto; }\n .msg.assistant { color: #1a1d22; padding: 2px 0; }\n .msg.assistant strong { font-weight: 600; }\n\n /* ── Tool Call ── */\n .tool-call { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: #f7f8f9; border: 1px solid #e6e8ec; border-radius: 10px; font-size: 11px; color: #5c6570; }\n .tool-call .icon { width: 20px; height: 20px; background: var(--ac-teal); border-radius: 5px; display: flex; align-items: center; justify-content: center; font-size: 12px; flex-shrink: 0; font-weight: 800; color: #04201f; }\n .tool-call .label { font-weight: 700; color: #3d454e; }\n .mono-tool { font-family: 'JetBrains Mono', monospace; color: #8a929c; }\n\n /* ── Widget card (DiscoveryCarousel shell) ── */\n .wcard { border: 1.5px solid #e0e3e7; border-radius: 14px; background: #fff; overflow: hidden; }\n .wcard-head { padding: 11px 14px; background: var(--ac-ink); color: #fff; display: flex; align-items: center; gap: 8px; }\n .wcard-head .wc-logo { width: 20px; height: 20px; background: var(--ac-teal); border-radius: 5px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }\n .wcard-head .wc-logo svg { width: 13px; height: 13px; stroke: #04201f; fill: none; stroke-width: 2; }\n .wcard-head .wc-title { font-size: 12px; font-weight: 700; letter-spacing: 0.2px; }\n .wcard-head .wc-sub { font-size: 10px; color: #9db3b1; margin-left: auto; }\n .wc-status { font-size: 10px; color: #9db3b1; }\n .wcard-body { padding: 13px 14px; display: flex; flex-direction: column; gap: 10px; }\n .wc-chip { font-size: 9px; font-weight: 700; padding: 2px 7px; background: rgba(255,255,255,0.14); color: #cfeeed; border-radius: 10px; }\n\n /* ── Destination cards inside carousel ── */\n .dest-track { display: flex; flex-direction: column; gap: 9px; }\n .dcard { border: 1.5px solid #e6e8ec; border-radius: 11px; padding: 10px 11px; display: flex; flex-direction: column; gap: 5px; }\n .dcard.active { border-color: var(--ac-teal); background: var(--ac-teal-soft); }\n .dcard-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }\n .dcard-name { font-size: 14px; font-weight: 800; color: var(--ac-ink); }\n .dcard-price { font-size: 11px; font-weight: 700; color: var(--ac-slate); white-space: nowrap; }\n .dcard-region { font-size: 10.5px; color: #7b838d; }\n .dcard-why { font-size: 11px; color: #3d454e; line-height: 1.45; }\n .dcard-btn { align-self: flex-start; font-size: 10.5px; font-weight: 700; padding: 4px 11px; border-radius: 20px; border: 1.5px solid #d4d8dd; color: #4a525c; background: #fff; }\n .dcard-btn.on { background: var(--ac-teal); border-color: var(--ac-teal); color: #04201f; }\n\n /* ── CTA / note ── */\n .cta { padding: 9px 10px; background: var(--ac-teal); border-radius: 9px; text-align: center; font-size: 12px; font-weight: 700; color: #04201f; display: flex; align-items: center; justify-content: center; gap: 6px; }\n .cta svg { width: 13px; height: 13px; stroke: #04201f; fill: none; stroke-width: 2; }\n .cta.disabled { background: #cdeceb; color: #5f8a89; }\n .note { font-size: 11px; color: #5c6570; background: #f7f8f9; border-radius: 8px; padding: 8px 10px; line-height: 1.5; }\n .note.why { border-left: 3px solid var(--ac-teal); }\n .standing { font-size: 10px; color: #8a929c; text-align: center; font-style: italic; }\n\n /* ── Off-app destination ── */\n .dest { border: 1.5px solid #e0e3e7; border-radius: 10px; padding: 11px; }\n .dest .dh { font-size: 12px; font-weight: 800; color: var(--ac-ink); margin-bottom: 6px; }\n .dest .dl { font-size: 11px; color: #5c6570; line-height: 1.5; }\n .kv { display: flex; justify-content: space-between; gap: 10px; font-size: 11px; padding: 5px 0; border-bottom: 1px dashed #eceef1; }\n .kv:last-child { border-bottom: none; }\n .kv .k { color: #7b838d; }\n .kv .v { font-weight: 600; color: #2c3540; text-align: right; }\n\n /* ── Gallery ── */\n .gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 22px; }\n .spec-frame { display: flex; flex-direction: column; gap: 8px; }\n .spec-frame .sf-name { font-size: 12px; font-weight: 700; color: var(--ac-ink); }\n .spec-frame .sf-name span { font-weight: 400; color: #8a929c; }\n\n /* ── API appendix ── */\n .api-panel { background: #fff; border: 1px solid #e6e8ec; border-radius: 12px; padding: 18px 20px; margin-bottom: 18px; max-width: 1040px; }\n .api-panel h4 { font-size: 13px; font-weight: 800; color: var(--ac-ink); margin-bottom: 4px; }\n .api-panel .ap-sub { font-size: 12px; color: #8a929c; margin-bottom: 12px; }\n .api-step { display: flex; gap: 10px; padding: 9px 0; border-top: 1px dashed #eceef1; }\n .api-step .an { font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 700; color: var(--ac-teal); flex-shrink: 0; width: 26px; }\n .api-step .ac { flex: 1; }\n .api-step .tool { font-family: 'JetBrains Mono', monospace; font-size: 11.5px; font-weight: 700; color: #2c3540; }\n .api-step .tool .kind { font-family: 'Inter'; font-size: 9px; font-weight: 700; color: #067e7c; background: var(--ac-teal-soft); padding: 1px 6px; border-radius: 4px; margin-left: 6px; text-transform: uppercase; letter-spacing: 0.3px; }\n .api-note { font-size: 11px; color: #5c6570; margin-top: 3px; line-height: 1.5; }\n .api-note code { font-family: 'JetBrains Mono', monospace; font-size: 10.5px; background: #f1f3f5; padding: 1px 5px; border-radius: 4px; color: #3d454e; }\n\n /* ── Audit table ── */\n .audit-table { width: 100%; border-collapse: collapse; font-size: 12px; max-width: 1160px; background: #fff; border: 1px solid #e6e8ec; border-radius: 12px; overflow: hidden; }\n .audit-table th { text-align: left; padding: 10px 14px; background: #f7f8f9; color: #5c6570; font-weight: 700; font-size: 11px; text-transform: uppercase; letter-spacing: 0.4px; border-bottom: 1px solid #e6e8ec; }\n .audit-table td { padding: 11px 14px; border-bottom: 1px solid #eef0f2; vertical-align: top; color: #3d454e; line-height: 1.5; }\n .audit-table tr:last-child td { border-bottom: none; }\n .audit-table td.req { font-weight: 700; color: var(--ac-ink); width: 210px; }\n .pass { font-size: 10px; font-weight: 800; padding: 2px 9px; border-radius: 20px; white-space: nowrap; }\n .pass.ok { background: var(--ac-green-soft); color: #15734d; border: 1px solid var(--ac-green-border); }\n .pass.flag { background: var(--ac-amber-soft); color: #92560a; border: 1px solid var(--ac-amber-border); }\n .audit-sub { font-size: 13px; font-weight: 800; color: var(--ac-ink); margin: 26px 0 12px; }\n\n .footer { text-align: center; font-size: 11px; color: #99a1ab; padding: 30px; border-top: 1px solid #e6e8ec; }\n</style>\n</head>\n<body>\n\n<div class=\"page-header\">\n <h1><span class=\"brand\">Acme Getaways</span> × ChatGPT — Discovery Top-of-Funnel Wireframes</h1>\n <p>Prepared by Noodle Seed · a fuzzy feeling → four grounded getaways → shortlist → signed handoff to book on Acme</p>\n <span class=\"scope\">FUNNEL BOUNDARY: discover & shape the trip in ChatGPT · book / date / pay OFF-APP on acme.example</span>\n</div>\n\n<div class=\"section-nav\">\n <a href=\"#legend\">Legend</a>\n <a href=\"#flow\">End-to-End Flow</a>\n <a href=\"#discover\">1 · Discovery</a>\n <a href=\"#shortlist\">2 · Shortlist & Re-shape</a>\n <a href=\"#handoff\">3 · Handoff</a>\n <a href=\"#gallery\">Widget Gallery</a>\n <a href=\"#tools\">MCP Tools</a>\n <a href=\"#audit\">Compliance Audit</a>\n</div>\n\n<div class=\"container\">\n\n <!-- LEGEND -->\n <div class=\"section\" id=\"legend\">\n <div class=\"vocab\">\n <div class=\"vocab-item\"><span class=\"vocab-sw teal\"></span> Acme accent — primary CTA & \"Shortlisted\" only</div>\n <div class=\"vocab-item\"><span class=\"vocab-sw green\"></span> Positive / in-season fit</div>\n <div class=\"vocab-item\"><span class=\"vocab-sw amber\"></span> Caution / honest limit</div>\n <div class=\"vocab-item\"><span class=\"vocab-sw ink\"></span> ChatGPT system surface</div>\n <div class=\"vocab-item\"><span class=\"vocab-sw grey\"></span> Off-app (dashed phone)</div>\n </div>\n <div class=\"rationale\">\n <h4>How to read these wireframes</h4>\n <p>Solid-border phones are the <strong>in-ChatGPT app</strong> (the discovery & shortlist loop — no account, no transaction). Dashed-border phones are <strong>off-app destinations</strong> (Acme's booking flow at <span class=\"mono\">book.acme.example</span>) reached only after the handoff. Every card is <strong>grounded</strong>: the place, \"from\" price, best-months, region, and reason all come from Acme's own curated catalog and are shown verbatim — the app never invents a destination or a number.</p>\n <p><span class=\"r-tag ux\">UX</span> flow rationale <span class=\"r-tag ui\">UI</span> interface rationale <span class=\"r-tag acme\">ACME</span> brand / catalog fit <span class=\"r-tag trust\">TRUST</span> honesty / boundary guardrail</p>\n </div>\n </div>\n\n <!-- END TO END FLOW -->\n <div class=\"section\" id=\"flow\">\n <span class=\"section-label\">The whole journey</span>\n <h2 class=\"section-title\">End-to-End: a fuzzy feeling → a shortlisted getaway → booked on Acme</h2>\n <p class=\"section-subtitle\">A traveler frames a vibe and a month, sees four grounded Acme getaways, shortlists the one they love, re-shapes if the mood changes, then hands off to Acme to pick dates and pay. Everything left of the dashed phone is account-free and transaction-free.</p>\n\n <div class=\"phones-row\">\n\n <!-- Step 1 -->\n <div class=\"phone-step\">\n <div class=\"step-label\">1 · Frame the feeling<small>vibe · month · travelers</small></div>\n <div class=\"phone\">\n <div class=\"phone-notch\"></div>\n <div class=\"phone-screen\">\n <div class=\"chatgpt-header\"><span class=\"model-name\">ChatGPT</span><span class=\"dots\">···</span></div>\n <div class=\"msg user\">Where should we go for a beach trip in June? Just the two of us.</div>\n <div class=\"tool-call\"><span class=\"icon\">A</span><span><span class=\"label\">discover_getaways</span><br><span class=\"mono-tool\">vibe=beach · month=June · travelers=2</span></span></div>\n <div class=\"msg assistant\">Acme's getaways, shaped for a beach June for two —</div>\n <div class=\"wcard\">\n <div class=\"wcard-head\">\n <span class=\"wc-logo\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m15.5 8.5-2 5-5 2 2-5 5-2Z\"/></svg></span>\n <span class=\"wc-title\">Acme Getaways</span><span class=\"wc-chip\">Discover</span>\n </div>\n <div class=\"wcard-body\">\n <div class=\"dest-track\">\n <div class=\"dcard active\">\n <div class=\"dcard-head\"><span class=\"dcard-name\">Coral Bay</span><span class=\"dcard-price\">from $890</span></div>\n <div class=\"dcard-region\">Adriatic coast · best May–Sep</div>\n <div class=\"dcard-why\">Calm swimming coves and a walkable old town — easy for a relaxed first trip.</div>\n <span class=\"dcard-btn\">Shortlist</span>\n </div>\n <div class=\"dcard\">\n <div class=\"dcard-head\"><span class=\"dcard-name\">Old Quarter</span><span class=\"dcard-price\">from $640</span></div>\n <div class=\"dcard-region\">Central Europe · best Apr–Oct</div>\n <div class=\"dcard-why\">Dense museum district and food halls, all reachable on foot.</div>\n <span class=\"dcard-btn\">Shortlist</span>\n </div>\n </div>\n <div class=\"cta disabled\">Continue on Acme · Coral Bay</div>\n <div class=\"standing\">Booking and payment happen on acme.example — never inside chat.</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"step-arrow\">→</div>\n\n <!-- Step 2 -->\n <div class=\"phone-step\">\n <div class=\"step-label\">2 · Shortlist the pick<small>local, non-destructive</small></div>\n <div class=\"phone\">\n <div class=\"phone-notch\"></div>\n <div class=\"phone-screen\">\n <div class=\"chatgpt-header\"><span class=\"model-name\">ChatGPT</span><span class=\"dots\">···</span></div>\n <div class=\"msg user\">Shortlist Coral Bay.</div>\n <div class=\"tool-call\"><span class=\"icon\">A</span><span><span class=\"label\">shortlist_getaway</span><br><span class=\"mono-tool\">destination=\"Coral Bay\"</span></span></div>\n <div class=\"wcard\">\n <div class=\"wcard-head\">\n <span class=\"wc-logo\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m15.5 8.5-2 5-5 2 2-5 5-2Z\"/></svg></span>\n <span class=\"wc-title\">Acme Getaways</span><span class=\"wc-chip\">Discover</span>\n </div>\n <div class=\"wcard-body\">\n <div class=\"wc-status\" style=\"color:#067e7c;\">Shortlisted Coral Bay.</div>\n <div class=\"dest-track\">\n <div class=\"dcard active\">\n <div class=\"dcard-head\"><span class=\"dcard-name\">Coral Bay</span><span class=\"dcard-price\">from $890</span></div>\n <div class=\"dcard-region\">Adriatic coast · best May–Sep</div>\n <div class=\"dcard-why\">Calm swimming coves and a walkable old town — easy for a relaxed first trip.</div>\n <span class=\"dcard-btn on\">✓ Shortlisted</span>\n </div>\n <div class=\"dcard\">\n <div class=\"dcard-head\"><span class=\"dcard-name\">Harbor City</span><span class=\"dcard-price\">from $980</span></div>\n <div class=\"dcard-region\">Pacific rim · best Sep–Nov</div>\n <div class=\"dcard-why\">Waterfront nightlife and day-trip islands a short ferry away.</div>\n <span class=\"dcard-btn\">Shortlist</span>\n </div>\n </div>\n <div class=\"cta\"><svg viewBox=\"0 0 24 24\"><path d=\"M14 4h6v6\"/><path d=\"m20 4-9 9\"/><path d=\"M20 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5\"/></svg>Continue on Acme · Coral Bay</div>\n <div class=\"standing\">Booking and payment happen on acme.example — never inside chat.</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"step-arrow\">→</div>\n\n <!-- Step 3 -->\n <div class=\"phone-step\">\n <div class=\"step-label\">3 · Re-shape the trip<small>vibe switch → re-render</small></div>\n <div class=\"phone\">\n <div class=\"phone-notch\"></div>\n <div class=\"phone-screen\">\n <div class=\"chatgpt-header\"><span class=\"model-name\">ChatGPT</span><span class=\"dots\">···</span></div>\n <div class=\"msg user\">Hmm, actually more of a city break. What've you got?</div>\n <div class=\"tool-call\"><span class=\"icon\">A</span><span><span class=\"label\">discover_getaways</span><br><span class=\"mono-tool\">vibe=city · month=June · travelers=2</span></span></div>\n <div class=\"msg assistant\">Switching to a city vibe —</div>\n <div class=\"wcard\">\n <div class=\"wcard-head\">\n <span class=\"wc-logo\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m15.5 8.5-2 5-5 2 2-5 5-2Z\"/></svg></span>\n <span class=\"wc-title\">Acme Getaways</span><span class=\"wc-chip\">Discover</span>\n </div>\n <div class=\"wcard-body\">\n <div class=\"dest-track\">\n <div class=\"dcard active\">\n <div class=\"dcard-head\"><span class=\"dcard-name\">Harbor City</span><span class=\"dcard-price\">from $980</span></div>\n <div class=\"dcard-region\">Pacific rim · best Sep–Nov</div>\n <div class=\"dcard-why\">Waterfront nightlife and day-trip islands a short ferry away.</div>\n <span class=\"dcard-btn\">Shortlist</span>\n </div>\n <div class=\"dcard\">\n <div class=\"dcard-head\"><span class=\"dcard-name\">Old Quarter</span><span class=\"dcard-price\">from $640</span></div>\n <div class=\"dcard-region\">Central Europe · best Apr–Oct</div>\n <div class=\"dcard-why\">Dense museum district and food halls, all reachable on foot.</div>\n <span class=\"dcard-btn\">Shortlist</span>\n </div>\n </div>\n <div class=\"note why\"><strong>Harbor City</strong> is the city fit — best Sep–Nov, so June is lively shoulder season. Month & party size carried over, so you're not re-asked.</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"step-arrow\">→</div>\n\n <!-- Step 4 -->\n <div class=\"phone-step\">\n <div class=\"step-label\">4 · Handoff<small>the funnel boundary</small></div>\n <div class=\"phone\">\n <div class=\"phone-notch\"></div>\n <div class=\"phone-screen\">\n <div class=\"chatgpt-header\"><span class=\"model-name\">ChatGPT</span><span class=\"dots\">···</span></div>\n <div class=\"msg user\">Let's do Coral Bay. Book it.</div>\n <div class=\"tool-call\"><span class=\"icon\">A</span><span><span class=\"label\">create_handoff</span><br><span class=\"mono-tool\">dest=coral_bay · month=June · pax=2</span></span></div>\n <div class=\"msg assistant\">Opening Acme with Coral Bay, June, two travelers pre-filled — pick exact dates and finish there.</div>\n <div class=\"wcard\">\n <div class=\"wcard-head\">\n <span class=\"wc-logo\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m15.5 8.5-2 5-5 2 2-5 5-2Z\"/></svg></span>\n <span class=\"wc-title\">Acme Getaways</span><span class=\"wc-sub\">create_handoff</span>\n </div>\n <div class=\"wcard-body\">\n <div class=\"kv\"><span class=\"k\">Destination</span><span class=\"v\">Coral Bay</span></div>\n <div class=\"kv\"><span class=\"k\">Trip</span><span class=\"v\">June · 2 travelers</span></div>\n <div class=\"kv\"><span class=\"k\">Attribution</span><span class=\"v\">src=chatgpt</span></div>\n <div class=\"cta\"><svg viewBox=\"0 0 24 24\"><path d=\"M14 4h6v6\"/><path d=\"m20 4-9 9\"/><path d=\"M20 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5\"/></svg>Continue on Acme</div>\n <div class=\"standing\">Booking and payment happen on acme.example — never inside chat.</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"step-arrow\">→</div>\n\n <!-- Step 5 off-app -->\n <div class=\"phone-step\">\n <div class=\"step-label\">5 · Off-app<small>book · date · pay on Acme</small></div>\n <div class=\"phone offapp\">\n <div class=\"phone-notch\"></div>\n <div class=\"phone-screen\">\n <div class=\"browser-header\"><span class=\"lock\">🔒</span><span class=\"url\">book.acme.example/plan?dest=coral_bay&month=June&pax=2&src=chatgpt</span></div>\n <div class=\"dest\">\n <div class=\"dh\">Acme · Book Coral Bay</div>\n <div class=\"dl\">The shaped trip lands pre-filled — <strong>Coral Bay · June · 2 travelers</strong>. Traveler signs in, picks exact dates against live availability, and pays. <strong>Purchase happens here.</strong></div>\n </div>\n <div class=\"note\" style=\"margin-top:8px;\">⟵ The deep link carried <span class=\"mono\">dest · month · pax</span> so nothing is re-typed, plus <span class=\"mono\">src=chatgpt</span> so Acme credits this booking to the ChatGPT funnel.</div>\n </div>\n </div>\n </div>\n\n </div>\n </div>\n\n <!-- 1 DISCOVERY -->\n <div class=\"section\" id=\"discover\">\n <span class=\"section-label\">Surface 1 · core</span>\n <h2 class=\"section-title\">Discovery — a grounded catalog, never a guess</h2>\n <p class=\"section-subtitle\">The opening move: turn a fuzzy feeling into four real Acme getaways, each with its own \"from\" price, best-months window, region, and honest reason. The model narrates which fit the stated vibe; the carousel shows the full curated catalog.</p>\n <div class=\"rationale\">\n <h4>Why it's built this way</h4>\n <p><span class=\"r-tag ux\">UX</span> A traveler at \"where should we go?\" wants a few credible options with a reason each — not an infinite list. Four cards scan in seconds; one tap shortlists.</p>\n <p><span class=\"r-tag acme\">ACME</span> Every place, price, and reason is Acme's own catalog data returned verbatim — the app is authoritative because Acme owns all four. <span class=\"r-tag trust\">TRUST</span> Prices are shown <strong>\"from $X\"</strong>, never as a quote; <code>discover_getaways</code> returns the whole catalog and never filters on the input, so switching vibe is never a dead end.</p>\n </div>\n <div class=\"phones-row\">\n <div class=\"phone-step\">\n <div class=\"step-label\">Budget-first intent</div>\n <div class=\"phone\"><div class=\"phone-notch\"></div><div class=\"phone-screen\">\n <div class=\"chatgpt-header\"><span class=\"model-name\">ChatGPT</span><span class=\"dots\">···</span></div>\n <div class=\"msg user\">Honestly, what's the cheapest you'd actually recommend for two?</div>\n <div class=\"tool-call\"><span class=\"icon\">A</span><span><span class=\"label\">discover_getaways</span></span></div>\n <div class=\"wcard\"><div class=\"wcard-head\"><span class=\"wc-logo\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m15.5 8.5-2 5-5 2 2-5 5-2Z\"/></svg></span><span class=\"wc-title\">Acme Getaways</span><span class=\"wc-chip\">Discover</span></div><div class=\"wcard-body\">\n <div class=\"dest-track\">\n <div class=\"dcard active\"><div class=\"dcard-head\"><span class=\"dcard-name\">Old Quarter</span><span class=\"dcard-price\">from $640</span></div><div class=\"dcard-region\">Central Europe · best Apr–Oct</div><div class=\"dcard-why\">Dense museum district and food halls, all reachable on foot.</div><span class=\"dcard-btn\">Shortlist</span></div>\n <div class=\"dcard\"><div class=\"dcard-head\"><span class=\"dcard-name\">Coral Bay</span><span class=\"dcard-price\">from $890</span></div><div class=\"dcard-region\">Adriatic coast · best May–Sep</div><div class=\"dcard-why\">Calm swimming coves and a walkable old town.</div><span class=\"dcard-btn\">Shortlist</span></div>\n </div>\n <div class=\"note why\"><strong>Old Quarter</strong> starts lowest at from $640 — the value pick without feeling like a compromise.</div>\n </div></div>\n </div></div>\n </div>\n <div class=\"step-arrow\">→</div>\n <div class=\"phone-step\">\n <div class=\"step-label\">Season-first intent</div>\n <div class=\"phone\"><div class=\"phone-notch\"></div><div class=\"phone-screen\">\n <div class=\"chatgpt-header\"><span class=\"model-name\">ChatGPT</span><span class=\"dots\">···</span></div>\n <div class=\"msg user\">We've got a week in December — where's actually good then?</div>\n <div class=\"tool-call\"><span class=\"icon\">A</span><span><span class=\"label\">discover_getaways</span><span class=\"mono-tool\"> month=December</span></span></div>\n <div class=\"wcard\"><div class=\"wcard-head\"><span class=\"wc-logo\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m15.5 8.5-2 5-5 2 2-5 5-2Z\"/></svg></span><span class=\"wc-title\">Acme Getaways</span><span class=\"wc-chip\">Discover</span></div><div class=\"wcard-body\">\n <div class=\"dest-track\">\n <div class=\"dcard active\"><div class=\"dcard-head\"><span class=\"dcard-name\">Monte Alto</span><span class=\"dcard-price\">from $1,120</span></div><div class=\"dcard-region\">Northern Alps · best Dec–Mar</div><div class=\"dcard-why\">Ski-in village with beginner slopes and long groomed runs.</div><span class=\"dcard-btn\">Shortlist</span></div>\n </div>\n <div class=\"note why\"><strong>Monte Alto</strong> is the one whose window is <strong>Dec–Mar</strong> — the in-season pick. Exact December availability is Acme's to confirm.</div>\n </div></div>\n </div></div>\n </div>\n <div class=\"step-arrow\">→</div>\n <div class=\"phone-step\">\n <div class=\"step-label\">Fullscreen browse (same component)</div>\n <div class=\"phone\"><div class=\"phone-notch\"></div><div class=\"phone-screen\">\n <div class=\"chatgpt-header\"><span class=\"model-name\">ChatGPT</span><span class=\"dots\">···</span></div>\n <div class=\"msg user\">Just show me everything.</div>\n <div class=\"tool-call\"><span class=\"icon\">A</span><span><span class=\"label\">discover_getaways</span></span></div>\n <div class=\"wcard\"><div class=\"wcard-head\"><span class=\"wc-logo\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m15.5 8.5-2 5-5 2 2-5 5-2Z\"/></svg></span><span class=\"wc-title\">Acme Getaways</span><span class=\"wc-chip\">Fullscreen</span></div><div class=\"wcard-body\">\n <div class=\"dest-track\">\n <div class=\"dcard\"><div class=\"dcard-head\"><span class=\"dcard-name\">Coral Bay</span><span class=\"dcard-price\">from $890</span></div><div class=\"dcard-region\">Adriatic coast · May–Sep · beach</div></div>\n <div class=\"dcard\"><div class=\"dcard-head\"><span class=\"dcard-name\">Monte Alto</span><span class=\"dcard-price\">from $1,120</span></div><div class=\"dcard-region\">Northern Alps · Dec–Mar · mountains</div></div>\n <div class=\"dcard\"><div class=\"dcard-head\"><span class=\"dcard-name\">Old Quarter</span><span class=\"dcard-price\">from $640</span></div><div class=\"dcard-region\">Central Europe · Apr–Oct · culture</div></div>\n <div class=\"dcard\"><div class=\"dcard-head\"><span class=\"dcard-name\">Harbor City</span><span class=\"dcard-price\">from $980</span></div><div class=\"dcard-region\">Pacific rim · Sep–Nov · city</div></div>\n </div>\n <div class=\"standing\">Same component, roomier layout — displayMode=\"fullscreen\".</div>\n </div></div>\n </div></div>\n </div>\n </div>\n </div>\n\n <!-- 2 SHORTLIST -->\n <div class=\"section\" id=\"shortlist\">\n <span class=\"section-label\">Surface 2</span>\n <h2 class=\"section-title\">Shortlist & re-shape — the deliberation loop</h2>\n <p class=\"section-subtitle\">Shortlisting is the \"I like this one\" gesture — a local widget-state write, not a booking. Changing the vibe, month, or party size re-runs discovery and re-renders the carousel, so the traveler can think out loud without losing context.</p>\n <div class=\"rationale\">\n <h4>Why it's built this way</h4>\n <p><span class=\"r-tag ux\">UX</span> The loop rewards exploration: shortlist, switch vibe, compare, shortlist again. The values you didn't change persist, so a vibe switch never re-asks month or party size.</p>\n <p><span class=\"r-tag ui\">UI</span> One action per card (Shortlist toggle) and one shell CTA (Continue on Acme) — well inside the ≤2-actions-per-card rule; the carousel is a single track with no nested scroll. <span class=\"r-tag trust\">TRUST</span> Shortlisting writes nothing off-app: <code>shortlist_getaway</code> is a non-destructive local action, not an account or a cart.</p>\n </div>\n <div class=\"phones-row\">\n <div class=\"phone-step\">\n <div class=\"step-label\">Before — nothing held yet</div>\n <div class=\"phone\"><div class=\"phone-notch\"></div><div class=\"phone-screen\">\n <div class=\"chatgpt-header\"><span class=\"model-name\">ChatGPT</span><span class=\"dots\">···</span></div>\n <div class=\"wcard\"><div class=\"wcard-head\"><span class=\"wc-logo\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m15.5 8.5-2 5-5 2 2-5 5-2Z\"/></svg></span><span class=\"wc-title\">Acme Getaways</span><span class=\"wc-chip\">Discover</span></div><div class=\"wcard-body\">\n <div class=\"wc-status\">Pick a getaway to continue.</div>\n <div class=\"dest-track\">\n <div class=\"dcard\"><div class=\"dcard-head\"><span class=\"dcard-name\">Coral Bay</span><span class=\"dcard-price\">from $890</span></div><div class=\"dcard-region\">Adriatic coast · best May–Sep</div><span class=\"dcard-btn\">Shortlist</span></div>\n <div class=\"dcard\"><div class=\"dcard-head\"><span class=\"dcard-name\">Harbor City</span><span class=\"dcard-price\">from $980</span></div><div class=\"dcard-region\">Pacific rim · best Sep–Nov</div><span class=\"dcard-btn\">Shortlist</span></div>\n </div>\n <div class=\"cta disabled\">Continue on Acme</div>\n </div></div>\n </div></div>\n </div>\n <div class=\"step-arrow\">→</div>\n <div class=\"phone-step\">\n <div class=\"step-label\">After — Coral Bay held</div>\n <div class=\"phone\"><div class=\"phone-notch\"></div><div class=\"phone-screen\">\n <div class=\"chatgpt-header\"><span class=\"model-name\">ChatGPT</span><span class=\"dots\">···</span></div>\n <div class=\"tool-call\"><span class=\"icon\">A</span><span><span class=\"label\">shortlist_getaway</span><span class=\"mono-tool\"> \"Coral Bay\"</span></span></div>\n <div class=\"wcard\"><div class=\"wcard-head\"><span class=\"wc-logo\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m15.5 8.5-2 5-5 2 2-5 5-2Z\"/></svg></span><span class=\"wc-title\">Acme Getaways</span><span class=\"wc-chip\">Discover</span></div><div class=\"wcard-body\">\n <div class=\"wc-status\" style=\"color:#067e7c;\">Shortlisted Coral Bay.</div>\n <div class=\"dest-track\">\n <div class=\"dcard active\"><div class=\"dcard-head\"><span class=\"dcard-name\">Coral Bay</span><span class=\"dcard-price\">from $890</span></div><div class=\"dcard-region\">Adriatic coast · best May–Sep</div><span class=\"dcard-btn on\">✓ Shortlisted</span></div>\n <div class=\"dcard\"><div class=\"dcard-head\"><span class=\"dcard-name\">Harbor City</span><span class=\"dcard-price\">from $980</span></div><div class=\"dcard-region\">Pacific rim · best Sep–Nov</div><span class=\"dcard-btn\">Shortlist</span></div>\n </div>\n <div class=\"cta\"><svg viewBox=\"0 0 24 24\"><path d=\"M14 4h6v6\"/><path d=\"m20 4-9 9\"/><path d=\"M20 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5\"/></svg>Continue on Acme · Coral Bay</div>\n </div></div>\n </div></div>\n </div>\n </div>\n </div>\n\n <!-- 3 HANDOFF -->\n <div class=\"section\" id=\"handoff\">\n <span class=\"section-label\">Surface 3 · the funnel boundary</span>\n <h2 class=\"section-title\">The single, signed, attributable handoff</h2>\n <p class=\"section-subtitle\">One deliberate exit — no second track. <strong>Continue on Acme</strong> calls <code>create_handoff</code>, which builds a signed deep link carrying the shaped trip and the <span class=\"mono\">src=chatgpt</span> attribution, then opens Acme's booking flow. Dates, inventory, and payment are Acme's, off-app by design.</p>\n <div class=\"rationale\">\n <h4>Why it's built this way</h4>\n <p><span class=\"r-tag acme\">ACME</span> Acme earns on completed bookings; the app's job is to send pre-shaped, attributable demand. The deep link carries <code>dest · month · pax</code> so nothing is re-typed, plus <code>src=chatgpt</code> so Acme can measure and pay for the funnel.</p>\n <p><span class=\"r-tag ui\">UI</span> Every value is already URL-safe (id slug · month enum · integer), so the tool substitutes them directly — it never URL-encodes or transforms an input. <span class=\"r-tag trust\">TRUST</span> The domain is declared in <code>handoff.allowedDomains</code>, so ChatGPT opens it without a safe-link warning; zero credentials cross the boundary.</p>\n </div>\n <div class=\"phones-row\">\n <div class=\"phone-step\">\n <div class=\"step-label\">In-app — the handoff card</div>\n <div class=\"phone\"><div class=\"phone-notch\"></div><div class=\"phone-screen\">\n <div class=\"chatgpt-header\"><span class=\"model-name\">ChatGPT</span><span class=\"dots\">···</span></div>\n <div class=\"tool-call\"><span class=\"icon\">A</span><span><span class=\"label\">create_handoff</span><br><span class=\"mono-tool\">dest=coral_bay · month=June · pax=2</span></span></div>\n <div class=\"wcard\"><div class=\"wcard-head\"><span class=\"wc-logo\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m15.5 8.5-2 5-5 2 2-5 5-2Z\"/></svg></span><span class=\"wc-title\">Acme Getaways</span><span class=\"wc-sub\">create_handoff</span></div><div class=\"wcard-body\">\n <div class=\"kv\"><span class=\"k\">Destination</span><span class=\"v\">Coral Bay</span></div>\n <div class=\"kv\"><span class=\"k\">Trip</span><span class=\"v\">June · 2 travelers</span></div>\n <div class=\"kv\"><span class=\"k\">Summary</span><span class=\"v\">Coral Bay · June · 2</span></div>\n <div class=\"cta\"><svg viewBox=\"0 0 24 24\"><path d=\"M14 4h6v6\"/><path d=\"m20 4-9 9\"/><path d=\"M20 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5\"/></svg>Continue on Acme</div>\n <div class=\"standing\">Booking and payment happen on acme.example — never inside chat.</div>\n </div></div>\n </div></div>\n </div>\n <div class=\"step-arrow\">→</div>\n <div class=\"phone-step\">\n <div class=\"step-label\">Off-app — Acme booking flow</div>\n <div class=\"phone offapp\"><div class=\"phone-notch\"></div><div class=\"phone-screen\">\n <div class=\"browser-header\"><span class=\"lock\">🔒</span><span class=\"url\">book.acme.example/plan?dest=coral_bay&month=June&pax=2&src=chatgpt</span></div>\n <div class=\"dest\">\n <div class=\"dh\">Acme · Plan your Coral Bay trip</div>\n <div class=\"dl\">Pre-filled from the handoff: <strong>Coral Bay · June · 2 travelers</strong>. Sign in, choose exact dates against live availability, review the dated price, and pay. <strong>The transaction lives here.</strong></div>\n </div>\n <div class=\"note\" style=\"margin-top:8px;\">The <span class=\"mono\">src=chatgpt</span> tag credits this session — and any booking that follows — to the ChatGPT funnel. It carries no PII.</div>\n </div></div>\n </div>\n </div>\n </div>\n\n <!-- WIDGET GALLERY -->\n <div class=\"section\" id=\"gallery\">\n <span class=\"section-label\">Reference</span>\n <h2 class=\"section-title\">Widget gallery</h2>\n <p class=\"section-subtitle\">The app ships <strong>one</strong> widget — <span class=\"mono\">DiscoveryCarousel</span> — shown here at rest and in its key states. Apps SDK compliant: system fonts, monochrome outlined icons, neutral surface, Acme teal reserved for the primary CTA and the \"Shortlisted\" state only.</p>\n <div class=\"gallery\">\n\n <div class=\"spec-frame\">\n <div class=\"sf-name\">DiscoveryCarousel ★ <span>· at rest</span></div>\n <div class=\"wcard\"><div class=\"wcard-head\"><span class=\"wc-logo\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m15.5 8.5-2 5-5 2 2-5 5-2Z\"/></svg></span><span class=\"wc-title\">Acme Getaways</span><span class=\"wc-chip\">Discover</span></div><div class=\"wcard-body\">\n <div class=\"dest-track\">\n <div class=\"dcard\"><div class=\"dcard-head\"><span class=\"dcard-name\">Coral Bay</span><span class=\"dcard-price\">from $890</span></div><div class=\"dcard-region\">Adriatic coast · best May–Sep</div><div class=\"dcard-why\">Calm swimming coves and a walkable old town.</div><span class=\"dcard-btn\">Shortlist</span></div>\n </div>\n <div class=\"cta disabled\">Continue on Acme</div>\n <div class=\"standing\">Booking and payment happen on acme.example.</div>\n </div></div>\n </div>\n\n <div class=\"spec-frame\">\n <div class=\"sf-name\">DiscoveryCarousel <span>· shortlisted state</span></div>\n <div class=\"wcard\"><div class=\"wcard-head\"><span class=\"wc-logo\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m15.5 8.5-2 5-5 2 2-5 5-2Z\"/></svg></span><span class=\"wc-title\">Acme Getaways</span><span class=\"wc-chip\">Discover</span></div><div class=\"wcard-body\">\n <div class=\"wc-status\" style=\"color:#067e7c;\">Shortlisted Harbor City.</div>\n <div class=\"dest-track\">\n <div class=\"dcard active\"><div class=\"dcard-head\"><span class=\"dcard-name\">Harbor City</span><span class=\"dcard-price\">from $980</span></div><div class=\"dcard-region\">Pacific rim · best Sep–Nov</div><span class=\"dcard-btn on\">✓ Shortlisted</span></div>\n </div>\n <div class=\"cta\"><svg viewBox=\"0 0 24 24\"><path d=\"M14 4h6v6\"/><path d=\"m20 4-9 9\"/><path d=\"M20 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5\"/></svg>Continue on Acme · Harbor City</div>\n </div></div>\n </div>\n\n <div class=\"spec-frame\">\n <div class=\"sf-name\">DiscoveryCarousel <span>· pending handoff</span></div>\n <div class=\"wcard\"><div class=\"wcard-head\"><span class=\"wc-logo\"><svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m15.5 8.5-2 5-5 2 2-5 5-2Z\"/></svg></span><span class=\"wc-title\">Acme Getaways</span><span class=\"wc-chip\">Discover</span></div><div class=\"wcard-body\">\n <div class=\"dest-track\">\n <div class=\"dcard active\"><div class=\"dcard-head\"><span class=\"dcard-name\">Monte Alto</span><span class=\"dcard-price\">from $1,120</span></div><div class=\"dcard-region\">Northern Alps · best Dec–Mar</div><span class=\"dcard-btn on\">✓ Shortlisted</span></div>\n </div>\n <div class=\"cta disabled\">Opening Acme…</div>\n <div class=\"standing\">Booking and payment happen on acme.example.</div>\n </div></div>\n </div>\n\n </div>\n </div>\n\n <!-- MCP TOOLS APPENDIX -->\n <div class=\"section\" id=\"tools\">\n <span class=\"section-label\">Technical appendix</span>\n <h2 class=\"section-title\">MCP Tools & Call Sequence</h2>\n <p class=\"section-subtitle\">Every tool that appears in a wireframe, grouped by journey phase, with the intent behind each call. Server: <span class=\"mono\">acme_discovery</span>. Three tools, one widget.</p>\n\n <div class=\"api-panel\">\n <h4>Discover & Shortlist (Steps 1–3)</h4>\n <div class=\"ap-sub\">The in-chat loop — grounded, account-free, transaction-free.</div>\n <div class=\"api-step\">\n <div class=\"an\">1</div>\n <div class=\"ac\">\n <div class=\"tool\">discover_getaways<span class=\"kind\">tool + view</span></div>\n <div class=\"api-note\">In <code>{ vibe, month, travelers }</code> → out <code>{ status, vibe, month, travelers, options[] }</code> and renders <code>DiscoveryCarousel</code>. Returns the <strong>full curated catalog verbatim</strong> (4 destinations); the model narrates fit. Host status: \"Finding getaways…\" → \"Getaways ready\". <code>read-only</code>.</div>\n </div>\n </div>\n <div class=\"api-step\">\n <div class=\"an\">2</div>\n <div class=\"ac\">\n <div class=\"tool\">shortlist_getaway<span class=\"kind\">tool + app visibility</span></div>\n <div class=\"api-note\">In <code>{ destination, note }</code> → out <code>{ status, destination, note }</code>. Called from inside the carousel when a card's <strong>Shortlist</strong> is tapped; updates widget view-state. <code>local-action</code>, non-destructive — not a booking.</div>\n </div>\n </div>\n <div class=\"api-step\">\n <div class=\"an\">3</div>\n <div class=\"ac\">\n <div class=\"tool\">discover_getaways<span class=\"kind\">re-invoke</span></div>\n <div class=\"api-note\">A natural-language vibe/month/party change re-invokes discovery with the updated input; unchanged values persist so the traveler isn't re-asked. The carousel re-renders in place.</div>\n </div>\n </div>\n </div>\n\n <div class=\"api-panel\">\n <h4>Handoff (Step 4 → off-app)</h4>\n <div class=\"ap-sub\">The single deliberate exit — signed, attributable, credential-free.</div>\n <div class=\"api-step\">\n <div class=\"an\">4</div>\n <div class=\"ac\">\n <div class=\"tool\">create_handoff<span class=\"kind\">tool · open-link</span></div>\n <div class=\"api-note\">In <code>{ destination, destinationName, month, travelers }</code> → out <code>{ status, destination, summary, handoffUrl }</code>. Builds <code>https://book.acme.example/plan?dest={destination}&month={month}&pax={travelers}&src=chatgpt</code> by direct substitution of already-URL-safe values. The widget calls it, then opens <code>handoffUrl</code>. Domain declared in <code>handoff.allowedDomains</code> (<code>book.acme.example</code>, <code>acme.example</code>) so the compiler derives ChatGPT's redirect domains. <code>open-action</code>.</div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- COMPLIANCE AUDIT -->\n <div class=\"section\" id=\"audit\">\n <span class=\"section-label\">Submission evidence</span>\n <h2 class=\"section-title\">OpenAI Apps SDK Compliance Audit</h2>\n <p class=\"section-subtitle\">Each row cites concrete app behavior, not aspiration. Reference: the OpenAI Apps SDK UX Principles + UI Guidelines. Verify against the built app with <span class=\"mono\">noodle check --target chatgpt</span> before submission.</p>\n\n <table class=\"audit-table\">\n <tr><th>OpenAI Requirement</th><th>How the Acme Getaways app addresses it</th><th>Status</th></tr>\n <tr><td class=\"req\">Conversational value</td><td>A traveler expresses a fuzzy feeling in natural language (\"warm, walkable, June, two of us\"); the model maps it to <code>vibe · month · travelers</code> and returns grounded options — something no tap-driven form does as fluidly.</td><td><span class=\"pass ok\">PASS</span></td></tr>\n <tr><td class=\"req\">Beyond base ChatGPT</td><td>Acme's own curated catalog (places, \"from\" prices, best-months, reasons) and a signed, attributable handoff — knowledge and an action base ChatGPT cannot provide.</td><td><span class=\"pass ok\">PASS</span></td></tr>\n <tr><td class=\"req\">Atomic, model-friendly actions</td><td>Three self-contained tools with explicit Zod input/output schemas: <code>discover_getaways</code>, <code>shortlist_getaway</code>, <code>create_handoff</code>. No ambiguity; all inputs fillable from language.</td><td><span class=\"pass ok\">PASS</span></td></tr>\n <tr><td class=\"req\">Helpful UI only</td><td>The carousel earns its widget: visual scanning of four options with price/season/reason, plus a shortlist gesture. No payment or checkout widget is built — booking is off-app by design.</td><td><span class=\"pass ok\">PASS</span></td></tr>\n <tr><td class=\"req\">In-chat task completion</td><td>The task — discover, shortlist, and shape a getaway — completes in chat; the booking is an intentional handoff, not an unfinished flow.</td><td><span class=\"pass ok\">PASS</span></td></tr>\n <tr><td class=\"req\">Performance & responsiveness</td><td>Static curated data and string-substitution handoff — no live inventory call and no AI in the tool path; carousel renders in one round-trip.</td><td><span class=\"pass ok\">PASS</span></td></tr>\n <tr><td class=\"req\">Discoverability</td><td>Broad natural triggers (\"beach trip in June\", \"cheapest getaway for two\", \"somewhere good in December\"). Golden-prompt set + app-description keywords are a launch workstream.</td><td><span class=\"pass flag\">PLAN</span></td></tr>\n <tr><td class=\"req\">Platform fit</td><td>Rich prompts, multi-turn deliberation (re-shape on vibe/month change), lightweight per-session memory (trip shape + shortlist). No multimodality claimed where it isn't real.</td><td><span class=\"pass ok\">PASS</span></td></tr>\n </table>\n\n <div class=\"audit-sub\">UI Guidelines Compliance</div>\n <table class=\"audit-table\">\n <tr><th>Guideline</th><th>How the app addresses it</th><th>Status</th></tr>\n <tr><td class=\"req\">Design tokens (no vendor lock)</td><td>Styling driven by Noodle Seed server <code>branding</code> tokens (accent <code>#0EA5A4</code>, surface, radius, density) via CSS cascade layers — no app-specific global CSS.</td><td><span class=\"pass ok\">PASS</span></td></tr>\n <tr><td class=\"req\">Accent restraint</td><td>Acme teal appears only on the primary <strong>Continue on Acme</strong> CTA, the compass logo mark, and the active \"Shortlisted\" state. Everything else is neutral system surface.</td><td><span class=\"pass ok\">PASS</span></td></tr>\n <tr><td class=\"req\">System fonts</td><td>System font stack throughout the widget; no custom web font shipped to the host.</td><td><span class=\"pass ok\">PASS</span></td></tr>\n <tr><td class=\"req\">Monochrome outlined icons</td><td>Compass (header) and external-link (CTA) are single-stroke outlined SVGs, no fills.</td><td><span class=\"pass ok\">PASS</span></td></tr>\n <tr><td class=\"req\">WCAG AA contrast</td><td>Text/surface pairs meet AA in light and dark; the widget reads <code>theme</code> from <code>useLayout()</code> and applies <code>surfaceDark</code> (<code>#0B1B1B</code>).</td><td><span class=\"pass ok\">PASS</span></td></tr>\n <tr><td class=\"req\">≤2 actions on inline cards</td><td>Each destination card has one action (Shortlist toggle); the shell has one primary CTA. Never more than two.</td><td><span class=\"pass ok\">PASS</span></td></tr>\n <tr><td class=\"req\">No nested scroll</td><td>The carousel is a single scroll surface; no scroll-within-scroll region.</td><td><span class=\"pass ok\">PASS</span></td></tr>\n <tr><td class=\"req\">Correct display-mode usage</td><td>Inline carousel for discovery; the same component in fullscreen for full-catalog browse. No PiP (no live session), no in-chat checkout.</td><td><span class=\"pass ok\">PASS</span></td></tr>\n </table>\n\n <div class=\"audit-sub\">Domain Guardrail Rows (travel-specific)</div>\n <table class=\"audit-table\">\n <tr><th>Guardrail</th><th>How the app enforces it</th><th>Status</th></tr>\n <tr><td class=\"req\">Never invent catalog data</td><td>Only the four curated destinations exist; every place, price, best-month, and reason is returned verbatim from Acme's catalog — the model narrates, never fabricates.</td><td><span class=\"pass ok\">PASS</span></td></tr>\n <tr><td class=\"req\">\"From\" pricing, never a quote</td><td>Prices always render as \"from $X\"; the dated, party-sized price is computed only on Acme's booking flow.</td><td><span class=\"pass ok\">PASS</span></td></tr>\n <tr><td class=\"req\">Honest funnel boundary</td><td>The standing note \"Booking and payment happen on acme.example — never inside chat.\" is always visible; the CTA always reads \"Continue on Acme\". No availability is asserted in chat.</td><td><span class=\"pass ok\">PASS</span></td></tr>\n <tr><td class=\"req\">Credential-free handoff</td><td>The deep link carries only <code>dest · month · pax · src</code> — no PII, no token; the traveler authenticates and pays on Acme.</td><td><span class=\"pass ok\">PASS</span></td></tr>\n </table>\n </div>\n\n</div>\n\n<div class=\"footer\">\n Acme Getaways × ChatGPT — Discovery Top-of-Funnel Wireframes · Noodle Seed · v1 · 2026<br>\n Acme Getaways is a fictional brand; catalog, prices & best-months are the app's own curated data, internally consistent with <span class=\"mono\">src/server.ts</span>. Verify Apps SDK compliance with <span class=\"mono\">noodle check --target chatgpt</span> at build time.\n</div>\n\n</body>\n</html>\n" },
|
|
33
33
|
{ relPath: "examples/acme-discovery/noodle.json", content: "{\n \"entrypoint\": \"src/server.ts\",\n \"name\": \"acme-discovery\",\n \"template\": \"widget\"\n}\n" },
|
|
@@ -35,10 +35,10 @@ export const BUNDLED_EXAMPLE_FILES = [
|
|
|
35
35
|
{ relPath: "examples/acme-discovery/src/helpers.ts", content: "import type { ServerDefinition } from '@noodleseed/one';\nimport { generateHelpers } from '@noodleseed/one/react';\n\nexport type AppType = ServerDefinition;\n\nexport const { useCallTool, useLayout, useOpenExternal, useToolInfo, useViewState } =\n generateHelpers<AppType>();\n" },
|
|
36
36
|
{ relPath: "examples/acme-discovery/src/knowledge/faq.txt", content: "ACME GETAWAYS FAQ\n\nQ: Does the assistant book and take payment?\nA: No. The assistant shapes the trip and hands off to Acmes own checkout with a signed link.\n\nQ: How current are prices?\nA: Starting prices come from Acmes live site; the assistant cites the page it used.\n\nQ: Can I compare destinations?\nA: Yes, ask for a shortlist by vibe, month, or budget.\n" },
|
|
37
37
|
{ relPath: "examples/acme-discovery/src/knowledge/product.md", content: "# Acme Getaways product guide\n\nAcme Getaways curates four destination types: beach, mountains, culture, and city escapes. Every listing shows a real starting price and the best months to travel. Bookings, payments, and date selection happen on Acmes own site through a signed handoff link; the assistant never takes payment details.\n\n## Cancellation and support\n\nAll trips can be cancelled free within 48 hours of the handoff. Support runs seven days a week through the chat on book.acme.example.\n" },
|
|
38
|
-
{ relPath: "examples/acme-discovery/src/server.ts", content: "import {\n annotations,\n embeddedAssistant,\n file,\n knowledge,\n openAICompatible,\n publicWebsite,\n secret,\n server,\n site,\n tool,\n variable,\n z,\n} from '@noodleseed/one';\n\n// Acme Getaways is a fictional travel brand. This app is deliberately top-of-funnel: discovery and\n// configuration happen inside ChatGPT; the booking/transaction happens off-app on Acme's own site,\n// reached through a signed, attributable handoff deep link. Destinations are the partner's own\n// catalog (grounding) — the app never invents a place, price, or best-month.\n//\n// Authoring note: a tool `fulfil` is *recorded*, not run as live JS. Inputs flow through as\n// `${input.x}` substitutions when placed directly into an output string; do not transform them\n// (no URL-encoding, arithmetic, or filtering on an input value — those break substitution). The\n// curated catalog below is static data the runtime returns verbatim.\n\nconst catalog = [\n {\n id: 'coral_bay',\n name: 'Coral Bay',\n region: 'Adriatic coast',\n vibe: 'beach',\n priceFrom: 890,\n bestMonths: 'May–Sep',\n why: 'Calm swimming coves and a walkable old town — easy for a relaxed first trip.',\n },\n {\n id: 'monte_alto',\n name: 'Monte Alto',\n region: 'Northern Alps',\n vibe: 'mountains',\n priceFrom: 1120,\n bestMonths: 'Dec–Mar',\n why: 'Ski-in village with beginner slopes and long groomed runs.',\n },\n {\n id: 'old_quarter',\n name: 'Old Quarter',\n region: 'Central Europe',\n vibe: 'culture',\n priceFrom: 640,\n bestMonths: 'Apr–Oct',\n why: 'Dense museum district and food halls, all reachable on foot.',\n },\n {\n id: 'harbor_city',\n name: 'Harbor City',\n region: 'Pacific rim',\n vibe: 'city',\n priceFrom: 980,\n bestMonths: 'Sep–Nov',\n why: 'Waterfront nightlife and day-trip islands a short ferry away.',\n },\n] as const;\n\n// A closed set of URL-safe month values. A `fulfil` cannot url-encode an input (recording would break\n// substitution), so the deep link carries `month` only if it is already safe — the model maps natural\n// phrasing (\"early June\") onto one of these when it fills the tool.\nconst monthEnum = z\n .enum([\n 'January',\n 'February',\n 'March',\n 'April',\n 'May',\n 'June',\n 'July',\n 'August',\n 'September',\n 'October',\n 'November',\n 'December',\n ])\n .default('June');\n\n// The catalog ids are already url-safe slugs. Constrain the handoff `destination` to this closed set so\n// only a real, url-safe id can reach the deep link.\nconst destinationId = z.enum(['coral_bay', 'monte_alto', 'old_quarter', 'harbor_city']);\n\nconst discoverInput = z.object({\n vibe: z.enum(['beach', 'mountains', 'culture', 'city']).default('beach'),\n month: monthEnum,\n travelers: z.number().int().min(1).default(2),\n});\n\n// Tool annotations for host planners: reads are read-only, the handoff opens an external link, and\n// shortlisting is a local non-destructive write.\nconst readOnly = annotations.readOnly();\nconst openLink = annotations.openAction();\nconst localWrite = annotations.localAction({ destructive: false, confirm: false });\n\nconst destinationOutput = z.object({\n id: z.string(),\n name: z.string(),\n region: z.string(),\n vibe: z.string(),\n priceFrom: z.number(),\n bestMonths: z.string(),\n why: z.string(),\n});\n\nconst discoverGetaways = tool('discover_getaways', {\n title: 'Discover getaways',\n description:\n 'Suggest Acme Getaways destinations for a vibe and month and render a discovery carousel.',\n annotations: readOnly,\n input: discoverInput,\n output: z.object({\n status: z.string(),\n vibe: z.string(),\n month: z.string(),\n travelers: z.number(),\n // Bounded list: the curated catalog is fixed and small, and the declared ceiling tells the\n // model and host the payload cannot grow. `noodle check` reports `tool_design_output_bounds`.\n options: z.array(destinationOutput).max(20),\n }),\n // The carousel presents Acme's curated catalog; the model narrates which fit the stated vibe.\n // (A tool cannot filter on an input value — that is connector/flow work — so all are returned.)\n fulfil: ({ input }) => ({\n status: `Acme Getaways for a ${input.vibe} trip in ${input.month}, ${input.travelers} traveler(s).`,\n vibe: input.vibe,\n month: input.month,\n travelers: input.travelers,\n options: catalog,\n }),\n viewTitle: 'Discover getaways',\n // ChatGPT host status copy (openai/toolInvocation/*) — required for widget-opening tools.\n invoking: 'Finding getaways…',\n invoked: 'Getaways ready',\n domain: 'https://getaways.acme.example',\n view: {\n component: 'discovery-carousel',\n entry: './views/discovery-carousel.tsx',\n },\n viewDescription:\n 'A top-of-funnel discovery carousel: pick a destination, then hand off to Acme to book.',\n csp: {\n connectDomains: ['https://acme.example'],\n resourceDomains: ['https://acme.example'],\n frameDomains: ['https://acme.example'],\n },\n});\n\nconst createHandoff = tool('create_handoff', {\n title: 'Create booking handoff',\n description:\n 'Create the Acme booking deep link for a chosen destination, carrying the configured trip. ' +\n 'Pass the destination id (url-safe slug, e.g. \"coral_bay\") and its display name.',\n annotations: openLink,\n input: z.object({\n destination: destinationId,\n destinationName: z.string().min(1),\n month: monthEnum,\n travelers: z.number().int().min(1).default(2),\n }),\n output: z.object({\n status: z.string(),\n destination: z.string(),\n summary: z.string(),\n handoffUrl: z.string(),\n }),\n // Inline the inputs directly so they substitute at runtime; every value is already url-safe\n // (id slug, month enum, integer), and `src=chatgpt` is the attribution the partner measures\n // ChatGPT-sourced conversions on.\n fulfil: ({ input }) => ({\n status: `Ready to continue on Acme for ${input.destinationName}.`,\n destination: input.destination,\n summary: `${input.destinationName} · ${input.month} · ${input.travelers} traveler(s)`,\n handoffUrl: `https://book.acme.example/plan?dest=${input.destination}&month=${input.month}&pax=${input.travelers}&src=chatgpt`,\n }),\n});\n\nconst shortlistGetaway = tool('shortlist_getaway', {\n visibility: ['app'],\n description: 'Record the traveler’s shortlisted destination from the discovery widget.',\n annotations: localWrite,\n input: z.object({\n destination: z.string(),\n note: z.string().default(''),\n }),\n output: z.object({\n status: z.string(),\n destination: z.string(),\n note: z.string(),\n }),\n fulfil: ({ input }) => ({\n status: `Shortlisted ${input.destination}.`,\n destination: input.destination,\n note: input.note,\n }),\n});\n\n// Grounding beyond the catalog: two controlled files answer policy/pricing/support questions with\n// citations, and Acme's live public site is crawled on deploy and re-crawled on the declared\n// refresh cadence — no sync job, no handwritten search tool. One declaration, one generated\n// `search_destinations` capability. The managed crawler and index are the defaults; a component\n// can instead bring its own via `crawler: firecrawl({ apiKey: secret('FIRECRAWL_API_KEY') })`\n// and `index: algolia({ appId: variable('ALGOLIA_APP_ID'), apiKey: secret('ALGOLIA_API_KEY') })`\n// — the code names the config, `noodle secrets|variables set` supplies the values.\nconst destinations = knowledge('destinations', {\n title: 'Acme Getaways destinations',\n description: 'Public destination, pricing, cancellation, and support information.',\n documents: [\n file('./knowledge/product.md', {\n title: 'Product guide',\n sourceUrl: 'https://getaways.acme.example/product',\n }),\n file('./knowledge/faq.txt', { title: 'FAQ' }),\n ],\n sites: [\n site({\n origin: 'https://getaways.acme.example',\n include: ['/destinations/**', '/pricing', '/support'],\n refresh: '12h',\n }),\n ],\n});\n\nexport default server(\n 'acme_discovery',\n {\n title: 'Acme Getaways',\n version: '1.0.0',\n branding: {\n name: 'Acme Getaways',\n accent: '#0EA5A4',\n surface: '#F0FDFA',\n surfaceDark: '#0B1B1B',\n radius: 'lg',\n density: 'comfortable',\n },\n // The only external destinations the app links out to — the compiler derives ChatGPT's\n // redirect_domains from this so the handoff opens without a safe-link warning.\n handoff: {\n allowedDomains: ['https://book.acme.example', 'https://acme.example'],\n },\n // The same three tools also serve Acme's own marketing site, with no second tool set and no\n // session backend: a visitor with no account gets the discovery carousel and the booking\n // handoff. `capabilities` is the whole externally reachable surface — short enough to review in\n // one glance, and closed by default when a tool is added to the server later. The knowledge\n // component projects its generated search capability the same way a tool does.\n assistant: embeddedAssistant({\n model: openAICompatible({\n baseUrl: variable('ASSISTANT_MODEL_BASE_URL'),\n model: variable('ASSISTANT_MODEL'),\n apiKey: secret('ASSISTANT_MODEL_API_KEY'),\n }),\n access: publicWebsite({\n origins: ['https://getaways.acme.example'],\n capabilities: [destinations, discoverGetaways, createHandoff, shortlistGetaway],\n instructions:\n 'Be a friendly, consultative travel guide, never pushy. Help visitors narrow a getaway before suggesting the next useful step. Ground recommendations in Acme knowledge, and clearly separate discovery from booking.',\n }),\n layout: { mode: 'floating', position: 'bottom-right' },\n labels: { welcomeHeading: 'Where would you like to go?' },\n }),\n knowledge: [destinations],\n },\n [discoverGetaways, createHandoff, shortlistGetaway],\n);\n" },
|
|
38
|
+
{ relPath: "examples/acme-discovery/src/server.ts", content: "import {\n annotations,\n embeddedAssistant,\n file,\n knowledge,\n noodleManaged,\n publicWebsite,\n server,\n site,\n tool,\n z,\n} from '@noodleseed/one';\n\n// Acme Getaways is a fictional travel brand. This app is deliberately top-of-funnel: discovery and\n// configuration happen inside ChatGPT; the booking/transaction happens off-app on Acme's own site,\n// reached through a signed, attributable handoff deep link. Destinations are the partner's own\n// catalog (grounding) — the app never invents a place, price, or best-month.\n//\n// Authoring note: a tool `fulfil` is *recorded*, not run as live JS. Inputs flow through as\n// `${input.x}` substitutions when placed directly into an output string; do not transform them\n// (no URL-encoding, arithmetic, or filtering on an input value — those break substitution). The\n// curated catalog below is static data the runtime returns verbatim.\n\nconst catalog = [\n {\n id: 'coral_bay',\n name: 'Coral Bay',\n region: 'Adriatic coast',\n vibe: 'beach',\n priceFrom: 890,\n bestMonths: 'May–Sep',\n why: 'Calm swimming coves and a walkable old town — easy for a relaxed first trip.',\n },\n {\n id: 'monte_alto',\n name: 'Monte Alto',\n region: 'Northern Alps',\n vibe: 'mountains',\n priceFrom: 1120,\n bestMonths: 'Dec–Mar',\n why: 'Ski-in village with beginner slopes and long groomed runs.',\n },\n {\n id: 'old_quarter',\n name: 'Old Quarter',\n region: 'Central Europe',\n vibe: 'culture',\n priceFrom: 640,\n bestMonths: 'Apr–Oct',\n why: 'Dense museum district and food halls, all reachable on foot.',\n },\n {\n id: 'harbor_city',\n name: 'Harbor City',\n region: 'Pacific rim',\n vibe: 'city',\n priceFrom: 980,\n bestMonths: 'Sep–Nov',\n why: 'Waterfront nightlife and day-trip islands a short ferry away.',\n },\n] as const;\n\n// A closed set of URL-safe month values. A `fulfil` cannot url-encode an input (recording would break\n// substitution), so the deep link carries `month` only if it is already safe — the model maps natural\n// phrasing (\"early June\") onto one of these when it fills the tool.\nconst monthEnum = z\n .enum([\n 'January',\n 'February',\n 'March',\n 'April',\n 'May',\n 'June',\n 'July',\n 'August',\n 'September',\n 'October',\n 'November',\n 'December',\n ])\n .default('June');\n\n// The catalog ids are already url-safe slugs. Constrain the handoff `destination` to this closed set so\n// only a real, url-safe id can reach the deep link.\nconst destinationId = z.enum(['coral_bay', 'monte_alto', 'old_quarter', 'harbor_city']);\n\nconst discoverInput = z.object({\n vibe: z.enum(['beach', 'mountains', 'culture', 'city']).default('beach'),\n month: monthEnum,\n travelers: z.number().int().min(1).default(2),\n});\n\n// Tool annotations for host planners: reads are read-only, the handoff opens an external link, and\n// shortlisting is a local non-destructive write.\nconst readOnly = annotations.readOnly();\nconst openLink = annotations.openAction();\nconst localWrite = annotations.localAction({ destructive: false, confirm: false });\n\nconst destinationOutput = z.object({\n id: z.string(),\n name: z.string(),\n region: z.string(),\n vibe: z.string(),\n priceFrom: z.number(),\n bestMonths: z.string(),\n why: z.string(),\n});\n\nconst discoverGetaways = tool('discover_getaways', {\n title: 'Discover getaways',\n description:\n 'Suggest Acme Getaways destinations for a vibe and month and render a discovery carousel.',\n annotations: readOnly,\n input: discoverInput,\n output: z.object({\n status: z.string(),\n vibe: z.string(),\n month: z.string(),\n travelers: z.number(),\n // Bounded list: the curated catalog is fixed and small, and the declared ceiling tells the\n // model and host the payload cannot grow. `noodle check` reports `tool_design_output_bounds`.\n options: z.array(destinationOutput).max(20),\n }),\n // The carousel presents Acme's curated catalog; the model narrates which fit the stated vibe.\n // (A tool cannot filter on an input value — that is connector/flow work — so all are returned.)\n fulfil: ({ input }) => ({\n status: `Acme Getaways for a ${input.vibe} trip in ${input.month}, ${input.travelers} traveler(s).`,\n vibe: input.vibe,\n month: input.month,\n travelers: input.travelers,\n options: catalog,\n }),\n viewTitle: 'Discover getaways',\n // ChatGPT host status copy (openai/toolInvocation/*) — required for widget-opening tools.\n invoking: 'Finding getaways…',\n invoked: 'Getaways ready',\n domain: 'https://getaways.acme.example',\n view: {\n component: 'discovery-carousel',\n entry: './views/discovery-carousel.tsx',\n },\n viewDescription:\n 'A top-of-funnel discovery carousel: pick a destination, then hand off to Acme to book.',\n csp: {\n connectDomains: ['https://acme.example'],\n resourceDomains: ['https://acme.example'],\n frameDomains: ['https://acme.example'],\n },\n});\n\nconst createHandoff = tool('create_handoff', {\n title: 'Create booking handoff',\n description:\n 'Create the Acme booking deep link for a chosen destination, carrying the configured trip. ' +\n 'Pass the destination id (url-safe slug, e.g. \"coral_bay\") and its display name.',\n annotations: openLink,\n input: z.object({\n destination: destinationId,\n destinationName: z.string().min(1),\n month: monthEnum,\n travelers: z.number().int().min(1).default(2),\n }),\n output: z.object({\n status: z.string(),\n destination: z.string(),\n summary: z.string(),\n handoffUrl: z.string(),\n }),\n // Inline the inputs directly so they substitute at runtime; every value is already url-safe\n // (id slug, month enum, integer), and `src=chatgpt` is the attribution the partner measures\n // ChatGPT-sourced conversions on.\n fulfil: ({ input }) => ({\n status: `Ready to continue on Acme for ${input.destinationName}.`,\n destination: input.destination,\n summary: `${input.destinationName} · ${input.month} · ${input.travelers} traveler(s)`,\n handoffUrl: `https://book.acme.example/plan?dest=${input.destination}&month=${input.month}&pax=${input.travelers}&src=chatgpt`,\n }),\n});\n\nconst shortlistGetaway = tool('shortlist_getaway', {\n visibility: ['app'],\n description: 'Record the traveler’s shortlisted destination from the discovery widget.',\n annotations: localWrite,\n input: z.object({\n destination: z.string(),\n note: z.string().default(''),\n }),\n output: z.object({\n status: z.string(),\n destination: z.string(),\n note: z.string(),\n }),\n fulfil: ({ input }) => ({\n status: `Shortlisted ${input.destination}.`,\n destination: input.destination,\n note: input.note,\n }),\n});\n\n// Grounding beyond the catalog: two controlled files answer policy/pricing/support questions with\n// citations, and Acme's live public site is crawled on deploy and re-crawled on the declared\n// refresh cadence — no sync job, no handwritten search tool. One declaration, one generated\n// `search_destinations` capability. The managed crawler and index are the defaults; a component\n// can instead bring its own via `crawler: firecrawl({ apiKey: secret('FIRECRAWL_API_KEY') })`\n// and `index: algolia({ appId: variable('ALGOLIA_APP_ID'), apiKey: secret('ALGOLIA_API_KEY') })`\n// — the code names the config, `noodle secrets|variables set` supplies the values.\nconst destinations = knowledge('destinations', {\n title: 'Acme Getaways destinations',\n description: 'Public destination, pricing, cancellation, and support information.',\n documents: [\n file('./knowledge/product.md', {\n title: 'Product guide',\n sourceUrl: 'https://getaways.acme.example/product',\n }),\n file('./knowledge/faq.txt', { title: 'FAQ' }),\n ],\n sites: [\n site({\n origin: 'https://getaways.acme.example',\n include: ['/destinations/**', '/pricing', '/support'],\n refresh: '12h',\n }),\n ],\n});\n\nexport default server(\n 'acme_discovery',\n {\n title: 'Acme Getaways',\n version: '1.0.0',\n branding: {\n name: 'Acme Getaways',\n accent: '#0EA5A4',\n surface: '#F0FDFA',\n surfaceDark: '#0B1B1B',\n radius: 'lg',\n density: 'comfortable',\n },\n // The only external destinations the app links out to — the compiler derives ChatGPT's\n // redirect_domains from this so the handoff opens without a safe-link warning.\n handoff: {\n allowedDomains: ['https://book.acme.example', 'https://acme.example'],\n },\n // The same three tools also serve Acme's own marketing site, with no second tool set and no\n // session backend: a visitor with no account gets the discovery carousel and the booking\n // handoff. `capabilities` is the whole externally reachable surface — short enough to review in\n // one glance, and closed by default when a tool is added to the server later. The knowledge\n // component projects its generated search capability the same way a tool does.\n assistant: embeddedAssistant({\n model: noodleManaged(),\n access: publicWebsite({\n origins: ['https://getaways.acme.example'],\n capabilities: [destinations, discoverGetaways, createHandoff, shortlistGetaway],\n instructions:\n 'Be a friendly, consultative travel guide, never pushy. Help visitors narrow a getaway before suggesting the next useful step. Ground recommendations in Acme knowledge, and clearly separate discovery from booking.',\n }),\n layout: { mode: 'floating', position: 'bottom-right' },\n labels: { welcomeHeading: 'Where would you like to go?' },\n }),\n knowledge: [destinations],\n },\n [discoverGetaways, createHandoff, shortlistGetaway],\n);\n" },
|
|
39
39
|
{ relPath: "examples/acme-discovery/src/views/discovery-carousel.tsx", content: "import { useState } from 'react';\nimport { useCallTool, useLayout, useOpenExternal, useToolInfo, useViewState } from '../helpers.js';\nimport './widget-style.css';\n\ntype Destination = {\n readonly id: string;\n readonly name: string;\n readonly region: string;\n readonly vibe: string;\n readonly priceFrom: number;\n readonly bestMonths: string;\n readonly why: string;\n};\n\nfunction asDiscovery(value: unknown) {\n return value as\n | {\n readonly status?: string;\n readonly month?: string;\n readonly travelers?: number;\n readonly options?: readonly Destination[];\n }\n | undefined;\n}\n\nexport default function DiscoveryCarousel() {\n const { displayMode, theme } = useLayout();\n const openExternal = useOpenExternal();\n const discovery = asDiscovery(useToolInfo('discover_getaways').structuredContent);\n const shortlist = useCallTool('shortlist_getaway');\n const handoff = useCallTool('create_handoff');\n\n const options = discovery?.options ?? [];\n const month = discovery?.month ?? 'June';\n const travelers = discovery?.travelers ?? 2;\n const [chosen, setChosen] = useViewState('chosen', options[0]?.id ?? '');\n const [status, setStatus] = useState(discovery?.status ?? 'Pick a getaway to continue.');\n const selected = options.find((entry) => entry.id === chosen) ?? options[0];\n const continueLabel = handoff.isPending\n ? 'Opening Acme…'\n : `Continue on Acme${selected ? ` · ${selected.name}` : ''}`;\n\n async function shortlistDestination(destination: Destination) {\n setChosen(destination.id);\n try {\n const result = await shortlist.callTool({ destination: destination.name });\n const structured = result.structuredContent as { readonly status?: string } | undefined;\n setStatus(structured?.status ?? `Shortlisted ${destination.name}.`);\n } catch {\n setStatus(`Couldn't shortlist ${destination.name} — try again.`);\n }\n }\n\n async function continueOnAcme() {\n if (selected === undefined) return;\n // The handoff is the product: configure here, transact off-app. The deep link carries the trip.\n // Only open the external target on a successful handoff; surface failures instead of failing silently.\n try {\n const result = await handoff.callTool({\n destination: selected.id,\n destinationName: selected.name,\n month,\n travelers,\n });\n const structured = result.structuredContent as { readonly handoffUrl?: string } | undefined;\n if (structured?.handoffUrl) openExternal(structured.handoffUrl);\n else setStatus('Continue on Acme is unavailable right now — try again.');\n } catch {\n setStatus('Continue on Acme failed — try again.');\n }\n }\n\n return (\n <main\n className={`nw-shell${theme === 'dark' ? ' dark' : ''}`}\n data-llm={`Acme Getaways discovery: ${options.length} options for ${month}, ${travelers} traveler(s); shortlisted ${selected?.name ?? 'none'}`}\n >\n <section className=\"nw-card\">\n <header className=\"nw-header\">\n <span className=\"nw-icon\" aria-hidden=\"true\">\n <CompassIcon />\n </span>\n <div className=\"nw-title-block\">\n <h1 className=\"nw-title\">Acme Getaways</h1>\n <p className=\"nw-subtitle\" aria-live=\"polite\">\n {status}\n </p>\n </div>\n <span className=\"nw-chip\">\n {displayMode === 'fullscreen' ? 'Fullscreen' : 'Discover'}\n </span>\n </header>\n\n <div className=\"nw-carousel\">\n {options.map((entry) => (\n <article\n className={`nw-dest${entry.id === chosen ? ' nw-dest-active' : ''}`}\n key={entry.id}\n >\n <div className=\"nw-dest-head\">\n <span className=\"nw-dest-name\">{entry.name}</span>\n <span className=\"nw-price\">from ${entry.priceFrom}</span>\n </div>\n <p className=\"nw-dest-region\">\n {entry.region} · best {entry.bestMonths}\n </p>\n {/* Grounded copy: the \"why\" comes from Acme's catalog, not invented at runtime. */}\n <p className=\"nw-dest-why\">{entry.why}</p>\n <button\n aria-pressed={entry.id === chosen}\n className=\"nw-button nw-button-ghost\"\n type=\"button\"\n onClick={() => shortlistDestination(entry)}\n >\n {entry.id === chosen ? 'Shortlisted' : 'Shortlist'}\n </button>\n </article>\n ))}\n </div>\n\n <div className=\"nw-actions\">\n <button\n className=\"nw-button nw-button-primary\"\n type=\"button\"\n disabled={selected === undefined || handoff.isPending}\n onClick={continueOnAcme}\n >\n <ExternalIcon />\n {continueLabel}\n </button>\n </div>\n <p className=\"nw-note\">Booking and payment happen on acme.example — never inside chat.</p>\n </section>\n </main>\n );\n}\n\nfunction CompassIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <circle cx=\"12\" cy=\"12\" r=\"9\" />\n <path d=\"m15.5 8.5-2 5-5 2 2-5 5-2Z\" />\n </svg>\n );\n}\n\nfunction ExternalIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M14 4h6v6\" />\n <path d=\"m20 4-9 9\" />\n <path d=\"M20 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5\" />\n </svg>\n );\n}\n" },
|
|
40
40
|
{ relPath: "examples/acme-discovery/src/views/widget-style.css", content: ":root {\n color-scheme: light dark;\n font-family:\n Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n --nw-bg: #ffffff;\n --nw-surface: #f4fbfa;\n --nw-text: #10201f;\n --nw-muted: #5b6b6a;\n --nw-border: #d4e6e4;\n --nw-accent: #0ea5a4;\n --nw-accent-strong: #0f766e;\n --nw-accent-soft: #e6faf8;\n --nw-radius: 10px;\n --nw-shadow: 0 18px 50px rgb(15 40 40 / 12%);\n}\n\n.dark,\n[data-theme=\"dark\"] {\n --nw-bg: #0b1b1b;\n --nw-surface: #102624;\n --nw-text: #eafaf8;\n --nw-muted: #9fb6b3;\n --nw-border: #244341;\n --nw-accent: #2dd4bf;\n --nw-accent-strong: #14b8a6;\n --nw-accent-soft: #0f3835;\n --nw-shadow: 0 18px 50px rgb(0 0 0 / 30%);\n}\n\n* {\n box-sizing: border-box;\n}\n\nbody {\n margin: 0;\n background: var(--nw-bg);\n color: var(--nw-text);\n}\n\nbutton {\n font: inherit;\n}\n\n.nw-shell {\n min-height: 100vh;\n padding: 14px;\n background: var(--nw-bg);\n color: var(--nw-text);\n}\n\n.nw-card {\n max-width: 720px;\n margin: 0 auto;\n background: var(--nw-surface);\n border: 1px solid var(--nw-border);\n border-radius: var(--nw-radius);\n box-shadow: var(--nw-shadow);\n overflow: hidden;\n}\n\n.nw-header {\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 16px;\n border-bottom: 1px solid var(--nw-border);\n}\n\n.nw-icon svg {\n width: 26px;\n height: 26px;\n fill: none;\n stroke: var(--nw-accent);\n stroke-width: 1.7;\n stroke-linecap: round;\n stroke-linejoin: round;\n}\n\n.nw-title-block {\n flex: 1;\n min-width: 0;\n}\n\n.nw-title {\n margin: 0;\n font-size: 17px;\n font-weight: 700;\n}\n\n.nw-subtitle {\n margin: 2px 0 0;\n font-size: 13px;\n color: var(--nw-muted);\n}\n\n.nw-chip {\n padding: 4px 10px;\n border-radius: 999px;\n background: var(--nw-accent-soft);\n color: var(--nw-accent-strong);\n font-size: 12px;\n font-weight: 600;\n}\n\n.nw-carousel {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));\n gap: 12px;\n padding: 16px;\n}\n\n.nw-dest {\n display: flex;\n flex-direction: column;\n gap: 6px;\n padding: 12px;\n border: 1px solid var(--nw-border);\n border-radius: 12px;\n background: var(--nw-bg);\n}\n\n.nw-dest-active {\n border-color: var(--nw-accent);\n box-shadow: 0 0 0 1px var(--nw-accent);\n}\n\n.nw-dest-head {\n display: flex;\n align-items: baseline;\n justify-content: space-between;\n gap: 8px;\n}\n\n.nw-dest-name {\n font-weight: 700;\n}\n\n.nw-price {\n color: var(--nw-accent-strong);\n font-size: 12px;\n font-weight: 600;\n}\n\n.nw-dest-region {\n margin: 0;\n font-size: 12px;\n color: var(--nw-muted);\n}\n\n.nw-dest-why {\n margin: 0;\n font-size: 13px;\n flex: 1;\n}\n\n.nw-actions {\n display: flex;\n gap: 8px;\n padding: 0 16px 12px;\n}\n\n.nw-button {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 9px 14px;\n border: 1px solid var(--nw-border);\n border-radius: 10px;\n background: var(--nw-bg);\n color: var(--nw-text);\n cursor: pointer;\n}\n\n.nw-button svg {\n width: 16px;\n height: 16px;\n fill: none;\n stroke: currentColor;\n stroke-width: 1.7;\n stroke-linecap: round;\n stroke-linejoin: round;\n}\n\n.nw-button-ghost {\n align-self: flex-start;\n padding: 6px 12px;\n font-size: 13px;\n}\n\n.nw-button-primary {\n background: var(--nw-accent);\n border-color: var(--nw-accent);\n color: #ffffff;\n font-weight: 600;\n}\n\n.nw-button-primary:disabled {\n opacity: 0.6;\n cursor: default;\n}\n\n.nw-note {\n margin: 0;\n padding: 0 16px 16px;\n font-size: 12px;\n color: var(--nw-muted);\n}\n" },
|
|
41
|
-
{ relPath: "examples/acme-discovery/test/server.test.ts", content: "import { describe, expect, it } from 'vitest';\nimport app from '../src/server.js';\n\ndescribe('acme-discovery example', () => {\n it('exports a Noodle server definition', () => {\n expect(typeof app.toManifest).toBe('function');\n });\n\n it('declares the off-app handoff domain the deep link lands on', async () => {\n // Top-of-funnel: the only external target is Acme's booking site, declared once at the server.\n const manifest = await app.toManifest();\n expect(JSON.stringify(manifest)).toContain('https://book.acme.example');\n });\n\n it('exposes the discovery tool and the handoff tool', async () => {\n const manifest = await app.toManifest();\n const text = JSON.stringify(manifest);\n // The discovery tool renders the carousel; the handoff tool emits the deep link; the widget-only\n // helper records a shortlist.\n expect(text).toContain('discover_getaways');\n expect(text).toContain('create_handoff');\n expect(text).toContain('shortlist_getaway');\n });\n\n it('gives the public website a consultative surface-specific goal', async () => {\n const manifest = await app.toManifest();\n expect(manifest.server.assistant?.surfaces?.[0]?.instructions).toContain(\n 'friendly, consultative travel guide',\n );\n });\n\n it('declares the grounded knowledge component and its live site scope', async () => {\n const manifest = (await app.toManifest()) as { server: { knowledge?: unknown[] } };\n // One declaration: controlled files plus the live public site, compiled later into the\n // generated `search_destinations` capability with citations.\n expect(manifest.server.knowledge).toHaveLength(1);\n const text = JSON.stringify(manifest);\n expect(text).toContain('knowledge/product.md');\n expect(text).toContain('https://getaways.acme.example');\n });\n});\n" },
|
|
41
|
+
{ relPath: "examples/acme-discovery/test/server.test.ts", content: "import { describe, expect, it } from 'vitest';\nimport app from '../src/server.js';\n\ndescribe('acme-discovery example', () => {\n it('exports a Noodle server definition', () => {\n expect(typeof app.toManifest).toBe('function');\n });\n\n it('declares the off-app handoff domain the deep link lands on', async () => {\n // Top-of-funnel: the only external target is Acme's booking site, declared once at the server.\n const manifest = await app.toManifest();\n expect(JSON.stringify(manifest)).toContain('https://book.acme.example');\n });\n\n it('exposes the discovery tool and the handoff tool', async () => {\n const manifest = await app.toManifest();\n const text = JSON.stringify(manifest);\n // The discovery tool renders the carousel; the handoff tool emits the deep link; the widget-only\n // helper records a shortlist.\n expect(text).toContain('discover_getaways');\n expect(text).toContain('create_handoff');\n expect(text).toContain('shortlist_getaway');\n });\n\n it('gives the public website a consultative surface-specific goal', async () => {\n const manifest = await app.toManifest();\n expect(manifest.server.assistant?.model).toEqual({ kind: 'noodle-managed' });\n expect(manifest.server.assistant?.surfaces?.[0]?.instructions).toContain(\n 'friendly, consultative travel guide',\n );\n });\n\n it('declares the grounded knowledge component and its live site scope', async () => {\n const manifest = (await app.toManifest()) as { server: { knowledge?: unknown[] } };\n // One declaration: controlled files plus the live public site, compiled later into the\n // generated `search_destinations` capability with citations.\n expect(manifest.server.knowledge).toHaveLength(1);\n const text = JSON.stringify(manifest);\n expect(text).toContain('knowledge/product.md');\n expect(text).toContain('https://getaways.acme.example');\n });\n});\n" },
|
|
42
42
|
{ relPath: "examples/acme-discovery/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" },
|
|
43
43
|
{ relPath: "examples/acme-tasks/README.md", content: "# Acme Tasks — designed around its top-3 prioritized user flows\n\nA Noodle MCP App for **Acme Tasks**, a fictional productivity tool. It is the flagship for\n**designing an app around its prioritized user flows**: a two-way (read + write) experience where the\ntop-3 flows — **Capture, Prioritize, Complete** — all happen in chat, each mapped to a tool and surfaced\nin one `TaskList` widget. It shows the \"identify and prioritize the flows first, then build\" discipline\nthe `noodle-seed` skill's `references/experience-design.md` teaches.\n\nCapability slots: prioritized multi-flow app design, a two-way (read + write) in-chat pattern, a task-list\nwidget with `tool` helpers, and a worked **design-first** artifact (the flow spec + wireframe\nbelow). A real deployment would connect the user's account with the end-user auth pattern — see\n[`../customer-auth/README.md`](../customer-auth/README.md); this example seeds a list so the focus stays\non the flows.\n\n## Design spec (write this before the code)\n\n- **What it is** — an in-chat task manager. Unlike a top-of-funnel app, there is **no handoff**: the value\n is doing the work in place (read the list, add, re-prioritize, complete).\n- **Personas** — the quick capturer (\"remind me to email the vendor\"), the morning triager (re-orders the\n day), the closer (marks things done without leaving chat).\n- **Top-3 prioritized user flows** (the heart of this example — build these, defer the rest)\n 1. **Capture** (`add_task`) — \"add: book flights for the offsite, high priority\" → task captured.\n 2. **Prioritize** (`list_today` renders the widget; `set_priority` re-orders) — triage today's list.\n 3. **Complete** (`complete_task`) — check a task off; the model can also complete on request.\n- **Tools** — `list_today` (model-visible, renders the widget), `add_task` and `complete_task`\n (model-visible), `set_priority` (widget-only helper hidden from the model).\n- **Widgets + display modes** — `TaskList` as an inline card that expands to fullscreen for a long list.\n No carousel or picture-in-picture — a single list is the right surface.\n- **Grounding** — the seeded list in `src/server.ts` stands in for the user's real list; the app never\n invents a task.\n- **Two users** — tools are atomic and model-fillable (\"high priority\" → `priority: \"high\"`), and each\n returns a spoken-ready status so the model can confirm in one turn.\n- **Cross-host confirmation** — `complete_task` keeps `confirm: true`. The server's explicit\n `interactions.confirmationFallback: 'host'` uses Noodle confirmation in capable/embedded hosts and trusts\n ChatGPT's native write approval only when the stateless transport cannot present that form. Backend\n authorization remains independent.\n\n## Wireframe (one screen: the three flows in place)\n\n```html\n<div class=\"phone\"> <!-- in-app: solid frame -->\n <div class=\"chatgpt-header\">ChatGPT · Acme Tasks</div>\n <div class=\"msg user\">what's on my list today?</div>\n <div class=\"tool-call\">list_today { focus: \"today\" }</div>\n <div class=\"wcard\">\n <div class=\"wcard-head\">TaskList</div> <!-- component name = code + spec -->\n <div class=\"wcard-body\">\n <input placeholder=\"Add a task…\" /> <!-- Flow 1: Capture -->\n <div class=\"task\">◻ Email the vendor about the Q3 quote [high ▾]</div> <!-- Flow 2 -->\n <div class=\"task\">◻ Review the analytics pull request [medium ▾]</div>\n <div class=\"task done\">✓ Book flights for the team offsite [low ▾]</div> <!-- Flow 3 -->\n </div>\n </div>\n</div>\n```\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 list_today --args '{\"focus\":\"today\"}'\nnoodle tools call add_task --args '{\"title\":\"Book flights for the offsite\",\"priority\":\"high\"}'\nnoodle tools call complete_task --args '{\"task\":\"review_pr\",\"title\":\"Review the analytics pull request\"}'\nnoodle check --target chatgpt\n```\n\n## Product agent guide\n\n[`src/agent-guide.ts`](src/agent-guide.ts) expresses the same three prioritized workflows as one host-neutral\n`agentGuide`. It supplies product judgment such as grounding and confirmation while the compiler derives\ncapability schemas, annotations, visibility, and widget relationships from `server.ts`. The guide does not\nweaken `complete_task` confirmation or make the app-only `set_priority` tool model-visible.\n\nPreview the generated Codex and Claude Code product skills before installing them:\n\n```sh\nnoodle agents setup --json\nnoodle agents setup --write\n```\n\nAfter changing a workflow or capability, regeneration is explicit so a normal Noodle workflow-skill update\ncannot overwrite the app product skill or local modifications:\n\n```sh\nnoodle agents setup --regenerate-app-skill --json\nnoodle agents setup --write --regenerate-app-skill\nnoodle agents doctor --json\n```\n\n## Deploy\n\n```sh\nnoodle link --org demo --app acme-tasks\nnoodle deploy --access owner-only\nnoodle open\n```\n\n## Optional in-product assistant\n\nThe default SaaS and widget scaffolds are credential-free. When the product deliberately includes an\nassistant, use the existing server tools and add an `assistant` option to the same `server.ts` instead of\ncreating a second entrypoint or tool set:\n\n```ts\nassistant: embeddedAssistant({\n model: openAICompatible({\n baseUrl: variable('ASSISTANT_MODEL_BASE_URL'),\n model: variable('ASSISTANT_MODEL'),\n apiKey: secret('ASSISTANT_MODEL_API_KEY'),\n }),\n allowedOrigins: ['https://tasks.example.com'],\n layout: { mode: 'floating', position: 'bottom-right' },\n labels: { welcomeHeading: 'How can I help with Acme Tasks?' },\n}),\n```\n\nThe assistant automatically inherits this server's existing `branding` block, so its name, accent,\nlight/dark surfaces, density, and radius match the `TaskList` widget without a second brand declaration.\n\nEvery assistant origin is exact and HTTPS-only. If the customer web app needs local browser testing, it\nmust serve itself over development HTTPS and add its `https://localhost:<port>` origin; `noodle dev` does\nnot provide TLS for that separate SaaS app.\n\nThe customer backend exchanges its authenticated user through `@noodleseed/assistant/server`; the browser\nuses the Web Component or React wrapper and never receives the embed client or model secret. Validate with\n`noodle check --target embedded-assistant`, then create the backend credential with\n`noodle assistant clients create` after deployment. Model URL/name/key values stay in Noodle managed config;\nonly the Noodle service URL and assistant client ID/secret belong in the authenticated customer backend.\n\nInstall the independently versioned embed SDK with the customer web application's existing package manager;\ndo not introduce a second lockfile.\n\nThis example has no connector secrets and does not include tokens, caller-key mechanisms, or\n`.env.noodle` values. All tasks are fictional seed data.\n" },
|
|
44
44
|
{ relPath: "examples/acme-tasks/design/UX-Document.md", content: "# Acme Tasks ChatGPT App — User Flow & Experience Document\n\n**Prepared by:** Noodle Seed\n**Scope:** Capture, prioritize, and complete today's tasks entirely inside ChatGPT — a two-way (read + write) task manager. There is no handoff; the value is doing the work in place.\n**Status:** Design specification (v1)\n**Two-way scope & auth stance:** IN-APP (in chat) — read today's list, capture new tasks, re-prioritize, and complete them, each write confirmed in-chat. OFF-APP — nothing transactional; the only boundary crossing is a **one-time scoped account connection** (`customerAuth` end-user OAuth: read + write, connected once). Every scope debate resolves here: if a request is \"see, add, re-order, or finish a task,\" it stays in chat; the account link is the single, revocable off-app moment.\n\n> This document is the master spec. The wireframes (`wireframe.html`), the widget code (`src/views/task-list.tsx`), and the server (`src/server.ts`) are all derivable from it. Acme Tasks is a fictional productivity app; all data below is sample content.\n\n---\n\n## Section 0 — The One-Paragraph Thesis\n\nA person mid-conversation in ChatGPT says *\"remind me to email the vendor about the Q3 quote\"* — and today that intent evaporates, because acting on it means leaving the conversation for a separate app. Acme Tasks closes that gap: the moment a task is spoken it is **captured, prioritized, and completed without ever leaving chat**. This is deliberately **not** a top-of-funnel handoff app — there is no cart to check out, no site to open, no \"continue in the app.\" The task manager *is* the conversation. We own the in-chat experience end to end (read the live list, add by natural language, re-prioritize, complete); the user owns their account, connected once through a scoped, revocable link. The strategic kicker: a to-do app is the highest-frequency surface a person touches, and the app that lets them clear their list *in the same window where the work is being discussed* becomes the one they never close. **In-chat completion is the product.**\n\n---\n\n## 1. Acme Tasks Product Overview (Knowledge Base)\n\n**What the company is.** Acme Tasks is a personal + small-team task manager: a single prioritized list per user, each task carrying a **title**, a **priority** (`high` / `medium` / `low`), and a **done** state. It is intentionally minimal — no projects, no assignees, no sub-tasks in v1 — so the model can reason about the whole list in one turn.\n\n**The data domain the app must know.** The user's *today* list. In this flagship the list is **seeded** so the design can focus on the flows; the three seed items are the canonical fixtures every downstream artifact reuses:\n\n| id | title | priority |\n| :--- | :--- | :--- |\n| `email_vendor` | Email the vendor about the Q3 quote | `high` |\n| `review_pr` | Review the analytics pull request | `medium` |\n| `book_offsite` | Book flights for the team offsite | `low` |\n\n**The highest-value / highest-risk domain** is the account write. Because the app can *complete* and *re-prioritize* real tasks, every mutation must be legible and confirmed — a silently-checked-off task is the worst possible failure.\n\n**What lives after any boundary crossing.** Nothing transactional. Unlike a discovery funnel, Acme Tasks has no off-app destination it hands users to; the only off-app step is the one-time `customerAuth` consent screen (§9). After that, everything is in chat.\n\n**Business model / why this matters.** Frequency and retention. A task app is opened many times a day; the version that removes the app-switch tax — \"I thought of it, I said it, it's on my list, it's done\" — wins the habit. The bottleneck the app removes is **context-switching**, not data entry.\n\n---\n\n## 2. Competitive Landscape — Task Managers on ChatGPT\n\nMost task integrations on assistant platforms are **read-only or one-way**: they can list what's due but push the user to a separate app to actually change anything, or they capture a task into a black box with no confirmation. The generic model can *talk about* a to-do list but has no grounded state — it will happily invent tasks that don't exist.\n\n**Acme Tasks' unique position** is the *closed two-way loop in one surface*: a grounded read (the real list, never guessed), natural-language writes (capture, re-prioritize, complete), and an in-chat confirmation for every mutation. The differentiator is not the widget — it is that the widget's actions **commit** and the user **sees what changed** without a tab switch.\n\n---\n\n## 3. Target User Personas\n\n- **The Quick Capturer** — *\"add: book flights for the offsite, low priority.\"* Thinks of a task mid-conversation and wants it on the list before the thought is gone. Values zero-friction capture.\n- **The Morning Triager** — *\"what's on my plate today?\"* Opens the day, scans the list, and re-orders priorities before starting. Values a fast, grounded read plus one-tap re-prioritize.\n- **The Closer** — *\"mark the vendor email done.\"* Finishes work and wants the satisfaction of checking it off without leaving the thread. Values instant, confirmed completion.\n\nAll three are the **same user at different moments of the day** — capture in the morning stand-up, triage before lunch, close out at end of day. The app is designed so one connected session serves all three.\n\n---\n\n## 4. Conversational User Flow\n\n### 4.1 Entry points (natural triggers)\n\n- **Capture:** \"remind me to…\", \"add a task…\", \"put X on my list\", \"I need to email the vendor.\"\n- **Prioritize / read:** \"what's on my list today?\", \"what's due?\", \"show my tasks\", \"make the PR review high priority.\"\n- **Complete:** \"mark X done\", \"I finished the vendor email\", \"check off the offsite booking.\"\n\n### 4.2 Flow architecture\n\n```\n ┌─────────────────────────────┐\n first run ─────────▶│ Connect Acme Tasks (once) │ §9 · customerAuth, scoped R/W\n └──────────────┬──────────────┘\n │ connection live thereafter\n ┌─────────────────────────────┼─────────────────────────────┐\n ▼ ▼ ▼\n ① CAPTURE ② PRIORITIZE ③ COMPLETE\n add_task list_today → TaskList complete_task\n \"add X, high\" (render widget) \"mark X done\"\n │ set_priority (in widget) │\n └──────────────▶ TaskList reflects the change ◀───────────┘\n (grounded read, always current)\n```\n\n`list_today` is the hub — it renders the `TaskList` widget the other two flows write into. Every flow returns a spoken-ready status so the model confirms in one turn.\n\n### 4.3 Detailed conversational scenarios (playscripts)\n\n**Scenario A — Capture (The Quick Capturer)**\n\n```\nUser: Add \"book flights for the team offsite\", low priority.\nTool call: add_task { title: \"Book flights for the team offsite\", priority: \"low\" }\nReturns: { status: \"Added “Book flights for the team offsite” (low).\", title, priority }\nAssistant: Added \"Book flights for the team offsite\" at low priority. Want to see the full list?\n```\n\n**Scenario B — Prioritize / read + re-order (The Morning Triager)**\n\n```\nUser: What's on my plate today?\nTool call: list_today { focus: \"today\" } ← renders TaskList widget\nReturns: { status: \"Acme Tasks for today: 3 open items, highest priority first.\", focus, tasks:[…] }\nWidget: TaskList — Email the vendor (high) · Review the analytics PR (medium) · Book offsite (low)\n\nUser: Bump the PR review to high.\nTool call: set_priority { task: \"review_pr\", priority: \"high\" } ← widget-only helper\nReturns: { status: \"Set review_pr to high priority.\", task, priority }\nWidget: TaskList re-orders — Review the analytics PR now sits with the high group.\n```\n\n**Scenario C — Complete (The Closer)**\n\n```\nUser: I finished the vendor email — mark it done.\nTool call: complete_task { task: \"email_vendor\", title: \"Email the vendor about the Q3 quote\" }\nReturns: { status: \"Completed “Email the vendor about the Q3 quote”.\", task }\nWidget: TaskList strikes the row through; open count drops from 3 → 2.\nAssistant: Done — \"Email the vendor about the Q3 quote\" is checked off. Two left today.\n```\n\n---\n\n## 5. UI Widget Specifications (Noodle Seed Apps Compliant)\n\n### 5.1 Design system compliance\n\nWidgets render inside the host (ChatGPT) via Noodle Seed's `view` component model and **CSS Cascade Layers**, so they inherit the host's light/dark surface and typography rather than shipping an app theme. Compliance rules, all enforceable via `noodle check --target chatgpt`:\n\n- **Tokens, not hard-coded chrome.** Text, surface, and border come from host/Noodle Seed semantic tokens. The **brand accent is declared once** in the server `branding` block — `accent: #7C3AED`, `surface: #F5F3FF`, `surfaceDark: #161228`, `radius: lg`, `density: comfortable` — and is restricted to the **primary CTA, the logo/check mark, and the \"high\" priority emphasis only**. It is never a background wash.\n- **Priority palette (semantic, fixed):** `high` = red `#DC2626`, `medium` = amber `#D97706`, `low` = slate `#6B7280`. These map 1:1 to the `priority` enum so the widget and the model share one vocabulary.\n- **System fonts, outlined monochrome icons, WCAG AA contrast, no nested scroll**, and every mutation shows a confirmable result. The widget exposes a single flat `data-llm` summary line (\"N open of M; K completed this session\") so the model can narrate state without re-reading the DOM.\n\n### 5.2 Display mode strategy\n\n| User intent | Display mode | Why |\n| :--- | :--- | :--- |\n| \"What's on my list?\" | **Inline card** | The whole list fits; the answer is the list. |\n| Triage a long list | **Fullscreen** (expand) | Density without nested scroll when items exceed the card. |\n| Capture / complete / re-prioritize | **Inline card (in place)** | The write updates the same card; no new surface. |\n\n**Deliberately NOT used:** *Carousel* (there is one list, not a set of peers) and *Picture-in-Picture* (nothing runs in the background). Stating the omissions is part of the compliance story.\n\n### 5.3 Widget specifications\n\n**★ `TaskList`** — the single widget; the hub all three flows read and write.\n- **Purpose:** show today's prioritized list and let the user capture, re-prioritize, and complete in place.\n- **Content fields:** header (logo, \"Acme Tasks\", status subtitle, open-count chip); a **capture input** (\"Add a task…\"); a **task row** per item = complete check + title + priority `<select>`; a footer note (\"Two-way in chat: read, capture, re-prioritize, complete\").\n- **Actions (≤2 primary):** **Add** (capture) and the per-row **complete check**; re-prioritize is a lightweight inline `<select>`, not a primary CTA.\n- **States:** *default* (seeded list), *captured* (new row appears immediately, then `add_task` records it), *re-prioritized* (row moves priority group), *completed* (row struck through, check filled, count decremented), *all-clear* (empty-state celebration + nudge to capture the next thing).\n- **Grounding flag:** the list only ever shows tasks that exist in state — the app never invents a task.\n\n---\n\n## 6. Tool Definitions (App Backend)\n\nAll tools are atomic, model-fillable from natural language, and each returns a spoken-ready `status`.\n\n**★ `list_today`** *(read-only · renders `TaskList`)*\n- **Input:** `{ focus: string = \"today\" }`\n- **Output:** `{ status, focus, tasks: [{ id, title, priority, done }] }`\n- **Notes:** `tool` — the one tool that opens the widget. Host status copy: invoking \"Loading your tasks…\", invoked \"Tasks ready\".\n\n**★ `add_task`** *(local write, non-destructive · model-visible)*\n- **Input:** `{ title: string, priority: \"high\"|\"medium\"|\"low\" = \"medium\" }`\n- **Output:** `{ status, title, priority }`\n- **Notes:** capture from natural language; \"high priority\" fills `priority: \"high\"`.\n\n**`complete_task`** *(local write, non-destructive · model-visible)*\n- **Input:** `{ task: string (id), title: string = \"\" }`\n- **Output:** `{ status, task }`\n- **Notes:** model-visible so the user can complete by voice (\"mark the vendor email done\") without touching the widget.\n\n**`set_priority`** *(local write, non-destructive · widget-only)*\n- **Input:** `{ task: string (id), priority: \"high\"|\"medium\"|\"low\" }`\n- **Output:** `{ status, task, priority }`\n- **Notes:** `tool` — hidden from the model; the `<select>` in `TaskList` is its only caller, keeping the model's tool surface to the three it should reason about.\n\n---\n\n## 7. Conversation Design Principles\n\n**Tone.** Brisk, confirming, never chatty. A task app earns trust by getting out of the way; every reply names *what changed* and offers the obvious next move.\n\n**Guardrails (non-negotiable):**\n- **Never invent a task.** The list is grounded in `list_today`'s returned state; if the app hasn't read a list, it says so rather than guessing.\n- **Never mutate silently.** Capture, re-prioritize, and complete each return a visible confirmation and update the widget — the user always sees the new state.\n- **Confirm completion explicitly.** \"Done — *X* is checked off\" plus the remaining count; completion is irreversible-feeling, so it is always narrated.\n- **Priority is the user's, not the model's.** The model may *suggest* a priority when capturing (\"this sounds high?\") but sets what the user says; it does not silently re-rank the list.\n\n**Memory strategy.** Session-scoped: captured tasks and completions persist across turns within the conversation (`added`/`done`/`priority` state layered over the seeded read). A production deployment persists to the connected account (§9).\n\n**Multi-turn intelligence.** The model **infers** structured fields from prose (\"book flights for the offsite, low\" → title + `priority: low`) and **asks** only when a title is genuinely missing. It never asks the user to restate a task it can already see in `TaskList`.\n\n---\n\n## 8. End-to-End User Journey Map\n\n| Phase | Time budget | What happens |\n| :--- | :--- | :--- |\n| **First run — Connect** | one-time, ~10s | Scoped `customerAuth` consent (read + write); dismissed forever after (§9). |\n| **Read (grounded)** | first 3–5s | \"What's on my plate?\" → `list_today` renders `TaskList` with the real list. |\n| **Capture** | ~2s per task | \"Add X, high\" → row appears instantly, `add_task` records it. |\n| **Prioritize** | ~2s per change | Inline `<select>` or \"bump the PR to high\" → `set_priority`, list re-orders. |\n| **Complete** | ~2s per task | Check the row or \"mark X done\" → `complete_task`, struck through, count drops. |\n| **Close-out** | end of day | All-clear empty state; nudge to capture tomorrow's first task. |\n\n---\n\n## 9. Account-Connection / Auth Architecture (Deep Dive)\n\nBecause Acme Tasks **writes** to the user's tasks, the first interaction is a **scoped, one-time connection** — Noodle Seed's `customerAuth` end-user OAuth pattern (see the `customer-auth` example). This replaces the \"handoff\" a top-of-funnel app would have: there is no destination to send the user to, only an account to link.\n\n**What must be true of the connection:**\n- **Plain-language scope.** The consent card names *read* (your tasks and priorities) and *write* (add, re-prioritize, complete tasks you ask me to) in the user's words — not buried in an OAuth redirect.\n- **Held by the connector, never the model.** The delegated credential is exchanged and stored by the credential broker; it is never surfaced in tool payloads, the widget, logs, or the model's context.\n- **Connected once, revocable anytime.** One connection powers all three flows; the consent card links the revoke path.\n- **The request resumes automatically.** After the user approves, the original ask (\"what's on my plate?\") continues without re-typing.\n\n**Flagship simplification (honest note):** this example ships with a **seeded** `today` list rather than a live account, so the design can stay focused on the three flows. The connect screen is wireframed as step 1 because it is the production pattern; the seed list stands in for the connected account's read. A real deployment swaps the seed for `customerAuth`-brokered account reads/writes — the tool signatures and widget do not change.\n\n**Edge cases at the boundary:** connection declined (app degrades to a read-only explanation, no writes attempted); token revoked mid-session (next write returns a re-connect prompt, never a silent failure); scope mismatch (write attempted without write scope → explicit \"reconnect to allow changes\").\n\n---\n\n## 10. Demo Scope Recommendation\n\n**MVP (build these, defer the rest):** the three flows against the seeded list, in one `TaskList` widget — Capture (`add_task`), Prioritize (`list_today` + `set_priority`), Complete (`complete_task`).\n\n**2-minute demo script:**\n1. **0:00** — \"What's on my plate today?\" → `TaskList` renders the three seed tasks, highest priority first. *(grounded read)*\n2. **0:25** — \"Add 'draft the board update', high priority.\" → new row appears at the top instantly. *(capture)*\n3. **0:50** — Open the PR review's priority `<select>`, set **high** → list re-orders. *(prioritize, in-widget)*\n4. **1:15** — \"I finished the vendor email — mark it done.\" → row strikes through, count 4 → 3. *(complete by voice)*\n5. **1:40** — Check the last row in the widget → all-clear empty state + \"capture tomorrow's first task?\" *(close the loop)*\n\n---\n\n## 11. Technical Architecture (High Level)\n\n- **Server:** one `server('acme_tasks', …)` in `src/server.ts` (Noodle Seed authoring SDK), four tools, `branding` tokens, per-tool CSP allowlist.\n- **Widget:** `TaskList` (`src/views/task-list.tsx`), a React `view` using `useCallTool` / `useToolInfo` / `useLayout` / `useViewState`; local session state (`added` / `done` / `priority`) layered over the `list_today` read, each change recorded through a tool call.\n- **State:** session-scoped in the flagship (seed list + local overlay). Production: `customerAuth`-brokered reads/writes to the account.\n- **Validation loop:** `noodle validate` → `noodle test` → `noodle dev`; compliance via `noodle check --target chatgpt`.\n\n---\n\n## 12. Success Metrics\n\n| Metric | Maps to |\n| :--- | :--- |\n| **Connect completion rate** | % of first-runs that finish the `customerAuth` consent (§9). |\n| **In-chat write rate** | writes (`add_task` + `set_priority` + `complete_task`) per session — the core \"work done in chat\" signal. |\n| **Capture-to-list latency** | time from utterance to the row appearing in `TaskList` (target < 1s optimistic). |\n| **Completion rate** | % of read sessions that end with at least one `complete_task` — the retention-driving \"closed the loop\" event. |\n| **Return frequency** | sessions per user per day — the habit metric a task app lives or dies on. |\n\n**Attribution:** each tool call carries the connected account identity (server identity + tenant), so writes are traceable to the session without exposing the credential.\n\n---\n\n## 13. Future Enhancements (Post-Launch)\n\n- **Live account** via `customerAuth` replacing the seed list (the natural first step out of the flagship).\n- **Due dates & scheduling** (\"email the vendor by Friday\") once the model can be trusted to parse relative dates.\n- **Bulk triage** (\"move everything low to tomorrow\") — a batched, previewed, reversible write.\n- **Projects / grouping** beyond a single flat list, once the single-list flows are proven.\n- **Undo** as a first-class in-chat verb for every write.\n\n---\n\n## Appendix — Two-Way Scope Cheat-Sheet\n\n| User request | In chat (in-app) | Off-app |\n| :--- | :---: | :---: |\n| \"What's on my list?\" | ✓ `list_today` → `TaskList` | — |\n| \"Add a task…\" | ✓ `add_task` | — |\n| \"Make X high priority\" | ✓ `set_priority` | — |\n| \"Mark X done\" | ✓ `complete_task` | — |\n| First-run account link | — | ✓ `customerAuth` consent (once, scoped, revocable) |\n| Anything transactional | — | *(none — the app has no transactional off-app step)* |\n" },
|
|
@@ -52,7 +52,7 @@ export const BUNDLED_EXAMPLE_FILES = [
|
|
|
52
52
|
{ relPath: "examples/acme-tasks/src/views/widget-style.css", content: ":root {\n color-scheme: light dark;\n font-family:\n Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n --nw-bg: #ffffff;\n --nw-surface: #f7f5ff;\n --nw-text: #1c1830;\n --nw-muted: #635d80;\n --nw-border: #e2ddf5;\n --nw-accent: #7c3aed;\n --nw-accent-strong: #6d28d9;\n --nw-accent-soft: #efe9ff;\n --nw-high: #dc2626;\n --nw-medium: #b45309;\n --nw-low: #2563eb;\n --nw-radius: 10px;\n --nw-shadow: 0 18px 50px rgb(30 20 60 / 12%);\n}\n\n.dark,\n[data-theme=\"dark\"] {\n --nw-bg: #161228;\n --nw-surface: #1d1735;\n --nw-text: #f2eeff;\n --nw-muted: #a99fce;\n --nw-border: #342a54;\n --nw-accent: #a78bfa;\n --nw-accent-strong: #8b5cf6;\n --nw-accent-soft: #2a2148;\n --nw-high: #f87171;\n --nw-medium: #fbbf24;\n --nw-low: #60a5fa;\n --nw-shadow: 0 18px 50px rgb(0 0 0 / 32%);\n}\n\n* {\n box-sizing: border-box;\n}\n\nbody {\n margin: 0;\n background: var(--nw-bg);\n color: var(--nw-text);\n}\n\nbutton,\ninput,\nselect {\n font: inherit;\n}\n\n.nw-shell {\n min-height: 100vh;\n padding: 14px;\n background: var(--nw-bg);\n color: var(--nw-text);\n}\n\n.nw-card {\n max-width: 620px;\n margin: 0 auto;\n background: var(--nw-surface);\n border: 1px solid var(--nw-border);\n border-radius: var(--nw-radius);\n box-shadow: var(--nw-shadow);\n overflow: hidden;\n}\n\n.nw-header {\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 16px;\n border-bottom: 1px solid var(--nw-border);\n}\n\n.nw-icon svg {\n width: 24px;\n height: 24px;\n fill: none;\n stroke: var(--nw-accent);\n stroke-width: 2;\n stroke-linecap: round;\n stroke-linejoin: round;\n}\n\n.nw-title-block {\n flex: 1;\n min-width: 0;\n}\n\n.nw-title {\n margin: 0;\n font-size: 17px;\n font-weight: 700;\n}\n\n.nw-subtitle {\n margin: 2px 0 0;\n font-size: 13px;\n color: var(--nw-muted);\n}\n\n.nw-chip {\n padding: 4px 10px;\n border-radius: 999px;\n background: var(--nw-accent-soft);\n color: var(--nw-accent-strong);\n font-size: 12px;\n font-weight: 600;\n}\n\n.nw-capture {\n display: flex;\n gap: 8px;\n padding: 14px 16px 4px;\n}\n\n.nw-input {\n flex: 1;\n padding: 9px 12px;\n border: 1px solid var(--nw-border);\n border-radius: 10px;\n background: var(--nw-bg);\n color: var(--nw-text);\n}\n\n.nw-button {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 9px 14px;\n border: 1px solid var(--nw-border);\n border-radius: 10px;\n background: var(--nw-bg);\n color: var(--nw-text);\n cursor: pointer;\n}\n\n.nw-button-primary {\n background: var(--nw-accent);\n border-color: var(--nw-accent);\n color: #ffffff;\n font-weight: 600;\n}\n\n.nw-button-primary:disabled {\n opacity: 0.6;\n cursor: default;\n}\n\n.nw-list {\n list-style: none;\n margin: 0;\n padding: 8px 16px 4px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n}\n\n.nw-task {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 10px 12px;\n border: 1px solid var(--nw-border);\n border-radius: 12px;\n background: var(--nw-bg);\n}\n\n.nw-task-title {\n flex: 1;\n min-width: 0;\n}\n\n.nw-task-done {\n opacity: 0.55;\n}\n\n.nw-task-done .nw-task-title {\n text-decoration: line-through;\n}\n\n.nw-check {\n width: 22px;\n height: 22px;\n border: 1.5px solid var(--nw-border);\n border-radius: 999px;\n background: transparent;\n color: #ffffff;\n cursor: pointer;\n flex: none;\n}\n\n.nw-check-on {\n background: var(--nw-accent);\n border-color: var(--nw-accent);\n}\n\n.nw-priority {\n padding: 5px 8px;\n border: 1px solid var(--nw-border);\n border-radius: 8px;\n background: var(--nw-bg);\n color: var(--nw-text);\n font-size: 12px;\n font-weight: 600;\n}\n\n.nw-priority-high {\n color: var(--nw-high);\n}\n\n.nw-priority-medium {\n color: var(--nw-medium);\n}\n\n.nw-priority-low {\n color: var(--nw-low);\n}\n\n.nw-note {\n margin: 0;\n padding: 8px 16px 16px;\n font-size: 12px;\n color: var(--nw-muted);\n}\n" },
|
|
53
53
|
{ relPath: "examples/acme-tasks/test/server.test.ts", content: "import { describe, expect, it } from 'vitest';\nimport app from '../src/server.js';\n\ndescribe('acme-tasks example', () => {\n it('exports a Noodle server definition', () => {\n expect(typeof app.toManifest).toBe('function');\n });\n\n it('exposes a tool for each of the top-3 prioritized flows', async () => {\n // Capture → add_task, Prioritize → list_today (+ set_priority helper), Complete → complete_task.\n const text = JSON.stringify(await app.toManifest());\n expect(text).toContain('add_task');\n expect(text).toContain('list_today');\n expect(text).toContain('complete_task');\n expect(text).toContain('set_priority');\n });\n\n it('seeds today’s list highest-priority first', async () => {\n const text = JSON.stringify(await app.toManifest());\n expect(text).toMatch(/\"tasks\":\\[\\{\"id\":\"email_vendor\".*\"priority\":\"high\"/);\n });\n\n it('opts the conversational completion action into runtime confirmation', async () => {\n const manifest = await app.toManifest();\n const completeTask = manifest.tools.find((candidate) => candidate.name === 'complete_task');\n const addTask = manifest.tools.find((candidate) => candidate.name === 'add_task');\n const setPriority = manifest.tools.find((candidate) => candidate.name === 'set_priority');\n\n expect(completeTask?.annotations?.confirm).toBe(true);\n expect(addTask?.annotations).not.toHaveProperty('confirm');\n expect(setPriority?.visibility).toEqual(['app']);\n });\n\n it('teaches its three product workflows through one host-neutral agent guide', async () => {\n const manifest = await app.toManifest();\n const guide = manifest.server.agentGuide;\n\n expect(guide?.workflows.map((workflow) => workflow.id)).toEqual([\n 'review_tasks',\n 'capture_task',\n 'complete_task',\n ]);\n expect(\n guide?.workflows.flatMap((workflow) => workflow.steps.map((step) => step.capability.name)),\n ).toEqual(expect.arrayContaining(['list_today', 'set_priority', 'add_task', 'complete_task']));\n expect(\n guide?.workflows\n .find((workflow) => workflow.id === 'review_tasks')\n ?.steps.map((step) => step.capability.name),\n ).toContain('set_priority');\n expect(\n guide?.examples.every((example) =>\n guide.workflows.some((workflow) => workflow.id === example.workflow),\n ),\n ).toBe(true);\n expect(guide?.boundaries.some((boundary) => boundary.toLowerCase().includes('confirm'))).toBe(\n true,\n );\n });\n});\n" },
|
|
54
54
|
{ relPath: "examples/acme-tasks/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" },
|
|
55
|
-
{ relPath: "examples/customer-auth/README.md", content: "# Customer Auth - OIDC identity and customer-routed APIs\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\nIt also owns the customer-branded embedded-assistant presentation showcase. Direct MCP calls obtain the\nroute from the verified OIDC claim; embedded sessions obtain it from the authenticated customer backend's\nsession exchange. Both paths keep the URL outside tool/model/browser-visible 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- `list_my_organizations` lists the NoodleSeed.com organizations the signed-in customer belongs to (no\n arguments — the org set comes from the verified customer session).\n- `list_org_apps` lists apps for one of those organizations through that tenant's API. It is visible and\n callable only when the verified customer has the `org_apps:read` scope and either the `org_admin` or\n `org_member` role.\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 server also declares one typed `agentGuide` for those product workflows. The deployed embedded assistant\nuses it automatically: each turn keeps only complete workflows supported by the verified session's roles,\nscopes, and model-visible tools. An organization member can receive organization/app review guidance, while\nonly an administrator with `org_apps:write` receives the complete archive workflow and its confirmation\nboundary. The guide stays server-side, so the Web Component, React renderer, headless hook, and public client\nneed no new option and receive no raw skill content. See\n[using a product guide at runtime](https://docs.noodleseed.dev/docs/guides/product-agent-guides#use-the-guide-at-runtime)\nfor the public behavior guide.\n\nA skill-aware external agent connected directly to the same tenant MCP URL receives the same\ncomplete-workflow filtering through the modern draft MCP Skills extension. Members and administrators may\ntherefore receive different `SKILL.md` and MCP-surface bytes, each with matching caller-specific digests.\nThis reuses the configured customer OAuth boundary; it does not require a second skill installation or auth\nsystem, and it is not a claim that every external host currently implements the draft extension. The\nsame [runtime guide](https://docs.noodleseed.dev/docs/guides/product-agent-guides#use-the-guide-at-runtime)\nexplains this preview boundary.\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. The\nserver must declare `customerAuth.*(...)` or `embeddedAssistant(...)` so Noodle Seed can establish the caller\nsubject, issuer, and audience. Otherwise `noodle validate`, `noodle auth doctor`, and deploy fail early with\n`delegated_token_exchange_identity_required`, before secrets are resolved or any connector egress. A\nsuccessful local Devtools exchange is not evidence that the hosted server has an identity source. Devtools\nsupplies a separate, loopback-only local identity context that is never accepted by hosted deployment.\n\nAt both connector and operation level, auth must be omitted or use `delegatedTokenExchange`. The compiler\nvalidates the concrete connector definition emitted from TypeScript, including connector defaults and\noperation overrides, and reports the exact failing auth path and kind. Do not keep a bearer, API-key,\nclient-credentials, or managed-provider fallback for local mode; use operation fakes while leaving auth\ndeclarative.\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 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. Route availability never changes `tools/list`: discovery remains based only on\nroles/scopes. A restricted tool is omitted for an ineligible customer and a guessed direct call still fails\nclosed.\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. The exchange wire\ncontract lives in docs/spec/connectors.md.\n\nFirebase and Microsoft remain supported managed adapters; their provider-specific contracts and tests live\nin docs/spec/auth-and-policy.md and the SharePoint flagship.\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 OpenAI Chat Completions-compatible endpoint. Configure its\nmanaged values at the Noodle deployment environment; none of these values belongs in the customer web\napplication environment, and the API key never reaches the browser:\n\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\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.\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\nFor an entirely application-owned React renderer, use the renderer-free hook. It creates no custom element\nand returns the AI SDK transcript plus the canonical client commands:\n\n```tsx\n'use client';\n\nimport { 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}: {\n principalKey: string;\n resolvedTheme: 'light' | 'dark';\n}) {\n const [draft, setDraft] = useState('');\n const { client, messages, status, error } = useNoodleAssistant({\n sessionEndpoint: '/api/noodle-assistant/session',\n principalKey,\n });\n const busy = status === 'submitted' || status === 'streaming';\n const settle = (operation: Promise<void>) => {\n void operation.catch(() => {\n // The hook exposes this same structured failure through `error`.\n });\n };\n\n return (\n <section aria-label=\"Assistant\" aria-busy={busy}>\n {messages.map((message) => (\n <article key={message.id} data-role={message.role}>\n {message.parts.map((part, index) => {\n if (part.type === 'text') return <p key={index}>{part.text}</p>;\n if (part.type === 'data-confirmation') {\n const review = part.data;\n return (\n <section key={review.id} aria-label=\"Review proposed action\">\n <h3>{review.title ?? 'Review proposed action'}</h3>\n {review.description ? <p>{review.description}</p> : null}\n <pre aria-label=\"Proposed action arguments\">\n {JSON.stringify(review.arguments ?? {}, null, 2)}\n </pre>\n <button\n disabled={busy || review.status !== 'pending'}\n onClick={() => settle(client.respond(review.id, { action: 'accept' }))}\n >\n Confirm\n </button>\n <button\n disabled={busy || review.status !== 'pending'}\n onClick={() => settle(client.respond(review.id, { action: 'decline' }))}\n >\n Don't proceed\n </button>\n </section>\n );\n }\n if (part.type === 'data-input-request') {\n const request = part.data;\n return (\n <section key={request.id} aria-label=\"Assistant needs input\">\n <p>{request.message}</p>\n <p>This renderer has not implemented the requested form.</p>\n <button\n disabled={busy || request.status !== 'pending'}\n onClick={() => settle(client.respond(request.id, { action: 'decline' }))}\n >\n Cancel request\n </button>\n </section>\n );\n }\n if (part.type === 'data-tool-result') {\n return (\n <pre key={part.data.id} aria-label={`${part.data.tool} result`}>\n {JSON.stringify(part.data.result, null, 2)}\n </pre>\n );\n }\n if (part.type === 'data-view') {\n return (\n <NoodleAppView\n key={`${part.data.id}:${part.data.resourceUri}`}\n client={client}\n view={part.data}\n theme={resolvedTheme}\n />\n );\n }\n return <p key={index}>Unsupported assistant content.</p>;\n })}\n </article>\n ))}\n {error ? <p role=\"alert\">{error.message}</p> : null}\n <form\n onSubmit={(event) => {\n event.preventDefault();\n const message = draft.trim();\n if (!message) return;\n setDraft('');\n settle(client.sendMessage(message));\n }}\n >\n <input\n aria-label=\"Message\"\n value={draft}\n onChange={(event) => setDraft(event.currentTarget.value)}\n />\n {busy ? (\n <button type=\"button\" onClick={() => client.abort()}>\n Stop\n </button>\n ) : (\n <button type=\"submit\">Send</button>\n )}\n </form>\n </section>\n );\n}\n```\n\n`principalKey` stays in the browser. Change it whenever the authenticated user or tenant changes; the hook\nthen aborts and clears the prior session and transcript. The sample fails closed on input requests until its\nfallback is replaced with a form generated from `requestedSchema`. A production renderer must show the\ncomplete confirmation review and both decisions. For `data-view`, map `resourceUri` or `tool` and the\nbounded/redacted result to a component already trusted by this application only when intentionally replacing\nthe linked App with a native UI. Otherwise use `NoodleAppView`; JSON result data is not the App UI. Its\nsemantic lifecycle identity is the client plus `view.id` plus `view.resourceUri`, so parent payload/callback\nrerenders keep the iframe and only a different view or unmount tears down the bridge.\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\nAfter deployment, use the assistant doctor to verify the embed client, model, and static session 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 does not invent or test an application-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\nIf the application deliberately sends a first turn on mount, do not combine a persistent \"sent\" ref with a\nmount effect. React Strict Mode can abort that provisional request and then suppress the stable remount.\nSchedule the send after the provisional cleanup and settle its promise:\n\n```tsx\nuseEffect(() => {\n let active = true;\n queueMicrotask(() => {\n if (active) settle(client.sendMessage(initialMessage));\n });\n return () => {\n active = false;\n };\n}, [client, initialMessage]);\n```\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 `NoodleAppView` on `view_available`\nor 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\nOutside React, subscribe to the DOM-free client directly. It exposes the same conversation as headless AI\nSDK `UIMessage` state, including typed confirmation, input, tool-result, and linked-view parts:\n\n```ts\nimport { createAssistantClient } from '@noodleseed/assistant/client';\n\nconst assistant = createAssistantClient({\n sessionEndpoint: '/api/noodle-assistant/session',\n});\n\nassistant.subscribeChat((state) => {\n renderUIMessageState(state);\n for (const message of state.messages) {\n for (const part of message.parts) {\n if (part.type === 'data-confirmation' && part.data.status === 'pending') {\n renderConfirmation(part.data, (response) => assistant.respond(part.data.id, response));\n }\n }\n }\n});\n```\n\n`theme=\"auto\"` follows the operating-system preference, not a SaaS-owned toggle. Pass the resolved\n`light`/`dark` theme to `NoodleAssistant` and `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## Deploy customer-protected to Noodle Seed Cloud\n\n```bash\nnoodle deploy examples/customer-auth/src/server.ts \\\n --org noodleseed \\\n --app customer-auth \\\n --env prod \\\n --access customers\n```\n\nEndpoint:\n\n```text\nhttps://cloud.noodleseed.dev/o/noodleseed/customer-auth/mcp\n```\n\n## MCP Primitives\n\n- Tool `list_my_organizations`: calls `GET /api/organizations` and returns the organizations the signed-in\n customer is a member of. Takes no arguments; the org set is scoped by the verified customer session.\n- Tool `list_org_apps`: calls `GET /api/organizations/{org_id}/apps` for one organization `org_id`.\n- Tool `archive_org_app`: after confirmation, calls\n `POST /api/organizations/{org_id}/apps/{app_id}/archive`.\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" },
|
|
55
|
+
{ relPath: "examples/customer-auth/README.md", content: "# Customer Auth - OIDC identity and customer-routed APIs\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\nIt also owns the customer-branded embedded-assistant presentation showcase. Direct MCP calls obtain the\nroute from the verified OIDC claim; embedded sessions obtain it from the authenticated customer backend's\nsession exchange. Both paths keep the URL outside tool/model/browser-visible 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- `list_my_organizations` lists the NoodleSeed.com organizations the signed-in customer belongs to (no\n arguments — the org set comes from the verified customer session).\n- `list_org_apps` lists apps for one of those organizations through that tenant's API. It is visible and\n callable only when the verified customer has the `org_apps:read` scope and either the `org_admin` or\n `org_member` role.\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 server also declares one typed `agentGuide` for those product workflows. The deployed embedded assistant\nuses it automatically: each turn keeps only complete workflows supported by the verified session's roles,\nscopes, and model-visible tools. An organization member can receive organization/app review guidance, while\nonly an administrator with `org_apps:write` receives the complete archive workflow and its confirmation\nboundary. The guide stays server-side, so the Web Component, React renderer, headless hook, and public client\nneed no new option and receive no raw skill content. See\n[using a product guide at runtime](https://docs.noodleseed.dev/docs/guides/product-agent-guides#use-the-guide-at-runtime)\nfor the public behavior guide.\n\nA skill-aware external agent connected directly to the same tenant MCP URL receives the same\ncomplete-workflow filtering through the modern draft MCP Skills extension. Members and administrators may\ntherefore receive different `SKILL.md` and MCP-surface bytes, each with matching caller-specific digests.\nThis reuses the configured customer OAuth boundary; it does not require a second skill installation or auth\nsystem, and it is not a claim that every external host currently implements the draft extension. The\nsame [runtime guide](https://docs.noodleseed.dev/docs/guides/product-agent-guides#use-the-guide-at-runtime)\nexplains this preview boundary.\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. The\nserver must declare `customerAuth.*(...)` or `embeddedAssistant(...)` so Noodle Seed can establish the caller\nsubject, issuer, and audience. Otherwise `noodle validate`, `noodle auth doctor`, and deploy fail early with\n`delegated_token_exchange_identity_required`, before secrets are resolved or any connector egress. A\nsuccessful local Devtools exchange is not evidence that the hosted server has an identity source. Devtools\nsupplies a separate, loopback-only local identity context that is never accepted by hosted deployment.\n\nAt both connector and operation level, auth must be omitted or use `delegatedTokenExchange`. The compiler\nvalidates the concrete connector definition emitted from TypeScript, including connector defaults and\noperation overrides, and reports the exact failing auth path and kind. Do not keep a bearer, API-key,\nclient-credentials, or managed-provider fallback for local mode; use operation fakes while leaving auth\ndeclarative.\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 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. Route availability never changes `tools/list`: discovery remains based only on\nroles/scopes. A restricted tool is omitted for an ineligible customer and a guessed direct call still fails\nclosed.\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. The exchange wire\ncontract lives in docs/spec/connectors.md.\n\nFirebase and Microsoft remain supported managed adapters; their provider-specific contracts and tests live\nin docs/spec/auth-and-policy.md and the SharePoint flagship.\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 OpenAI Chat Completions-compatible endpoint. Configure its\nmanaged values at the Noodle deployment environment; none of these values belongs in the customer web\napplication environment, and the API key never reaches the browser:\n\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\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.\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\nFor an entirely application-owned React renderer, use the renderer-free hook. It creates no custom element\nand returns the AI SDK transcript plus the canonical client commands:\n\n```tsx\n'use client';\n\nimport { 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}: {\n principalKey: string;\n resolvedTheme: 'light' | 'dark';\n}) {\n const [draft, setDraft] = useState('');\n const { client, messages, status, error } = useNoodleAssistant({\n sessionEndpoint: '/api/noodle-assistant/session',\n principalKey,\n });\n const busy = status === 'submitted' || status === 'streaming';\n const settle = (operation: Promise<void>) => {\n void operation.catch(() => {\n // The hook exposes this same structured failure through `error`.\n });\n };\n\n return (\n <section aria-label=\"Assistant\" aria-busy={busy}>\n {messages.map((message) => (\n <article key={message.id} data-role={message.role}>\n {message.parts.map((part, index) => {\n if (part.type === 'text') return <p key={index}>{part.text}</p>;\n if (part.type === 'data-confirmation') {\n const review = part.data;\n return (\n <section key={review.id} aria-label=\"Review proposed action\">\n <h3>{review.title ?? 'Review proposed action'}</h3>\n {review.description ? <p>{review.description}</p> : null}\n <pre aria-label=\"Proposed action arguments\">\n {JSON.stringify(review.arguments ?? {}, null, 2)}\n </pre>\n <button\n disabled={busy || review.status !== 'pending'}\n onClick={() => settle(client.respond(review.id, { action: 'accept' }))}\n >\n Confirm\n </button>\n <button\n disabled={busy || review.status !== 'pending'}\n onClick={() => settle(client.respond(review.id, { action: 'decline' }))}\n >\n Don't proceed\n </button>\n </section>\n );\n }\n if (part.type === 'data-input-request') {\n const request = part.data;\n return (\n <section key={request.id} aria-label=\"Assistant needs input\">\n <p>{request.message}</p>\n <p>This renderer has not implemented the requested form.</p>\n <button\n disabled={busy || request.status !== 'pending'}\n onClick={() => settle(client.respond(request.id, { action: 'decline' }))}\n >\n Cancel request\n </button>\n </section>\n );\n }\n if (part.type === 'data-tool-result') {\n return (\n <pre key={part.data.id} aria-label={`${part.data.tool} result`}>\n {JSON.stringify(part.data.result, null, 2)}\n </pre>\n );\n }\n if (part.type === 'data-view') {\n return (\n <NoodleAppView\n key={`${part.data.id}:${part.data.resourceUri}`}\n client={client}\n view={part.data}\n theme={resolvedTheme}\n />\n );\n }\n return <p key={index}>Unsupported assistant content.</p>;\n })}\n </article>\n ))}\n {error ? <p role=\"alert\">{error.message}</p> : null}\n <form\n onSubmit={(event) => {\n event.preventDefault();\n const message = draft.trim();\n if (!message) return;\n setDraft('');\n settle(client.sendMessage(message));\n }}\n >\n <input\n aria-label=\"Message\"\n value={draft}\n onChange={(event) => setDraft(event.currentTarget.value)}\n />\n {busy ? (\n <button type=\"button\" onClick={() => client.abort()}>\n Stop\n </button>\n ) : (\n <button type=\"submit\">Send</button>\n )}\n </form>\n </section>\n );\n}\n```\n\n`principalKey` stays in the browser. Change it whenever the authenticated user or tenant changes; the hook\nthen aborts and clears the prior session and transcript. The sample fails closed on input requests until its\nfallback is replaced with a form generated from `requestedSchema`. A production renderer must show the\ncomplete confirmation review and both decisions. For `data-view`, map `resourceUri` or `tool` and the\nbounded/redacted result to a component already trusted by this application only when intentionally replacing\nthe linked App with a native UI. Otherwise use `<noodle-app-view>` or its React `NoodleAppView` adapter;\nJSON result data is not the App UI. The element's semantic lifecycle identity is the client plus `view.id`\nplus `view.resourceUri`, so parent payload/callback rerenders keep the iframe and only a different view,\ndisconnect, 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\nAfter deployment, use the assistant doctor to verify the embed client, model, and static session 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 does not invent or test an application-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\nIf the application deliberately sends a first turn on mount, do not combine a persistent \"sent\" ref with a\nmount effect. React Strict Mode can abort that provisional request and then suppress the stable remount.\nSchedule the send after the provisional cleanup and settle its promise:\n\n```tsx\nuseEffect(() => {\n let active = true;\n queueMicrotask(() => {\n if (active) settle(client.sendMessage(initialMessage));\n });\n return () => {\n active = false;\n };\n}, [client, initialMessage]);\n```\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\nOutside React, subscribe to the DOM-free client directly and use the isolated framework-neutral App host.\nIt exposes the same conversation as headless AI SDK `UIMessage` state, including typed confirmation, input,\ntool-result, and linked-view parts, without installing React:\n\n```html\n<noodle-app-view id=\"assistant-app-view\"></noodle-app-view>\n```\n\n```ts\nimport '@noodleseed/assistant/app-view';\nimport { createAssistantClient } from '@noodleseed/assistant/client';\n\nconst assistant = createAssistantClient({\n sessionEndpoint: '/api/noodle-assistant/session',\n});\nconst appView = document.querySelector('#assistant-app-view');\nif (!appView) throw new Error('Missing App view host');\nappView.client = assistant;\nappView.theme = resolvedTheme;\n\nassistant.subscribeChat((state) => {\n renderUIMessageState(state);\n for (const message of state.messages) {\n for (const part of message.parts) {\n if (part.type === 'data-confirmation' && part.data.status === 'pending') {\n renderConfirmation(part.data, (response) => assistant.respond(part.data.id, response));\n }\n if (part.type === 'data-view') appView.view = part.data;\n }\n }\n});\n```\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## Deploy customer-protected to Noodle Seed Cloud\n\n```bash\nnoodle deploy examples/customer-auth/src/server.ts \\\n --org noodleseed \\\n --app customer-auth \\\n --env prod \\\n --access customers\n```\n\nEndpoint:\n\n```text\nhttps://cloud.noodleseed.dev/o/noodleseed/customer-auth/mcp\n```\n\n## MCP Primitives\n\n- Tool `list_my_organizations`: calls `GET /api/organizations` and returns the organizations the signed-in\n customer is a member of. Takes no arguments; the org set is scoped by the verified customer session.\n- Tool `list_org_apps`: calls `GET /api/organizations/{org_id}/apps` for one organization `org_id`.\n- Tool `archive_org_app`: after confirmation, calls\n `POST /api/organizations/{org_id}/apps/{app_id}/archive`.\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" },
|
|
56
56
|
{ relPath: "examples/customer-auth/noodle.json", content: "{\n \"entrypoint\": \"src/server.ts\",\n \"name\": \"customer-auth\"\n}\n" },
|
|
57
57
|
{ 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" },
|
|
58
58
|
{ relPath: "examples/customer-auth/src/server.ts", content: "import {\n annotations,\n authenticatedWebsite,\n connector,\n customerAuth,\n customerEndpoint,\n embeddedAssistant,\n openAICompatible,\n secret,\n server,\n tool,\n variable,\n z,\n} from '@noodleseed/one';\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 }),\n // Production origins are exact HTTPS; http://localhost:<port> is allowed for local development.\n access: authenticatedWebsite({\n origins: [assistantOrigin, 'https://dev.noodleseed.com', 'http://localhost:3000'],\n }),\n layout: { mode: 'floating', position: 'bottom-right', panelWidth: 420 },\n labels: {\n welcomeHeading: 'How can I help with Noodle Seed?',\n composerPlaceholder: 'Ask about your apps…',\n },\n presentation: {\n panel: { surface: 'glass', elevation: 'soft', border: 'subtle' },\n launcher: { icon: 'brand-mark', status: 'session', effect: 'pulse' },\n header: {\n mark: 'status',\n badge: { text: 'Workspace online', tone: 'success', indicator: true },\n },\n composer: { leadingIcon: 'brand-mark', shape: 'pill' },\n },\n suggestedPrompts: ['Explain how to connect this customer-authenticated MCP server'],\n }),\n },\n [\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 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 contextProvider: true,\n input: z.object({}),\n // The customer API returns every organization for the signed-in customer in one response, with no\n // page parameter to pass through, so the bound is declared on the shape. A customer belongs to a\n // handful of organizations; `noodle check` reports an unbounded list as\n // `tool_design_output_bounds`.\n output: z.object({\n organizations: z.array(z.unknown()).max(100),\n }),\n annotations: annotations.readOnly(),\n fulfil({ connectors }) {\n const organizations = connectors.app_api.listOrganizations();\n return {\n organizations: organizations.organizations,\n };\n },\n }),\n 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" },
|