@noodleseed/one 0.155.0 → 0.157.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/README.md +5 -1
- package/dist/assistant-embed-django-tests.d.ts +3 -0
- package/dist/assistant-embed-django-tests.d.ts.map +1 -0
- package/dist/assistant-embed-django-tests.js +140 -0
- package/dist/assistant-embed-django-tests.js.map +1 -0
- package/dist/assistant-embed-django.d.ts +4 -0
- package/dist/assistant-embed-django.d.ts.map +1 -0
- package/dist/assistant-embed-django.js +259 -0
- package/dist/assistant-embed-django.js.map +1 -0
- package/dist/assistant-embed-installation.d.ts +29 -0
- package/dist/assistant-embed-installation.d.ts.map +1 -0
- package/dist/assistant-embed-installation.js +124 -0
- package/dist/assistant-embed-installation.js.map +1 -0
- package/dist/assistant-embed-profile.d.ts +8 -0
- package/dist/assistant-embed-profile.d.ts.map +1 -0
- package/dist/assistant-embed-profile.js +150 -0
- package/dist/assistant-embed-profile.js.map +1 -0
- package/dist/assistant-embed-scaffold-template.d.ts +3 -2
- package/dist/assistant-embed-scaffold-template.d.ts.map +1 -1
- package/dist/assistant-embed-scaffold-template.js +39 -84
- package/dist/assistant-embed-scaffold-template.js.map +1 -1
- package/dist/assistant-embed-tests.d.ts +5 -0
- package/dist/assistant-embed-tests.d.ts.map +1 -0
- package/dist/assistant-embed-tests.js +106 -0
- package/dist/assistant-embed-tests.js.map +1 -0
- package/dist/assistant-embed-vue.d.ts +3 -0
- package/dist/assistant-embed-vue.d.ts.map +1 -0
- package/dist/assistant-embed-vue.js +77 -0
- package/dist/assistant-embed-vue.js.map +1 -0
- package/dist/commands/assistant-embed-ops.d.ts.map +1 -1
- package/dist/commands/assistant-embed-ops.js +112 -63
- package/dist/commands/assistant-embed-ops.js.map +1 -1
- package/dist/commands/author-loop.d.ts.map +1 -1
- package/dist/commands/author-loop.js +24 -28
- package/dist/commands/author-loop.js.map +1 -1
- package/dist/commands/deploy-first-flow.d.ts.map +1 -1
- package/dist/commands/deploy-first-flow.js +26 -15
- package/dist/commands/deploy-first-flow.js.map +1 -1
- package/dist/commands/deploy-preflight.d.ts +5 -0
- package/dist/commands/deploy-preflight.d.ts.map +1 -1
- package/dist/commands/deploy-preflight.js +44 -5
- package/dist/commands/deploy-preflight.js.map +1 -1
- package/dist/commands/local-readiness.d.ts +9 -1
- package/dist/commands/local-readiness.d.ts.map +1 -1
- package/dist/commands/local-readiness.js +74 -4
- package/dist/commands/local-readiness.js.map +1 -1
- package/dist/commands/project-setup.d.ts.map +1 -1
- package/dist/commands/project-setup.js +10 -15
- package/dist/commands/project-setup.js.map +1 -1
- package/dist/control-plane-request.d.ts +4 -0
- package/dist/control-plane-request.d.ts.map +1 -1
- package/dist/control-plane-request.js +40 -20
- package/dist/control-plane-request.js.map +1 -1
- package/dist/project-scaffold-model.d.ts +14 -0
- package/dist/project-scaffold-model.d.ts.map +1 -0
- package/dist/project-scaffold-model.js +36 -0
- package/dist/project-scaffold-model.js.map +1 -0
- package/dist/project-scaffold-templates.d.ts.map +1 -1
- package/dist/project-scaffold-templates.js +73 -39
- package/dist/project-scaffold-templates.js.map +1 -1
- package/dist/project-scaffold-test-template.d.ts +4 -0
- package/dist/project-scaffold-test-template.d.ts.map +1 -0
- package/dist/project-scaffold-test-template.js +89 -0
- package/dist/project-scaffold-test-template.js.map +1 -0
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +1 -2
- package/dist/project.js.map +1 -1
- package/dist/widget-focused-template.d.ts +1 -1
- package/dist/widget-focused-template.d.ts.map +1 -1
- package/dist/widget-focused-template.js +34 -12
- package/dist/widget-focused-template.js.map +1 -1
- package/dist/widget-scaffold-template.d.ts +1 -1
- package/dist/widget-scaffold-template.d.ts.map +1 -1
- package/dist/widget-scaffold-template.js +75 -43
- package/dist/widget-scaffold-template.js.map +1 -1
- package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +17 -3
- package/node_modules/@noodle-borg/agent-kit/dist/skill-app-playbook-ref.js +2 -2
- package/node_modules/@noodle-borg/agent-kit/dist/skill-authoring-refs.js +9 -12
- package/node_modules/@noodle-borg/agent-kit/dist/skill-connect-refs.js +2 -2
- package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-operations-ref.js +1 -1
- package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-ref.js +32 -26
- package/node_modules/@noodle-borg/agent-kit/dist/skill-server-playbook-ref.js +2 -2
- package/node_modules/@noodle-borg/agent-kit/dist/skill-verification-ref.js +2 -1
- package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
- package/node_modules/@noodle-borg/assistant-gateway/package.json +1 -1
- package/node_modules/@noodle-borg/authoring/dist/assistant.d.ts +6 -0
- package/node_modules/@noodle-borg/authoring/dist/recording.d.ts +1 -1
- package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-assistant.js +10 -4
- package/node_modules/@noodle-borg/compiler/package.json +0 -1
- package/node_modules/@noodle-borg/deploy-client/dist/deploy-payload-size.js +6 -2
- package/node_modules/@noodle-borg/service/dist/registry-helpers.d.ts +1 -1
- package/node_modules/@noodle-borg/service/dist/routes/deploy-preflight.js +193 -96
- package/node_modules/@noodle-borg/service/package.json +1 -1
- package/node_modules/@noodle-borg/wire-contracts/dist/index.d.ts +3 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/index.js +3 -0
- package/node_modules/@noodleseed/assistant/README.md +21 -23
- package/node_modules/@noodleseed/assistant/package.json +1 -1
- package/package.json +5 -4
- package/dist/saas-scaffold-template.d.ts +0 -4
- package/dist/saas-scaffold-template.d.ts.map +0 -1
- package/dist/saas-scaffold-template.js +0 -56
- package/dist/saas-scaffold-template.js.map +0 -1
- package/node_modules/tailwindcss/LICENSE +0 -21
- package/node_modules/tailwindcss/README.md +0 -36
- package/node_modules/tailwindcss/dist/chunk-5JIJA4QV.mjs +0 -1
- package/node_modules/tailwindcss/dist/chunk-HMCCH6MG.mjs +0 -1
- package/node_modules/tailwindcss/dist/chunk-X4GG3EDV.mjs +0 -1
- package/node_modules/tailwindcss/dist/colors-C__qRT83.d.ts +0 -347
- package/node_modules/tailwindcss/dist/colors.d.mts +0 -347
- package/node_modules/tailwindcss/dist/colors.d.ts +0 -5
- package/node_modules/tailwindcss/dist/colors.js +0 -1
- package/node_modules/tailwindcss/dist/colors.mjs +0 -1
- package/node_modules/tailwindcss/dist/default-theme.d.mts +0 -1199
- package/node_modules/tailwindcss/dist/default-theme.d.ts +0 -1199
- package/node_modules/tailwindcss/dist/default-theme.js +0 -1
- package/node_modules/tailwindcss/dist/default-theme.mjs +0 -1
- package/node_modules/tailwindcss/dist/flatten-color-palette.d.mts +0 -6
- package/node_modules/tailwindcss/dist/flatten-color-palette.d.ts +0 -6
- package/node_modules/tailwindcss/dist/flatten-color-palette.js +0 -1
- package/node_modules/tailwindcss/dist/flatten-color-palette.mjs +0 -1
- package/node_modules/tailwindcss/dist/lib.d.mts +0 -379
- package/node_modules/tailwindcss/dist/lib.d.ts +0 -3
- package/node_modules/tailwindcss/dist/lib.js +0 -38
- package/node_modules/tailwindcss/dist/lib.mjs +0 -38
- package/node_modules/tailwindcss/dist/plugin.d.mts +0 -11
- package/node_modules/tailwindcss/dist/plugin.d.ts +0 -134
- package/node_modules/tailwindcss/dist/plugin.js +0 -1
- package/node_modules/tailwindcss/dist/plugin.mjs +0 -1
- package/node_modules/tailwindcss/dist/resolve-config-B4yBzhca.d.ts +0 -29
- package/node_modules/tailwindcss/dist/resolve-config-QUZ9b-Gn.d.mts +0 -190
- package/node_modules/tailwindcss/dist/types-DWdTiksJ.d.mts +0 -128
- package/node_modules/tailwindcss/index.css +0 -944
- package/node_modules/tailwindcss/package.json +0 -89
- package/node_modules/tailwindcss/preflight.css +0 -393
- package/node_modules/tailwindcss/theme.css +0 -510
- package/node_modules/tailwindcss/utilities.css +0 -1
|
@@ -10,6 +10,7 @@ export const BUNDLED_EXAMPLE_NAMES = [
|
|
|
10
10
|
"acme-tasks",
|
|
11
11
|
"acme-bistro",
|
|
12
12
|
"customer-auth",
|
|
13
|
+
"stateful-draft",
|
|
13
14
|
"gmail-multi-account",
|
|
14
15
|
"google-bigquery",
|
|
15
16
|
];
|
|
@@ -42,7 +43,7 @@ export const BUNDLED_EXAMPLE_FILES = [
|
|
|
42
43
|
{ 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('keeps the lead capture behind explicit confirmation and a managed customer sink', async () => {\n const manifest = (await app.toManifest()) as {\n tools: { name: string; annotations?: Record<string, unknown> }[];\n };\n const captureLead = manifest.tools.find((tool) => tool.name === 'capture_lead');\n // The confirmation card is the visitor's consent moment (ADR 0214): a lead may never leave the\n // conversation without it, and the sink endpoint/credential stay operator-managed data.\n expect(captureLead?.annotations?.confirm).toBe(true);\n const catalog = JSON.stringify(\n (app as unknown as { toConnectorCatalog: () => unknown }).toConnectorCatalog(),\n );\n expect(catalog).toContain('${env.LEAD_SINK_URL}');\n expect(catalog).toContain('LEAD_SINK_TOKEN');\n // Fixed attribution set in the request mapping, never model-supplied; no named vendor host.\n expect(catalog).toContain('website-assistant');\n expect(catalog).not.toContain('api.resend.com');\n expect(catalog).not.toContain('api.hubapi.com');\n });\n\n it('serves a mixed marketing surface and an authenticated account surface from one server', async () => {\n const manifest = await app.toManifest();\n const surfaces = manifest.server.assistant?.surfaces ?? [];\n expect(surfaces.map((surface) => surface.mode)).toEqual(['mixed', 'authenticated']);\n // The sign-in trigger is listed on the mixed surface so the assistant can offer it; the\n // authenticated surface carries its own narrowed list and voice.\n const capabilityNames = (surface: (typeof surfaces)[number]) =>\n surface.capabilities?.map((capability) => capability.name) ?? [];\n expect(capabilityNames(surfaces[0]!)).toContain('my_trips');\n expect(capabilityNames(surfaces[0]!)).toContain('capture_lead');\n expect(capabilityNames(surfaces[1]!)).toEqual([\n 'destinations',\n 'discover_getaways',\n 'create_handoff',\n 'my_trips',\n ]);\n // Authoring the sign-up label is the opt-in for the card's create-account button.\n expect(manifest.server.assistant?.labels?.signUpAction).toBe('Create free account');\n });\n\n it('opens the marketing surface to browser agents and leaves the account surface closed', async () => {\n const manifest = await app.toManifest();\n const surfaces = manifest.server.assistant?.surfaces ?? [];\n\n // Both front doors, asserted by count first: without it the per-surface claims below read a\n // missing surface as `undefined` and pass, so deleting a surface would silently satisfy them.\n expect(surfaces).toHaveLength(2);\n // ADR 0220: the opt-in governs *discovery* — whether the embed registers this session's already\n // projected tools with `document.modelContext`. A browser agent on Acme's marketing page reaches\n // exactly the six capabilities above, over the same authorization, confirmation, and budget path\n // the panel's own calls take. `capture_lead` still stops for its confirmation card.\n expect(surfaces[0]?.webmcp).toEqual({ enabled: true });\n // Per-surface opt-in exists so the two front doors can answer differently, and here they do: the\n // signed-in account surface carries a traveler's identity, so its tools are not advertised to\n // whatever agent happens to be running in that browser.\n expect(surfaces[1]?.webmcp).toBeUndefined();\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" },
|
|
43
44
|
{ relPath: "examples/acme-discovery/test/site-page.test.ts", content: "import { readFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { describe, expect, it } from 'vitest';\nimport app from '../src/server.js';\n\n/**\n * `site/index.html` is the demo half of the WebMCP story (ADR 0220): the marketing page a browser\n * agent actually visits. The compiled server says the marketing surface opts in; only a real page\n * running the real snippet shows what that buys.\n *\n * These guard the two properties that make the demo honest rather than the markup, which is meant to\n * be edited: the page mounts the published one-liner and nothing else, and it never advertises a\n * getaway the server cannot discuss.\n */\n\nconst page = readFileSync(join(import.meta.dirname, '..', 'site', 'index.html'), 'utf8');\n\ndescribe('the acme-discovery demo page', () => {\n it('mounts the assistant with the published one-line snippet', () => {\n expect(page).toContain('<script src=\"https://cloud.noodleseed.dev/v1/assistant/embed.js\"');\n expect(page).toMatch(/data-embed-id=\"pub_[a-z0-9]{20,64}\"/u);\n });\n\n it('carries bootstrap markup only, so the page never borrows the session itself', () => {\n // The bridge lives in the embed bundle, where it runs under the session's authority and budgets.\n // Page-local JavaScript reaching for the same tools would carry none of that, so there is none:\n // the demo's only script is the snippet above, and it has no body of its own.\n expect(page.match(/<script\\b/gu)).toHaveLength(1);\n expect(page).toMatch(/data-embed-id=\"pub_[a-z0-9]{20,64}\"><\\/script>/u);\n });\n\n it('is a placeholder deployment, not a live embed anyone can point at', () => {\n // Copying this file must not aim a stranger's page at a real deployment, so the id is fictional\n // and the README says how to mint your own.\n expect(page).toContain('pub_examplepublicembedid00');\n expect(page).toContain('noodle deploy');\n });\n\n it('offers only getaways the server can actually discuss', async () => {\n const catalog = JSON.stringify(await app.toManifest());\n const offered = [...page.matchAll(/<h3 class=\"listing-name\">([^<]+)<\\/h3>/gu)].map(\n (match) => match[1],\n );\n\n expect(offered.length).toBeGreaterThan(2);\n for (const name of offered) {\n expect(catalog, `${name} is on the page but not in the server's catalog`).toContain(name);\n }\n });\n});\n" },
|
|
44
45
|
{ 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" },
|
|
45
|
-
{ 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
|
|
46
|
+
{ 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 access: authenticatedWebsite({ origins: [variable('APP_ORIGIN')] }),\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\nBind `APP_ORIGIN` to the exact website origin. Production uses HTTPS; local development may use an exact\nloopback HTTP origin. The customer application runs its own development server beside `noodle dev`.\n\nIn the existing application, preview the adapter with\n`noodle assistant embed --framework nextjs --surface authenticated --dry-run --json`. Review the recipe,\ngenerated contents/hashes and conflicts before rerunning without `--dry-run`. Implement the named\n`authenticateAssistantRequest` seam with the application's existing login and server-owned membership.\nFollow the installed `NOODLE-INTEGRATION.md` for signed-out, wrong-origin, cross-tenant and browser checks.\nInstalled files are not integration proof; missing sandbox identities or backend evidence remain unverified.\nThe generated session route delegates guards, bounded parsing and exchange to\n`createAssistantSessionHandler` in `@noodleseed/assistant/server`. Implement the existing-session adapter;\ndo not copy token-exchange infrastructure. Run the supplied `test/noodle-assistant.test.ts` with Vitest,\nthen test the adapter against the host application's signed-out and cross-tenant membership fixtures.\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" },
|
|
46
47
|
{ 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" },
|
|
47
48
|
{ relPath: "examples/acme-tasks/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 Tasks × ChatGPT — Two-Way App 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: #1f1f1f; line-height: 1.55; }\n .mono { font-family: 'JetBrains Mono', monospace; }\n\n /* ── Acme Tasks branding + host-compliant tokens ── */\n :root {\n --accent: #7C3AED; /* branding.accent — CTAs, logo, high-priority emphasis only */\n --accent-soft: #EDE9FE;\n --accent-border: #DDD6FE;\n --ink: #161228; /* branding.surfaceDark — ChatGPT / system surface */\n --hi: #DC2626; /* priority: high */\n --med: #D97706; /* priority: medium */\n --low: #6B7280; /* priority: low */\n --green: #059669;\n --green-soft: #E7F6EF;\n --green-border: #A7E0C6;\n --amber: #B45309;\n --amber-soft: #FBF1E2;\n --amber-border: #EBD7A8;\n --blue-soft: #E8EEFB;\n --line: #ececeb;\n }\n\n .page-header { background: var(--ink); color: #fff; border-bottom: 3px solid var(--accent); 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: #b79bff; }\n .page-header p { font-size: 13px; color: #b3b3c2; margin-top: 4px; }\n .page-header .scope { display: inline-block; margin-top: 10px; font-size: 11px; font-weight: 600; letter-spacing: 0.4px; padding: 4px 12px; background: var(--accent); color: #fff; border-radius: 4px; }\n\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(--accent); }\n\n .container { max-width: 1480px; margin: 0 auto; padding: 40px 48px 90px; }\n\n /* ── Legend ── */\n .vocab { display: flex; gap: 18px; flex-wrap: wrap; margin: 0 0 24px; 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.accent { background: var(--accent); }\n .vocab-sw.hi { background: var(--hi); } .vocab-sw.med { background: var(--med); } .vocab-sw.low { background: var(--low); }\n .vocab-sw.green { background: var(--green); }\n .vocab-sw.ink { background: var(--ink); }\n .vocab-sw.grey { background: #cdd2d8; }\n\n .section { margin-bottom: 72px; }\n .section-label { font-size: 11px; font-weight: 700; letter-spacing: 1.5px; text-transform: uppercase; color: var(--accent); margin-bottom: 8px; display: block; }\n .section-title { font-size: 27px; font-weight: 800; letter-spacing: -0.5px; margin-bottom: 6px; color: var(--ink); }\n .section-subtitle { font-size: 14px; color: #5c6570; margin-bottom: 24px; max-width: 880px; }\n\n /* ── Rationale block ── */\n .rationale { background: #fff; border: 1px solid #e6e8ec; border-left: 3px solid var(--accent); border-radius: 10px; padding: 16px 20px; margin-bottom: 24px; 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(--green-soft); color: #15734d; }\n .r-tag.ui { background: var(--blue-soft); color: #1d4fa0; }\n .r-tag.acme { background: var(--accent-soft); color: #5b21b6; }\n .r-tag.trust { background: var(--amber-soft); color: #8a5a0a; }\n\n /* ── Phones ── */\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(--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(--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-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(--green); font-size: 11px; }\n\n .msg { max-width: 94%; font-size: 13px; line-height: 1.55; }\n .msg.user { align-self: flex-end; background: #ece9e3; color: #1f1f1f; padding: 10px 14px; border-radius: 18px 18px 4px 18px; margin-left: auto; }\n .msg.assistant { color: #1f1f1f; padding: 2px 0; }\n .msg.assistant strong { font-weight: 600; }\n .msg.assistant .ok { color: var(--green); font-weight: 600; }\n\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(--accent); border-radius: 5px; display: flex; align-items: center; justify-content: center; font-size: 12px; flex-shrink: 0; font-weight: 800; color: #fff; }\n .tool-call .label { font-weight: 700; color: #3d454e; }\n .mono-tool { font-family: 'JetBrains Mono', monospace; color: #8a929c; }\n\n /* ── Widget card (TaskList) ── */\n .wcard { border: 1.5px solid #e0e3e7; border-radius: 14px; background: #fff; overflow: hidden; }\n .wcard-head { padding: 11px 14px; background: #fbfbfa; border-bottom: 1px solid var(--line); display: flex; align-items: center; gap: 8px; }\n .wcard-head .wc-logo { width: 18px; height: 18px; background: var(--accent); border-radius: 5px; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 800; color: #fff; }\n .wcard-head .wc-title { font-size: 12.5px; font-weight: 700; letter-spacing: 0.2px; color: #1f1f1f; }\n .wcard-head .wc-sub { font-size: 10px; color: #a0a4a8; margin-left: auto; font-family: 'JetBrains Mono', monospace; }\n .wcard-body { padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; }\n\n /* ── Capture input ── */\n .capture { display: flex; gap: 8px; }\n .capture .cinput { flex: 1; font-size: 12px; color: #6b7280; background: #f6f7f9; border: 1px solid #e3e6ea; border-radius: 8px; padding: 8px 10px; }\n .capture .cinput.typed { color: #1f1f1f; }\n .capture .cadd { font-size: 12px; font-weight: 700; color: #fff; background: var(--accent); border-radius: 8px; padding: 8px 14px; }\n\n /* ── Task rows ── */\n .task { display: flex; gap: 10px; align-items: center; padding: 8px 0; border-bottom: 1px solid #f2f2f0; }\n .task:last-child { border-bottom: none; }\n .tcheck { width: 18px; height: 18px; border-radius: 50%; border: 1.6px solid #c6ccd3; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 10px; }\n .tcheck.done { background: var(--green); border-color: var(--green); color: #fff; }\n .ttitle { flex: 1; font-size: 12.5px; color: #202020; line-height: 1.35; min-width: 0; }\n .ttitle.done { text-decoration: line-through; color: #a8adb2; }\n .pri { font-size: 9.5px; font-weight: 700; padding: 2px 8px; border-radius: 5px; flex-shrink: 0; display: inline-flex; align-items: center; gap: 3px; }\n .pri.hi { background: rgba(220,38,38,0.10); color: var(--hi); }\n .pri.med { background: rgba(217,119,6,0.10); color: var(--med); }\n .pri.low { background: rgba(107,114,128,0.12); color: var(--low); }\n .pri .caret { font-size: 8px; opacity: 0.7; }\n\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(--accent); }\n .note.ok { background: var(--green-soft); color: #146b32; border-left: 3px solid var(--green); }\n .caution { display: flex; gap: 6px; font-size: 10.5px; font-weight: 600; padding: 7px 9px; background: var(--amber-soft); color: #8a5a0a; border: 1px solid var(--amber-border); border-radius: 8px; line-height: 1.45; }\n .disclaimer { font-size: 9.5px; color: #a0a4a8; font-style: italic; line-height: 1.45; padding-top: 2px; }\n .footnote { font-size: 10px; color: #8a929c; text-align: center; padding-top: 4px; }\n\n .cta { padding: 9px 10px; background: var(--accent); border-radius: 9px; text-align: center; font-size: 12px; font-weight: 700; color: #fff; }\n .cta.ghost { background: #fff; border: 1.5px solid #d4d8dd; color: #4a525c; }\n .cta-row { display: flex; gap: 8px; }\n .cta-row .cta { flex: 1; }\n\n /* ── Connect / customerAuth ── */\n .connect { text-align: center; padding: 6px 4px; }\n .connect .ci { width: 46px; height: 46px; background: var(--accent); border-radius: 12px; margin: 6px auto 12px; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 22px; font-weight: 800; }\n .connect .ctitle { font-size: 14px; font-weight: 800; color: var(--ink); }\n .scopes { text-align: left; font-size: 10.5px; color: #4a525c; display: flex; flex-direction: column; gap: 6px; margin: 6px 0; }\n .scopes .sc { display: flex; gap: 7px; align-items: flex-start; }\n .scopes .sc .k { color: var(--green); font-weight: 800; min-width: 34px; }\n\n .dest { border: 1.5px solid #e0e3e7; border-radius: 10px; padding: 11px; }\n .dest .dh { font-size: 12px; font-weight: 800; color: var(--ink); margin-bottom: 6px; }\n .dest .dl { font-size: 11px; color: #5c6570; line-height: 1.5; }\n\n .empty { text-align: center; padding: 8px 4px; }\n .empty .ei { width: 44px; height: 44px; background: var(--green); border-radius: 12px; margin: 4px auto 10px; display: flex; align-items: center; justify-content: center; font-size: 22px; }\n .empty .et { font-size: 13px; font-weight: 800; color: var(--ink); }\n\n /* ── Gallery ── */\n .gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 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(--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 22px; margin-bottom: 18px; max-width: 1040px; }\n .api-panel h4 { font-size: 13px; font-weight: 800; color: var(--ink); margin-bottom: 10px; }\n .api-step { display: flex; gap: 10px; align-items: baseline; padding: 7px 0; border-bottom: 1px dashed #eceef1; font-size: 12.5px; }\n .api-step:last-child { border-bottom: none; }\n .api-step .verb { font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: 700; color: #fff; background: var(--accent); padding: 2px 7px; border-radius: 4px; flex-shrink: 0; }\n .api-step .verb.read { background: #64748b; }\n .api-step .tname { font-family: 'JetBrains Mono', monospace; font-weight: 700; color: #3d454e; }\n .api-note { font-size: 11px; color: #6b7280; padding: 4px 0 2px 0; line-height: 1.5; }\n\n /* ── Audit table ── */\n .audit-table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #e6e8ec; border-radius: 12px; overflow: hidden; font-size: 12.5px; }\n .audit-table th { text-align: left; background: #fafbfc; color: #5c6570; font-weight: 700; font-size: 11px; text-transform: uppercase; letter-spacing: 0.4px; padding: 11px 14px; border-bottom: 1px solid #e6e8ec; }\n .audit-table td { padding: 11px 14px; border-bottom: 1px solid #f0f1f3; vertical-align: top; color: #3d454e; line-height: 1.5; }\n .audit-table tr:last-child td { border-bottom: none; }\n .audit-table .req { font-weight: 700; color: var(--ink); width: 22%; }\n .verdict { font-weight: 800; font-size: 11px; padding: 2px 8px; border-radius: 5px; white-space: nowrap; }\n .verdict.pass { background: var(--green-soft); color: #146b32; }\n .verdict.flag { background: var(--amber-soft); color: #8a5a0a; }\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 Tasks</span> × ChatGPT — Two-Way App Wireframes</h1>\n <p>Prepared by Noodle Seed · a two-way (read + write) task manager — capture, prioritize & complete, all in chat · three core flows</p>\n <span class=\"scope\">SCOPE: two-way in chat — Capture · Prioritize · Complete · OFF-APP: one-time scoped account link (customerAuth) only</span>\n</div>\n\n<div class=\"section-nav\">\n <a href=\"#legend\">Legend</a>\n <a href=\"#connect\">First-run · Connect</a>\n <a href=\"#flow\">End-to-End Flow</a>\n <a href=\"#capture\">1 · Capture</a>\n <a href=\"#prioritize\">2 · Prioritize</a>\n <a href=\"#complete\">3 · Complete</a>\n <a href=\"#gallery\">Widget Gallery</a>\n <a href=\"#api\">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 accent\"></span> Acme accent — primary CTAs, logo & checks only</div>\n <div class=\"vocab-item\"><span class=\"vocab-sw hi\"></span> high</div>\n <div class=\"vocab-item\"><span class=\"vocab-sw med\"></span> medium</div>\n <div class=\"vocab-item\"><span class=\"vocab-sw low\"></span> low</div>\n <div class=\"vocab-item\"><span class=\"vocab-sw green\"></span> Completed / success</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 → account link only)</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>. Acme Tasks is a <strong>two-way (read + write) app</strong>, not a discovery funnel: every widget reflects the user's real list, and each action — capture, re-prioritize, complete — is <strong>recorded through a tool call</strong> and confirmed in-chat. The single dashed phone is the <strong>one-time account link</strong> (<span class=\"mono\">customerAuth</span>); there is no transactional off-app step.</p>\n <p>Widgets are built with the Noodle Seed authoring SDK — <span class=\"mono\">tool + view</span> renders <span class=\"mono\">TaskList</span>; <span class=\"mono\">tool + app visibility</span> powers the in-widget re-prioritize. Styling uses host/Noodle Seed semantic tokens via CSS cascade layers; the brand accent (<span class=\"mono\">#7C3AED</span>) is reserved for the logo, checks, primary CTA and <em>high</em> emphasis. Priority uses fixed semantic colors: high red · medium amber · low slate.</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> product-model fit <span class=\"r-tag trust\">TRUST</span> write-safety / grounding guardrail</p>\n </div>\n </div>\n\n <!-- CONNECT -->\n <div class=\"section\" id=\"connect\">\n <span class=\"section-label\">First-run</span>\n <h2 class=\"section-title\">Connect Acme Tasks — scoped, once</h2>\n <p class=\"section-subtitle\">Because the app writes to the user's tasks, the very first interaction is a scoped account connection. It names exactly what the app can read and change, is dismissed forever after, and the original request resumes automatically. <em>(This flagship seeds the list so the flows stay in focus; the connect screen is the production pattern — see <span class=\"mono\">customer-auth</span>.)</em></p>\n <div class=\"rationale\">\n <h4>Why it's built this way</h4>\n <p><span class=\"r-tag trust\">TRUST</span> An app that can complete and re-prioritize your tasks needs unambiguous, plain-language consent — the scope card names read vs. write in the user's words and links the revoke path, rather than burying it in an OAuth redirect.</p>\n <p><span class=\"r-tag acme\">ACME</span> Uses Noodle Seed's <span class=\"mono\">customerAuth</span> end-user OAuth. One connection powers all three flows; the delegated credential is held by the credential broker, never surfaced to the model, the widget, or logs.</p>\n </div>\n <div class=\"phones-row\">\n <div class=\"phone-step\">\n <div class=\"step-label\">Connect prompt</div>\n <div class=\"phone\"><div class=\"phone-notch\"></div><div class=\"phone-screen\">\n <div class=\"chatgpt-header\"><span class=\"model-name\">ChatGPT · Acme Tasks</span><span class=\"dots\">···</span></div>\n <div class=\"msg user\">What's on my plate today?</div>\n <div class=\"msg assistant\">I can pull your live Acme Tasks list — let's connect it first (takes a few seconds).</div>\n <div class=\"wcard\"><div class=\"wcard-body\">\n <div class=\"connect\">\n <div class=\"ci\">✓</div>\n <div class=\"ctitle\">Connect Acme Tasks</div>\n </div>\n <div class=\"scopes\">\n <div class=\"sc\"><span class=\"k\">Read</span><span>your tasks and their priorities</span></div>\n <div class=\"sc\"><span class=\"k\">Write</span><span>add, re-prioritize & complete tasks you ask me to</span></div>\n </div>\n <div class=\"cta\">Connect Acme Tasks</div>\n <div class=\"disclaimer\">Scoped end-user OAuth (customerAuth) · read + write · revoke anytime in Acme Tasks → Settings → Connections.</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\">Authorize (off-app)</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\">tasks.acme.example/oauth/authorize?scope=tasks.read+tasks.write</span></div>\n <div class=\"dest\">\n <div class=\"dh\">Acme Tasks authorization</div>\n <div class=\"dl\">User signs in and approves read + write. Acme redirects back with a token held by the connector, not the model. <strong>Happens once.</strong></div>\n </div>\n <div class=\"note\" style=\"margin-top:8px;\">↩ On return, the original request (\"what's on my plate today?\") resumes automatically — no re-typing.</div>\n </div></div>\n </div>\n <div class=\"step-arrow\">→</div>\n <div class=\"phone-step\">\n <div class=\"step-label\">Connected → request resumes</div>\n <div class=\"phone\"><div class=\"phone-notch\"></div><div class=\"phone-screen\">\n <div class=\"chatgpt-header\"><span class=\"model-name\">ChatGPT · Acme Tasks</span><span class=\"dots\">···</span></div>\n <div class=\"note ok\">✓ Acme Tasks connected — I'll keep it linked for next time.</div>\n <div class=\"tool-call\"><span class=\"icon read\">✓</span><span><span class=\"label\">list_today</span> <span class=\"mono-tool\">{ focus: \"today\" }</span></span></div>\n <div class=\"msg assistant\">Here's today 👇 <span style=\"color:#8a929c;\">(see Flow 2)</span></div>\n </div></div>\n </div>\n </div>\n </div>\n\n <!-- END TO END FLOW -->\n <div class=\"section\" id=\"flow\">\n <span class=\"section-label\">The whole loop</span>\n <h2 class=\"section-title\">End-to-end: read today → capture → re-prioritize → complete</h2>\n <p class=\"section-subtitle\">One connected session touching all three flows. The user reads the grounded list, captures a new task, bumps a priority, and checks work off — never leaving the conversation, every change recorded and confirmed in place.</p>\n <div class=\"phones-row\">\n\n <!-- Step 1 · read -->\n <div class=\"phone-step\">\n <div class=\"step-label\">1 · Read today<small>grounded list</small></div>\n <div class=\"phone\"><div class=\"phone-notch\"></div><div class=\"phone-screen\">\n <div class=\"chatgpt-header\"><span class=\"model-name\">ChatGPT · Acme Tasks</span><span class=\"dots\">···</span></div>\n <div class=\"msg user\">What's on my plate today?</div>\n <div class=\"tool-call\"><span class=\"icon read\">✓</span><span><span class=\"label\">list_today</span></span></div>\n <div class=\"wcard\"><div class=\"wcard-head\"><span class=\"wc-logo\">✓</span><span class=\"wc-title\">Today's tasks · 3 open</span><span class=\"wc-sub\">TaskList</span></div><div class=\"wcard-body\">\n <div class=\"capture\"><span class=\"cinput\">Add a task…</span><span class=\"cadd\">Add</span></div>\n <div class=\"task\"><div class=\"tcheck\"></div><div class=\"ttitle\">Email the vendor about the Q3 quote</div><span class=\"pri hi\">high <span class=\"caret\">▾</span></span></div>\n <div class=\"task\"><div class=\"tcheck\"></div><div class=\"ttitle\">Review the analytics pull request</div><span class=\"pri med\">medium <span class=\"caret\">▾</span></span></div>\n <div class=\"task\"><div class=\"tcheck\"></div><div class=\"ttitle\">Book flights for the team offsite</div><span class=\"pri low\">low <span class=\"caret\">▾</span></span></div>\n <div class=\"footnote\">Two-way in chat: read, capture, re-prioritize, complete.</div>\n </div></div>\n </div></div>\n </div>\n\n <div class=\"step-arrow\">→</div>\n\n <!-- Step 2 · capture -->\n <div class=\"phone-step\">\n <div class=\"step-label\">2 · Capture<small>natural language</small></div>\n <div class=\"phone\"><div class=\"phone-notch\"></div><div class=\"phone-screen\">\n <div class=\"chatgpt-header\"><span class=\"model-name\">ChatGPT · Acme Tasks</span><span class=\"dots\">···</span></div>\n <div class=\"msg user\">Add \"draft the board update\", high priority.</div>\n <div class=\"tool-call\"><span class=\"icon\">✓</span><span><span class=\"label\">add_task</span> <span class=\"mono-tool\">{ title:\"Draft the board update\", priority:\"high\" }</span></span></div>\n <div class=\"wcard\"><div class=\"wcard-head\"><span class=\"wc-logo\">✓</span><span class=\"wc-title\">Today's tasks · 4 open</span><span class=\"wc-sub\">TaskList</span></div><div class=\"wcard-body\">\n <div class=\"task\"><div class=\"tcheck\"></div><div class=\"ttitle\">Draft the board update</div><span class=\"pri hi\">high <span class=\"caret\">▾</span></span></div>\n <div class=\"task\"><div class=\"tcheck\"></div><div class=\"ttitle\">Email the vendor about the Q3 quote</div><span class=\"pri hi\">high <span class=\"caret\">▾</span></span></div>\n <div class=\"task\"><div class=\"tcheck\"></div><div class=\"ttitle\">Review the analytics pull request</div><span class=\"pri med\">medium <span class=\"caret\">▾</span></span></div>\n <div class=\"note ok\">✓ Added \"Draft the board update\" (high).</div>\n </div></div>\n </div></div>\n </div>\n\n <div class=\"step-arrow\">→</div>\n\n <!-- Step 3 · prioritize -->\n <div class=\"phone-step\">\n <div class=\"step-label\">3 · Prioritize<small>in-widget re-order</small></div>\n <div class=\"phone\"><div class=\"phone-notch\"></div><div class=\"phone-screen\">\n <div class=\"chatgpt-header\"><span class=\"model-name\">ChatGPT · Acme Tasks</span><span class=\"dots\">···</span></div>\n <div class=\"msg user\">Bump the PR review to high.</div>\n <div class=\"tool-call\"><span class=\"icon\">✓</span><span><span class=\"label\">set_priority</span> <span class=\"mono-tool\">{ task:\"review_pr\", priority:\"high\" }</span></span></div>\n <div class=\"wcard\"><div class=\"wcard-head\"><span class=\"wc-logo\">✓</span><span class=\"wc-title\">Today's tasks · 4 open</span><span class=\"wc-sub\">TaskList</span></div><div class=\"wcard-body\">\n <div class=\"task\"><div class=\"tcheck\"></div><div class=\"ttitle\">Draft the board update</div><span class=\"pri hi\">high <span class=\"caret\">▾</span></span></div>\n <div class=\"task\"><div class=\"tcheck\"></div><div class=\"ttitle\">Email the vendor about the Q3 quote</div><span class=\"pri hi\">high <span class=\"caret\">▾</span></span></div>\n <div class=\"task\"><div class=\"tcheck\"></div><div class=\"ttitle\">Review the analytics pull request</div><span class=\"pri hi\">high <span class=\"caret\">▾</span></span></div>\n <div class=\"note ok\">✓ Set review_pr to high priority.</div>\n </div></div>\n </div></div>\n </div>\n\n <div class=\"step-arrow\">→</div>\n\n <!-- Step 4 · complete -->\n <div class=\"phone-step\">\n <div class=\"step-label\">4 · Complete<small>checked & confirmed</small></div>\n <div class=\"phone\"><div class=\"phone-notch\"></div><div class=\"phone-screen\">\n <div class=\"chatgpt-header\"><span class=\"model-name\">ChatGPT · Acme Tasks</span><span class=\"dots\">···</span></div>\n <div class=\"msg user\">I finished the vendor email — mark it done.</div>\n <div class=\"tool-call\"><span class=\"icon\">✓</span><span><span class=\"label\">complete_task</span> <span class=\"mono-tool\">{ task:\"email_vendor\", title:\"Email the vendor…\" }</span></span></div>\n <div class=\"wcard\"><div class=\"wcard-head\"><span class=\"wc-logo\">✓</span><span class=\"wc-title\">Today's tasks · 3 open</span><span class=\"wc-sub\">TaskList</span></div><div class=\"wcard-body\">\n <div class=\"task\"><div class=\"tcheck done\">✓</div><div class=\"ttitle done\">Email the vendor about the Q3 quote</div><span class=\"pri hi\">high</span></div>\n <div class=\"task\"><div class=\"tcheck\"></div><div class=\"ttitle\">Draft the board update</div><span class=\"pri hi\">high <span class=\"caret\">▾</span></span></div>\n <div class=\"task\"><div class=\"tcheck\"></div><div class=\"ttitle\">Review the analytics pull request</div><span class=\"pri hi\">high <span class=\"caret\">▾</span></span></div>\n <div class=\"note ok\">✓ Completed \"Email the vendor about the Q3 quote\". 3 left today.</div>\n </div></div>\n </div></div>\n </div>\n\n </div>\n </div>\n\n <!-- FLOW 1 : CAPTURE -->\n <div class=\"section\" id=\"capture\">\n <span class=\"section-label\">Flow 1 · lowest friction</span>\n <h2 class=\"section-title\">Capture — a spoken thought becomes a task, instantly</h2>\n <p class=\"section-subtitle\">The moment a task is mentioned it lands on the list. The model parses the prose into a title and priority, the row appears optimistically, and <span class=\"mono\">add_task</span> records it — no form, no app-switch.</p>\n <div class=\"rationale\">\n <h4>Why it's built this way</h4>\n <p><span class=\"r-tag ux\">UX</span> Capture must be the fastest path in the app — the thought is fragile. \"Add X, high\" is one turn: the model fills <span class=\"mono\">{ title, priority }</span> from natural language, so the user never fills a field by hand.</p>\n <p><span class=\"r-tag ui\">UI</span> The new row renders immediately (optimistic), then the tool call confirms with a spoken-ready status. The capture input lives at the top of <span class=\"mono\">TaskList</span> so keyboard capture and voice capture converge on the same widget.</p>\n <p><span class=\"r-tag trust\">TRUST</span> Priority is the user's: the model may <em>suggest</em> (\"sounds high?\") but sets what the user says, and defaults to <span class=\"mono\">medium</span> when unstated — it never silently ranks work.</p>\n </div>\n <div class=\"phones-row\">\n <div class=\"phone-step\">\n <div class=\"step-label\">Say it</div>\n <div class=\"phone\"><div class=\"phone-notch\"></div><div class=\"phone-screen\">\n <div class=\"chatgpt-header\"><span class=\"model-name\">ChatGPT · Acme Tasks</span><span class=\"dots\">···</span></div>\n <div class=\"msg user\">Remind me to book flights for the team offsite — low priority.</div>\n <div class=\"tool-call\"><span class=\"icon\">✓</span><span><span class=\"label\">add_task</span> <span class=\"mono-tool\">{ title:\"Book flights for the team offsite\", priority:\"low\" }</span></span></div>\n <div class=\"msg assistant\">Added it to today's list at low priority.</div>\n </div></div>\n </div>\n <div class=\"step-arrow\">→</div>\n <div class=\"phone-step\">\n <div class=\"step-label\">Row appears (optimistic + recorded)</div>\n <div class=\"phone\"><div class=\"phone-notch\"></div><div class=\"phone-screen\">\n <div class=\"chatgpt-header\"><span class=\"model-name\">ChatGPT · Acme Tasks</span><span class=\"dots\">···</span></div>\n <div class=\"wcard\"><div class=\"wcard-head\"><span class=\"wc-logo\">✓</span><span class=\"wc-title\">Today's tasks · 3 open</span><span class=\"wc-sub\">TaskList</span></div><div class=\"wcard-body\">\n <div class=\"capture\"><span class=\"cinput typed\">Book flights for the team offsite</span><span class=\"cadd\">Add</span></div>\n <div class=\"task\"><div class=\"tcheck\"></div><div class=\"ttitle\">Email the vendor about the Q3 quote</div><span class=\"pri hi\">high <span class=\"caret\">▾</span></span></div>\n <div class=\"task\"><div class=\"tcheck\"></div><div class=\"ttitle\">Review the analytics pull request</div><span class=\"pri med\">medium <span class=\"caret\">▾</span></span></div>\n <div class=\"task\"><div class=\"tcheck\"></div><div class=\"ttitle\">Book flights for the team offsite</div><span class=\"pri low\">low <span class=\"caret\">▾</span></span></div>\n <div class=\"note ok\">✓ Added \"Book flights for the team offsite\" (low).</div>\n </div></div>\n </div></div>\n </div>\n </div>\n </div>\n\n <!-- FLOW 2 : PRIORITIZE -->\n <div class=\"section\" id=\"prioritize\">\n <span class=\"section-label\">Flow 2 · the hub</span>\n <h2 class=\"section-title\">Prioritize — read the day, then re-order it in place</h2>\n <p class=\"section-subtitle\"><span class=\"mono\">list_today</span> renders the grounded list, highest priority first; the per-row priority control (a <span class=\"mono\">tool + app visibility</span> helper hidden from the model) lets the user re-rank without describing the task twice. This is the widget the other two flows write into.</p>\n <div class=\"rationale\">\n <h4>Why it's built this way</h4>\n <p><span class=\"r-tag ux\">UX</span> \"What's on my plate?\" wants an answer <em>and</em> a next move. The list is grouped high → low so the triager sees the real order at a glance and adjusts with one tap.</p>\n <p><span class=\"r-tag acme\">ACME</span> Re-prioritize is <span class=\"mono\">set_priority</span> as <span class=\"mono\">tool + app visibility</span> — <strong>widget-only</strong>, hidden from the model's tool surface. The model reasons about three tools (list, add, complete); the fourth is pure UI plumbing, keeping the model's choices clean.</p>\n <p><span class=\"r-tag trust\">TRUST</span> The list only ever shows tasks that exist — the app never invents a task or a priority. Every re-rank echoes a confirmation (\"Set review_pr to high priority\").</p>\n </div>\n <div class=\"phones-row\">\n <div class=\"phone-step\">\n <div class=\"step-label\">Grounded read</div>\n <div class=\"phone\"><div class=\"phone-notch\"></div><div class=\"phone-screen\">\n <div class=\"chatgpt-header\"><span class=\"model-name\">ChatGPT · Acme Tasks</span><span class=\"dots\">···</span></div>\n <div class=\"msg user\">What's due today?</div>\n <div class=\"tool-call\"><span class=\"icon read\">✓</span><span><span class=\"label\">list_today</span> <span class=\"mono-tool\">{ focus:\"today\" }</span></span></div>\n <div class=\"wcard\"><div class=\"wcard-head\"><span class=\"wc-logo\">✓</span><span class=\"wc-title\">Today's tasks · 3 open</span><span class=\"wc-sub\">TaskList</span></div><div class=\"wcard-body\">\n <div class=\"task\"><div class=\"tcheck\"></div><div class=\"ttitle\">Email the vendor about the Q3 quote</div><span class=\"pri hi\">high <span class=\"caret\">▾</span></span></div>\n <div class=\"task\"><div class=\"tcheck\"></div><div class=\"ttitle\">Review the analytics pull request</div><span class=\"pri med\">medium <span class=\"caret\">▾</span></span></div>\n <div class=\"task\"><div class=\"tcheck\"></div><div class=\"ttitle\">Book flights for the team offsite</div><span class=\"pri low\">low <span class=\"caret\">▾</span></span></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\">Re-prioritize (widget-only write)</div>\n <div class=\"phone\"><div class=\"phone-notch\"></div><div class=\"phone-screen\">\n <div class=\"chatgpt-header\"><span class=\"model-name\">ChatGPT · Acme Tasks</span><span class=\"dots\">···</span></div>\n <div class=\"note why\"><strong>Tapping the priority ▾</strong> on \"Review the analytics pull request\" → <span class=\"mono\">high</span>. The list re-orders in place.</div>\n <div class=\"tool-call\"><span class=\"icon\">✓</span><span><span class=\"label\">set_priority</span> <span class=\"mono-tool\">{ task:\"review_pr\", priority:\"high\" }</span></span></div>\n <div class=\"wcard\"><div class=\"wcard-head\"><span class=\"wc-logo\">✓</span><span class=\"wc-title\">Today's tasks · 3 open</span><span class=\"wc-sub\">TaskList</span></div><div class=\"wcard-body\">\n <div class=\"task\"><div class=\"tcheck\"></div><div class=\"ttitle\">Email the vendor about the Q3 quote</div><span class=\"pri hi\">high <span class=\"caret\">▾</span></span></div>\n <div class=\"task\"><div class=\"tcheck\"></div><div class=\"ttitle\">Review the analytics pull request</div><span class=\"pri hi\">high <span class=\"caret\">▾</span></span></div>\n <div class=\"task\"><div class=\"tcheck\"></div><div class=\"ttitle\">Book flights for the team offsite</div><span class=\"pri low\">low <span class=\"caret\">▾</span></span></div>\n <div class=\"note ok\">✓ Set review_pr to high priority.</div>\n </div></div>\n </div></div>\n </div>\n </div>\n </div>\n\n <!-- FLOW 3 : COMPLETE -->\n <div class=\"section\" id=\"complete\">\n <span class=\"section-label\">Flow 3 · the payoff</span>\n <h2 class=\"section-title\">Complete — check it off by voice or tap, confirmed every time</h2>\n <p class=\"section-subtitle\">The satisfaction loop. The user finishes work and closes it out — either by tapping the row's check or by saying \"mark X done\" (<span class=\"mono\">complete_task</span> is model-visible). The row strikes through, the open count drops, and the model narrates what changed.</p>\n <div class=\"rationale\">\n <h4>Why it's built this way</h4>\n <p><span class=\"r-tag ux\">UX</span> Completion is the reward that drives return visits, so it is friction-free two ways: the check in <span class=\"mono\">TaskList</span> and the spoken \"mark X done\". Ending on an all-clear empty state nudges the next capture instead of a dead end.</p>\n <p><span class=\"r-tag trust\">TRUST</span> Completion is never silent: <span class=\"mono\">complete_task</span> returns \"Completed '…'\" plus the remaining count, and the struck-through row is the visible receipt. The model confirms in the same turn.</p>\n <p><span class=\"r-tag acme\">ACME</span> <span class=\"mono\">complete_task</span> is model-visible (unlike re-prioritize) precisely because \"I finished X\" is a natural sentence the user will say — the model must be able to act on it directly.</p>\n </div>\n <div class=\"phones-row\">\n <div class=\"phone-step\">\n <div class=\"step-label\">Complete by voice (write + confirm)</div>\n <div class=\"phone\"><div class=\"phone-notch\"></div><div class=\"phone-screen\">\n <div class=\"chatgpt-header\"><span class=\"model-name\">ChatGPT · Acme Tasks</span><span class=\"dots\">···</span></div>\n <div class=\"msg user\">Done with the vendor email and the PR review.</div>\n <div class=\"tool-call\"><span class=\"icon\">✓</span><span><span class=\"label\">complete_task ×2</span> <span class=\"mono-tool\">email_vendor · review_pr</span></span></div>\n <div class=\"wcard\"><div class=\"wcard-head\"><span class=\"wc-logo\">✓</span><span class=\"wc-title\">Today's tasks · 1 open</span><span class=\"wc-sub\">TaskList</span></div><div class=\"wcard-body\">\n <div class=\"task\"><div class=\"tcheck done\">✓</div><div class=\"ttitle done\">Email the vendor about the Q3 quote</div><span class=\"pri hi\">high</span></div>\n <div class=\"task\"><div class=\"tcheck done\">✓</div><div class=\"ttitle done\">Review the analytics pull request</div><span class=\"pri med\">medium</span></div>\n <div class=\"task\"><div class=\"tcheck\"></div><div class=\"ttitle\">Book flights for the team offsite</div><span class=\"pri low\">low <span class=\"caret\">▾</span></span></div>\n <div class=\"note ok\">✓ Completed 2 tasks. 1 left today.</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\">All clear (close the loop)</div>\n <div class=\"phone\"><div class=\"phone-notch\"></div><div class=\"phone-screen\">\n <div class=\"chatgpt-header\"><span class=\"model-name\">ChatGPT · Acme Tasks</span><span class=\"dots\">···</span></div>\n <div class=\"msg user\">Booked the flights too — check it off.</div>\n <div class=\"tool-call\"><span class=\"icon\">✓</span><span><span class=\"label\">complete_task · list_today</span></span></div>\n <div class=\"wcard\"><div class=\"wcard-head\"><span class=\"wc-logo\">✓</span><span class=\"wc-title\">Today's tasks · all clear</span><span class=\"wc-sub\">TaskList · empty</span></div><div class=\"wcard-body\">\n <div class=\"empty\"><div class=\"ei\">🎉</div><div class=\"et\">All clear for today</div></div>\n <div class=\"note ok\">Everything on today's list is done — 3/3 completed this session.</div>\n <div class=\"cta-row\"><div class=\"cta\">Capture tomorrow's first task</div></div>\n <div class=\"disclaimer\">Nudges to the next capture instead of leaving the user at a dead end.</div>\n </div></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\">One widget, <span class=\"mono\">TaskList</span>, across its states. Compliant: system fonts, neutral surfaces, accent reserved for logo/checks/primary CTA/high emphasis; priority in fixed semantic colors (high red · medium amber · low slate).</p>\n <div class=\"gallery\">\n\n <div class=\"spec-frame\">\n <div class=\"sf-name\">TaskList ★ <span>· grounded read (default)</span></div>\n <div class=\"wcard\"><div class=\"wcard-head\"><span class=\"wc-logo\">✓</span><span class=\"wc-title\">Today's tasks · 3 open</span><span class=\"wc-sub\">TaskList</span></div><div class=\"wcard-body\">\n <div class=\"capture\"><span class=\"cinput\">Add a task…</span><span class=\"cadd\">Add</span></div>\n <div class=\"task\"><div class=\"tcheck\"></div><div class=\"ttitle\">Email the vendor about the Q3 quote</div><span class=\"pri hi\">high <span class=\"caret\">▾</span></span></div>\n <div class=\"task\"><div class=\"tcheck\"></div><div class=\"ttitle\">Review the analytics pull request</div><span class=\"pri med\">medium <span class=\"caret\">▾</span></span></div>\n <div class=\"task\"><div class=\"tcheck\"></div><div class=\"ttitle\">Book flights for the team offsite</div><span class=\"pri low\">low <span class=\"caret\">▾</span></span></div>\n </div></div>\n </div>\n\n <div class=\"spec-frame\">\n <div class=\"sf-name\">TaskList <span>· captured (optimistic + recorded)</span></div>\n <div class=\"wcard\"><div class=\"wcard-head\"><span class=\"wc-logo\">✓</span><span class=\"wc-title\">Today's tasks · 4 open</span><span class=\"wc-sub\">TaskList</span></div><div class=\"wcard-body\">\n <div class=\"task\"><div class=\"tcheck\"></div><div class=\"ttitle\">Draft the board update</div><span class=\"pri hi\">high <span class=\"caret\">▾</span></span></div>\n <div class=\"task\"><div class=\"tcheck\"></div><div class=\"ttitle\">Email the vendor about the Q3 quote</div><span class=\"pri hi\">high <span class=\"caret\">▾</span></span></div>\n <div class=\"note ok\">✓ Added \"Draft the board update\" (high).</div>\n </div></div>\n </div>\n\n <div class=\"spec-frame\">\n <div class=\"sf-name\">TaskList <span>· completed (write + confirm)</span></div>\n <div class=\"wcard\"><div class=\"wcard-head\"><span class=\"wc-logo\">✓</span><span class=\"wc-title\">Today's tasks · 2 open</span><span class=\"wc-sub\">TaskList</span></div><div class=\"wcard-body\">\n <div class=\"task\"><div class=\"tcheck done\">✓</div><div class=\"ttitle done\">Email the vendor about the Q3 quote</div><span class=\"pri hi\">high</span></div>\n <div class=\"task\"><div class=\"tcheck\"></div><div class=\"ttitle\">Review the analytics pull request</div><span class=\"pri med\">medium <span class=\"caret\">▾</span></span></div>\n <div class=\"note ok\">✓ Completed \"Email the vendor…\". 2 left.</div>\n </div></div>\n </div>\n\n <div class=\"spec-frame\">\n <div class=\"sf-name\">TaskList <span>· all-clear empty state</span></div>\n <div class=\"wcard\"><div class=\"wcard-head\"><span class=\"wc-logo\">✓</span><span class=\"wc-title\">Today's tasks · all clear</span><span class=\"wc-sub\">TaskList · empty</span></div><div class=\"wcard-body\">\n <div class=\"empty\"><div class=\"ei\">🎉</div><div class=\"et\">All clear for today</div></div>\n <div class=\"cta\">Capture tomorrow's first task</div>\n </div></div>\n </div>\n\n <div class=\"spec-frame\">\n <div class=\"sf-name\">Connect / customerAuth <span>· scoped consent</span></div>\n <div class=\"wcard\"><div class=\"wcard-body\">\n <div class=\"connect\"><div class=\"ci\">✓</div><div class=\"ctitle\">Connect Acme Tasks</div></div>\n <div class=\"scopes\"><div class=\"sc\"><span class=\"k\">R/W</span><span>read tasks & priorities · add, re-prioritize, complete</span></div></div>\n <div class=\"cta\">Connect Acme Tasks</div>\n </div></div>\n </div>\n\n </div>\n </div>\n\n <!-- MCP TOOLS APPENDIX -->\n <div class=\"section\" id=\"api\">\n <span class=\"section-label\">Technical appendix</span>\n <h2 class=\"section-title\">MCP tools & call sequence</h2>\n <p class=\"section-subtitle\">The four tools behind the flows. <span class=\"mono\">list_today</span> is the only widget-opening tool (<span class=\"mono\">tool + view</span>); <span class=\"mono\">set_priority</span> is widget-only (<span class=\"mono\">tool + app visibility</span>, hidden from the model); <span class=\"mono\">add_task</span> and <span class=\"mono\">complete_task</span> are model-visible.</p>\n\n <div class=\"api-panel\">\n <h4>Read & render (Flow 2 · the hub)</h4>\n <div class=\"api-step\"><span class=\"verb read\">READ</span><span><span class=\"tname\">list_today</span> — returns { status, focus, tasks:[{ id, title, priority, done }] } and renders TaskList</span></div>\n <div class=\"api-note\">Input <span class=\"mono\">{ focus:\"today\" }</span>. Read-only annotation; host status copy: invoking \"Loading your tasks…\", invoked \"Tasks ready\". This is the single widget-opening tool — the other flows write into the card it renders.</div>\n </div>\n\n <div class=\"api-panel\">\n <h4>Capture (Flow 1)</h4>\n <div class=\"api-step\"><span class=\"verb\">WRITE</span><span><span class=\"tname\">add_task</span> — returns { status, title, priority }</span></div>\n <div class=\"api-note\">Input <span class=\"mono\">{ title, priority: \"high\"|\"medium\"|\"low\" = \"medium\" }</span>. Model-fillable from prose (\"book flights, low\" → { title, priority:\"low\" }); local non-destructive write. Status is spoken-ready so the model confirms in one turn.</div>\n </div>\n\n <div class=\"api-panel\">\n <h4>Prioritize (Flow 2 helper) & Complete (Flow 3)</h4>\n <div class=\"api-step\"><span class=\"verb\">WRITE</span><span><span class=\"tname\">set_priority</span> — returns { status, task, priority } · tool + app visibility, hidden from the model</span></div>\n <div class=\"api-note\">Input <span class=\"mono\">{ task:id, priority }</span>. Called only by the priority ▾ in TaskList — keeps the model's tool surface to the three it should reason about.</div>\n <div class=\"api-step\"><span class=\"verb\">WRITE</span><span><span class=\"tname\">complete_task</span> — returns { status, task } · model-visible</span></div>\n <div class=\"api-note\">Input <span class=\"mono\">{ task:id, title:\"\" }</span>. Model-visible so \"I finished X\" completes directly; local non-destructive write, confirmed with the remaining count.</div>\n </div>\n </div>\n\n <!-- COMPLIANCE AUDIT -->\n <div class=\"section\" id=\"audit\">\n <span class=\"section-label\">Submission-ready</span>\n <h2 class=\"section-title\">OpenAI Apps SDK Compliance Audit</h2>\n <p class=\"section-subtitle\">Every row cites concrete app behavior. Built with the Noodle Seed authoring SDK and verified with <span class=\"mono\">noodle check --target chatgpt</span>.</p>\n\n <table class=\"audit-table\">\n <thead><tr><th class=\"req\">Requirement</th><th>How Acme Tasks addresses it</th><th>Verdict</th></tr></thead>\n <tbody>\n <tr><td class=\"req\">Conversational value</td><td>Capture parses prose into structured fields (\"book flights for the offsite, low\" → { title, priority:\"low\" }); complete-by-voice (\"I finished the vendor email\") acts directly — actions no tap-only app affords.</td><td><span class=\"verdict pass\">PASS</span></td></tr>\n <tr><td class=\"req\">Beyond base ChatGPT</td><td>Grounded read of the user's real list (never guessed) plus committed writes to the connected account — state the base model cannot hold.</td><td><span class=\"verdict pass\">PASS</span></td></tr>\n <tr><td class=\"req\">Atomic, model-friendly tools</td><td>Four tools, explicit Zod input/output schemas, each returning a spoken-ready status. <span class=\"mono\">list_today</span> / <span class=\"mono\">add_task</span> / <span class=\"mono\">complete_task</span> model-visible; <span class=\"mono\">set_priority</span> widget-only.</td><td><span class=\"verdict pass\">PASS</span></td></tr>\n <tr><td class=\"req\">Helpful UI only</td><td>One widget, <span class=\"mono\">TaskList</span> — visual scanning of a prioritized list plus in-place capture/complete/re-prioritize. No carousel (one list) and no PiP (nothing backgrounded); omissions are deliberate.</td><td><span class=\"verdict pass\">PASS</span></td></tr>\n <tr><td class=\"req\">In-chat task completion</td><td>The whole loop — read, capture, re-prioritize, complete — finishes in chat; there is no handoff. The only off-app step is the one-time account link.</td><td><span class=\"verdict pass\">PASS</span></td></tr>\n <tr><td class=\"req\">Performance & responsiveness</td><td>Optimistic render on capture/complete, then the tool call records; one tool call per user step. Host status copy shown while <span class=\"mono\">list_today</span> loads.</td><td><span class=\"verdict pass\">PASS</span></td></tr>\n <tr><td class=\"req\">Discoverability</td><td>Broad natural triggers: \"remind me to…\", \"what's on my list?\", \"make X high priority\", \"mark X done\". Golden-prompt set & description keywords are a launch workstream.</td><td><span class=\"verdict flag\">PLAN</span></td></tr>\n <tr><td class=\"req\">Design tokens & theming</td><td>Host/Noodle Seed semantic tokens via CSS cascade layers; brand accent (<span class=\"mono\">#7C3AED</span>) restricted to logo, checks, primary CTA & high emphasis. Adapts to host light/dark.</td><td><span class=\"verdict pass\">PASS</span></td></tr>\n <tr><td class=\"req\">System fonts, icons, WCAG AA</td><td>System font stack; single outlined monochrome check icon; contrast on priority chips and CTAs meets AA; no nested scroll (inline card → fullscreen for long lists).</td><td><span class=\"verdict pass\">PASS</span></td></tr>\n <tr><td class=\"req\">≤2 primary actions on inline card</td><td>Two primaries — capture <strong>Add</strong> and the per-row <strong>complete</strong> check; re-prioritize is a lightweight inline control, not a CTA.</td><td><span class=\"verdict pass\">PASS</span></td></tr>\n <tr><td class=\"req\">Write safety (domain guardrail)</td><td>No silent mutation: capture, re-prioritize & complete each return a confirmation and update the widget. Completion always states the remaining count. Priority is the user's — the model suggests, never silently re-ranks.</td><td><span class=\"verdict pass\">PASS</span></td></tr>\n <tr><td class=\"req\">Grounding (domain guardrail)</td><td>The list only shows tasks that exist in state; the app never invents a task. A production deployment reads from the connected account, not the model's memory.</td><td><span class=\"verdict pass\">PASS</span></td></tr>\n <tr><td class=\"req\">Scoped account auth & secrets</td><td>Scoped <span class=\"mono\">customerAuth</span> (read + write), connected once, revocable. Delegated credential held by the credential broker — never in tool payloads, the widget, or logs.</td><td><span class=\"verdict pass\">PASS</span></td></tr>\n </tbody>\n </table>\n </div>\n\n</div>\n\n<div class=\"footer\">\n Acme Tasks × ChatGPT — Two-Way App Wireframes · Noodle Seed · v1 · July 2026<br>\n Illustrative wireframes for a fictional app. Built with the Noodle Seed authoring SDK (tool + view / tool + app visibility · customerAuth). This flagship seeds the task list; a production deployment connects the user's account. Task data shown is sample content.\n</div>\n\n</body>\n</html>\n" },
|
|
48
49
|
{ relPath: "examples/acme-tasks/noodle.json", content: "{\n \"entrypoint\": \"src/server.ts\",\n \"name\": \"acme-tasks\",\n \"template\": \"widget\"\n}\n" },
|
|
@@ -54,7 +55,7 @@ export const BUNDLED_EXAMPLE_FILES = [
|
|
|
54
55
|
{ 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" },
|
|
55
56
|
{ 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" },
|
|
56
57
|
{ 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" },
|
|
57
|
-
{ 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. The built-in card hides\nits optional technical Additional details disclosure while retaining the business review and confirmation\ncontrols; this presentation setting does not weaken the exact runtime confirmation boundary.\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 Responses-compatible endpoint, selected explicitly with\n`transport: 'responses'` in `src/server.ts`. Use `transport: 'chat-completions'` or omit the field for a\nChat Completions endpoint. Noodle never falls back between them. Configure its managed values at the Noodle\ndeployment environment; none of these values belongs in the customer web application environment, and the\nAPI key never reaches the browser:\n\nThe assistant session carries a verified user, tenant, deployment, roles, and scopes. For this flagship's\nrouted tools, the embedding backend resolves the signed-in user's cluster from server-owned membership data\nand passes `routing: { endpoints: { customer_api: cluster.apiBaseUrl } }` to\n`createAssistantSession`. Noodle validates and privately stores that route; it is not returned to the\nbrowser. Do not copy the route into page context, session claims, tool input, or model instructions.\n\nThe authenticated assistant surface also declares `accountTier` as a model-visible session claim. Pass it\nfrom the same backend-owned account record as `claims: { accountTier: account.tier }`; undeclared claims are\ndropped. This is personalization context, not authorization: the verified roles/scopes beside each tool and\nthe server-owned customer route remain the enforcement boundaries. The public\n[runtime guide](https://docs.noodleseed.dev/docs/guides/product-agent-guides#use-the-guide-at-runtime)\nexplains how verified session claims constrain the guide content available to the model.\n\n```bash\nnoodle variables set ASSISTANT_ORIGIN https://app.example.com --scope env\nnoodle variables set ASSISTANT_MODEL_BASE_URL https://model.example.com/v1 --scope env\nnoodle variables set ASSISTANT_MODEL your-model --scope env\nnoodle secrets set ASSISTANT_MODEL_API_KEY --scope env\nnoodle variables set CUSTOMER_API_CLIENT_ID your-broker-client-id --scope env\nnoodle secrets set CUSTOMER_API_CLIENT_SECRET --scope env\nnoodle check --target embedded-assistant src/server.ts\n```\n\n`ASSISTANT_ORIGIN` is the operator-owned production embedding origin, so one source can serve every customer\nwithout an application fork. Assistant origins are exact. Production embedding origins must use HTTPS; plain HTTP is accepted only for\nloopback development origins such as `http://localhost:3000`, `http://127.0.0.1:3000`, or\n`http://[::1]:3000`. `noodle dev` serves the MCP project, not that separate embedding application.\n\nThe bounded `presentation` object configures the panel, launcher, header, composer, and messages. Its\nprimitives derive colors from shared server `branding`; raw HTML, CSS, inline SVG, renderer classes, and\ncallbacks are not accepted. This example omits `presentation.panel.surface`, so the renderer keeps the\nopaque default panel treatment while the example's light/dark `branding` surfaces provide its customer colors;\nset the bounded surface to `glass` only when translucency is intentional.\n\nThese TypeScript values remain the reusable developer defaults. After deployment, an environment operator\ncan adjust theme, logo, launcher style, position, and the bounded color palette from the Console's\n**Assistant** tab or `noodle assistant appearance` without changing the customer's embed code. See the\n[embedded assistant guide](https://docs.noodleseed.dev/docs/guides/embedded-assistant) for precedence and reset\nbehavior.\n\nCreate the backend credential after deployment. The CLI writes it to a mode-0600 file and never prints the\nsecret:\n\n```bash\nnoodle assistant clients create --name web --org noodleseed --app customer-auth --env prod\n```\n\nOnly the Noodle service URL, assistant client ID, and assistant client secret belong in the authenticated\ncustomer backend. The model URL, model name, and model API key remain managed by the Noodle deployment.\n\nThe customer's authenticated backend calls `createAssistantSession(...)` from\n`@noodleseed/assistant/server`, passing the already-verified user and browser origin. The browser then uses\nthe returned short-lived session through the managed Web Component/React renderer or a customer-owned UI:\n\n```bash\npnpm add @noodleseed/assistant\n```\n\n```tsx\nimport { NoodleAssistant } from '@noodleseed/assistant/react';\n\n<NoodleAssistant\n sessionEndpoint=\"/api/noodle-assistant/session\"\n theme={resolvedTheme}\n onSessionExpired={() => console.info('Assistant session renewed')}\n/>;\n```\n\n`resolvedTheme` is the application's current `'light' | 'dark'` value. Use `theme=\"auto\"` only when the\nbrowser operating-system preference is intentionally authoritative.\n\n### Minimal fail-closed custom renderer skeleton\n\nUse the renderer-free hook only when the product has a concrete reason to own the conversation UI. This\nminimal skeleton keeps the canonical client and App host, but intentionally refuses confirmation and input\nacceptance until the application implements their complete schema-aware presentation. Start with the managed\nrenderer unless the application accepts every obligation below.\n\n```tsx\n'use client';\n\nimport { useEffect, useState } from 'react';\nimport { NoodleAppView } from '@noodleseed/assistant/react';\nimport { useNoodleAssistant } from '@noodleseed/assistant/react/client';\n\nexport function CustomerAssistant({\n principalKey,\n resolvedTheme,\n onSignInRequested,\n}: {\n principalKey: string;\n resolvedTheme: 'light' | 'dark';\n onSignInRequested: (request: {\n signInTicket: string;\n expiresAt: string;\n }) => Promise<'started' | 'cancelled'>;\n}) {\n const [draft, setDraft] = useState('');\n const [sessionNotice, setSessionNotice] = useState('');\n const [turnNotice, setTurnNotice] = useState('');\n const [pendingSignInTicket, setPendingSignInTicket] = useState<string>();\n const { client, messages, suggestions, status, error } = useNoodleAssistant({\n sessionEndpoint: '/api/noodle-assistant/session',\n principalKey,\n });\n const busy = status === 'submitted' || status === 'streaming';\n const settle = (operation: Promise<void>) => {\n void operation.catch(() => {\n // The hook exposes this same structured failure through `error`.\n });\n };\n useEffect(\n () =>\n client.subscribe((event) => {\n if (event.event === 'session_expired') setSessionNotice('Session expired.');\n if (event.event === 'session_started' || event.event === 'session_reset') {\n setSessionNotice('');\n }\n }),\n [client],\n );\n\n return (\n <section aria-label=\"Assistant\" aria-busy={busy}>\n {messages.map((message) => (\n <article key={message.id} data-role={message.role}>\n {message.parts.map((part, index) => {\n if (part.type === 'text') return <p key={index}>{part.text}</p>;\n if (part.type === 'data-confirmation') {\n const review = part.data;\n return (\n <section key={review.id} aria-label=\"Review proposed action\">\n <h3>{review.title ?? 'Review proposed action'}</h3>\n {review.description ? <p>{review.description}</p> : null}\n <p>This custom renderer has not implemented a complete schema-aware review.</p>\n <button\n disabled={busy || review.status !== 'pending'}\n onClick={() => settle(client.respond(review.id, { action: 'decline' }))}\n >\n Don't proceed\n </button>\n <button\n disabled={busy || review.status !== 'pending'}\n onClick={() => settle(client.respond(review.id, { action: 'cancel' }))}\n >\n Cancel\n </button>\n </section>\n );\n }\n if (part.type === 'data-input-request') {\n const request = part.data;\n return (\n <section key={request.id} aria-label=\"Assistant needs input\">\n <p>{request.message}</p>\n {/* request.requestedSchema is the sole input-form contract. */}\n <p>This custom renderer has not implemented the requested schema form.</p>\n <button\n disabled={busy || request.status !== 'pending'}\n onClick={() => settle(client.respond(request.id, { action: 'decline' }))}\n >\n Don't proceed\n </button>\n <button\n disabled={busy || request.status !== 'pending'}\n onClick={() => settle(client.respond(request.id, { action: 'cancel' }))}\n >\n Cancel\n </button>\n </section>\n );\n }\n if (part.type === 'data-tool-result') {\n return (\n <p key={part.data.id} role=\"status\">\n A result is available, but this renderer has no trusted presentation for it.\n </p>\n );\n }\n if (part.type === 'data-view') {\n return (\n <NoodleAppView\n key={`${part.data.id}:${part.data.resourceUri}`}\n client={client}\n view={part.data}\n theme={resolvedTheme}\n />\n );\n }\n if (part.type === 'data-sign-in') {\n const request = part.data;\n return (\n <section key={request.id} aria-label=\"Sign in required\">\n <p>Continue with your account to use this capability.</p>\n <button\n disabled={busy || pendingSignInTicket !== undefined}\n onClick={() => {\n setPendingSignInTicket(request.signInTicket);\n void Promise.resolve()\n .then(() =>\n onSignInRequested({\n signInTicket: request.signInTicket,\n expiresAt: request.expiresAt,\n }),\n )\n .then(\n (result) => {\n if (result === 'cancelled') setPendingSignInTicket(undefined);\n },\n () => setPendingSignInTicket(undefined),\n );\n }}\n >\n Sign in\n </button>\n </section>\n );\n }\n return <p key={index}>Unsupported assistant content.</p>;\n })}\n </article>\n ))}\n <p role=\"status\" aria-live=\"polite\">\n {sessionNotice || turnNotice || (busy ? 'Assistant is working' : '')}\n </p>\n {suggestions?.prompts.length ? (\n <nav aria-label=\"Suggested messages\">\n {suggestions.prompts.map((prompt) => (\n <button\n key={prompt}\n type=\"button\"\n disabled={busy}\n onClick={() => {\n setTurnNotice('');\n settle(client.sendMessage(prompt));\n }}\n >\n {prompt}\n </button>\n ))}\n </nav>\n ) : null}\n {error ? <p role=\"alert\">The assistant could not complete that request.</p> : null}\n <form\n onSubmit={(event) => {\n event.preventDefault();\n const message = draft.trim();\n if (!message) return;\n setDraft('');\n setTurnNotice('');\n settle(client.sendMessage(message));\n }}\n >\n <input\n aria-label=\"Message\"\n value={draft}\n onChange={(event) => setDraft(event.currentTarget.value)}\n />\n {busy ? (\n <button\n type=\"button\"\n onClick={() => {\n client.abort();\n setTurnNotice('Response stopped. This does not undo a started action.');\n }}\n >\n Stop\n </button>\n ) : (\n <button type=\"submit\">Send</button>\n )}\n </form>\n </section>\n );\n}\n```\n\n`principalKey` stays in the browser. Change it whenever the authenticated user or tenant changes; the hook\nthen aborts and clears the prior session and transcript. The sample does not render Confirm until the host\nimplements a complete schema-aware review, and it does not accept elicitation until a portable form covers\n`requestedSchema`; use the managed renderer instead of shipping either unsupported branch. Suggestions are\nhook-owned and submit ordinary messages through `client.sendMessage`.\n\n`data-sign-in` has no status and is never passed to `client.respond`. Bind `signInTicket` to the host's\nshort-lived login transaction without putting it in URLs, logs, analytics, or durable browser storage. Resolve\nthe callback as `started` only after the host owns one active transaction; return `cancelled` or reject when no\ntransaction started so the renderer restores the sign-in affordance. A\nmixed renderer uses separate public and authenticated clients: the public shell uses `embedId`, `serviceUrl`,\nand a visitor principal key; after login, the destination mounts a new client against the same-origin session\nendpoint under the user/tenant principal key. Never pass both source options or mutate the public client's\nsource in place. `session_expired` is observational: the client owns its single safe pre-execution re-exchange.\nNever add a generic Retry button or replay an interaction decision automatically.\n\nFor `data-tool-result`, do not expose technical tool names or raw JSON. Prefer the linked `data-view`;\notherwise map a known bounded result to application-trusted UI or keep the explicit unsupported state. For\n`data-view`, use `<noodle-app-view>` or its React `NoodleAppView` adapter. The element's semantic lifecycle\nidentity is the client plus `view.id` plus `view.resourceUri`, so payload/callback rerenders keep the iframe\nand only a different view, disconnect, or App teardown request retires the bridge.\nApp views remain inline by default: the host advertises only inline presentation and rejects a widget's\nfullscreen request. A customer-owned renderer may opt in explicitly with `allowFullscreen` on\n`NoodleAppView` or `allow-fullscreen` on `<noodle-app-view>` only when fullscreen is part of its intended\nexperience. When fullscreen is accepted, the shared host adds a top-right exit control that returns the same\nmounted App to inline mode without discarding its state.\nNever inject `part.data.html`, assign it to `srcdoc`, fetch a `ui://` URI, or reproduce the bridge directly. Pages with a\nContent-Security-Policy must include the Noodle service origin in both `connect-src` and `frame-src`.\n\nBefore the production-equivalent host build, run the presence-only handoff check:\n\n```sh\nnoodle assistant embed --check --json\n```\n\nAdd application-owned delegated-exchange requirements with repeatable `--require-env NAME` flags. The JSON\nreports required and missing names, CSP status, and post-deploy probes without returning environment values\nor writing scaffold files. Map the names through the production secret manager, CI environment, and any\nsecret allowlist; regenerate existing framework-owned environment binding types before the build. Default\nDevtools/model exercises to synthetic data, and obtain approval before sending real connector data to an\nexternal model.\n\nRead `evidence.levels` in order: static host, local contract, hosted session, production browser, then\noperations. Stop at `evidence.firstUnproven`. A static result may be `passed`, `partial`, or `failed`; even\ntop-level `ready: true` means only that no static blocker was detected. It never proves the local session\ncontract or a production-browser flow. Value-free diagnostics call out an MCP endpoint used as the service\nURL, an HTML redirect risk, an SSR mount risk, and a cross-origin session endpoint. The command has no live\nor browser flag; its `postDeployProbes` are next actions, not executed evidence.\n\nAfter deployment, use the assistant doctor to verify the embed client, exact model transport, and static\nsession boundary:\n\n```sh\nnoodle assistant doctor --user-id <real-test-user> --origin \"$PUBLIC_APP_ORIGIN\" --org <org> --app <app> --env <env>\n```\n\nThe doctor makes one bounded synthetic model request without business tools or customer conversation data;\nfailures show only a redacted category, status, and retryability. It does not invent or test an\napplication-specific customer route. Prove routed assistant tools by\nhaving the authenticated embedding backend pass the user's server-verified endpoint during session\nexchange, then invoke one representative safe read.\n\nDo not send a first turn on mount by default. React effect cleanup can suppress one provisional Strict Mode\neffect, but it cannot make a remount, dependency change, or client replacement idempotent. Require an\nexplicit user action unless the host owns durable one-shot state and an application idempotency key that\nmakes repeated sends safe.\n\nFor a chat-first custom host, raw `tool_started` supplies the direct call `id` and technical tool name. Map\nknown tools to concise application copy and use a neutral fallback. Reserve a stable `role=\"status\"` region\nfor thinking, tool activity, and the view skeleton; switch to the ready `<noodle-app-view>` (or React\n`NoodleAppView`) on `view_available` or to `role=\"alert\"` on error. Decorative skeleton shapes stay hidden from assistive technology, and shimmer\nor transition motion is disabled under `prefers-reduced-motion`.\n\nUse `${view.id}:${view.resourceUri}` as transport identity. Different call IDs are distinct invocations and\nmust not be deduplicated generically. If this application intentionally owns one current panel for a known\nresource, declare an application-owned slot for that resource and replace only that slot.\n\n### Framework-neutral DOM client\n\nSubscribe to the DOM-free client directly without a component wrapper and use the isolated App host.\nIt exposes the same conversation as headless AI SDK `UIMessage` state, including typed confirmation, input,\ntool-result, and linked-view parts:\n\n```html\n<div id=\"assistant-app-views\"></div>\n```\n\n```ts\nimport '@noodleseed/assistant/app-view';\nimport {\n type AssistantViewAvailableDetail,\n type NoodleAppViewElement,\n} from '@noodleseed/assistant/app-view';\nimport { createAssistantClient } from '@noodleseed/assistant/client';\n\nconst assistant = createAssistantClient({\n sessionEndpoint: '/api/noodle-assistant/session',\n});\nconst appViews = document.querySelector('#assistant-app-views');\nif (!appViews) throw new Error('Missing App views host');\nconst mountedViews = new Map<string, NoodleAppViewElement>();\nconst readResolvedTheme = (): 'light' | 'dark' =>\n document.documentElement.classList.contains('dark') ? 'dark' : 'light';\nlet resolvedTheme: 'light' | 'dark' = readResolvedTheme();\nconst syncResolvedTheme = () => {\n resolvedTheme = readResolvedTheme();\n for (const mountedView of mountedViews.values()) mountedView.theme = resolvedTheme;\n};\nnew MutationObserver(syncResolvedTheme).observe(document.documentElement, {\n attributes: true,\n attributeFilter: ['class'],\n});\nconst appViewFor = (view: AssistantViewAvailableDetail) => {\n const key = `${view.id}:${view.resourceUri}`;\n let appView = mountedViews.get(key);\n if (!appView) {\n appView = document.createElement('noodle-app-view') as NoodleAppViewElement;\n appView.client = assistant;\n mountedViews.set(key, appView);\n appViews.append(appView);\n }\n appView.theme = resolvedTheme;\n return appView;\n};\n\nassistant.subscribeChat((state) => {\n renderUIMessageState(state, {\n respond: (id, response) => assistant.respond(id, response),\n });\n const activeViewKeys = new Set<string>();\n for (const message of state.messages) {\n for (const part of message.parts) {\n if (part.type === 'data-view') {\n const key = `${part.data.id}:${part.data.resourceUri}`;\n activeViewKeys.add(key);\n appViewFor(part.data).view = part.data;\n }\n }\n }\n for (const [key, mountedView] of mountedViews) {\n if (!activeViewKeys.has(key)) {\n mountedView.remove();\n mountedViews.delete(key);\n }\n }\n});\n```\n\n`renderUIMessageState` is application code. It must present a complete schema-aware confirmation or input\nform and require an explicit user gesture before using `respond`; never call `respond` while scanning a\ntranscript snapshot.\n\n`theme=\"auto\"` follows the operating-system preference, not a SaaS-owned toggle. Pass the resolved\n`light`/`dark` theme to `NoodleAssistant` and `<noodle-app-view>`/`NoodleAppView`; updates reach mounted MCP Apps without a\nremount. CSS custom properties inherit through the host, and documented `--ns-assistant-*` variables remain\nthe final integration escape hatch. Server `branding` is shared by widgets and the assistant; there is no\nsecond branding declaration. Text streams progressively. Expired turns re-exchange and retry once;\nconfirmations never replay automatically.\n\nThe customer IdP must place the full tenant API base URL in `tenant.api_base_url`. For example, one verified\ncustomer may receive `https://customer-a.api.noodleseed.dev/v1` and another\n`https://customer-b.api.noodleseed.dev/v1`; both satisfy the declared suffix policy. Application code,\ndeployment variables, and connector arguments do not select the tenant route.\n\n`CUSTOMER_API_CLIENT_ID` and `CUSTOMER_API_CLIENT_SECRET` authenticate only the broker to the fixed exchange\nendpoint. They are not customer API bearer tokens. The exchange endpoint verifies the platform-signed\nsubject assertion and mints a short-lived token scoped to the signed-in user and route binding.\n\n## Launch and qualified-usage proof\n\nUse the [embedded assistant guide](https://docs.noodleseed.dev/docs/guides/embedded-assistant) for the complete\npre-launch, browser-proof, qualified-usage, recovery, measurement, and operator procedure; do not copy those\ncommands into this example. Keep deployed capability, production-browser proof, qualified usage, and measured\noutcome separate; raw turn volume is utilization rather than outcome. Platform-owned completion events still\nrequire a separate human-approved analytics and customer-data contract.\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" },
|
|
58
|
+
{ relPath: "examples/customer-auth/README.md", content: "# Customer Auth - OIDC identity and customer-routed APIs\n\nFor a visitor who starts before signup, use the [Stateful Draft reference](../stateful-draft/README.md)\nalongside this authenticated backend integration.\n\nThis curated example owns the customer/end-user authentication capability slot. It proves that a SaaS app\ncan protect an MCP endpoint with direct OIDC, retain role/scope-based tool authorization, and route ordinary\nreads and confirmed actions to the API origin selected by the verified customer's identity provider.\n\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. The built-in card hides\nits optional technical Additional details disclosure while retaining the business review and confirmation\ncontrols; this presentation setting does not weaken the exact runtime confirmation boundary.\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 Responses-compatible endpoint, selected explicitly with\n`transport: 'responses'` in `src/server.ts`. Use `transport: 'chat-completions'` or omit the field for a\nChat Completions endpoint. Noodle never falls back between them. Configure its managed values at the Noodle\ndeployment environment; none of these values belongs in the customer web application environment, and the\nAPI key never reaches the browser:\n\nThe assistant session carries a verified user, tenant, deployment, roles, and scopes. For this flagship's\nrouted tools, the embedding backend resolves the signed-in user's cluster from server-owned membership data\nand passes `routing: { endpoints: { customer_api: cluster.apiBaseUrl } }` to\n`createAssistantSession`. Noodle validates and privately stores that route; it is not returned to the\nbrowser. Do not copy the route into page context, session claims, tool input, or model instructions.\n\nThe authenticated assistant surface also declares `accountTier` as a model-visible session claim. Pass it\nfrom the same backend-owned account record as `claims: { accountTier: account.tier }`; undeclared claims are\ndropped. This is personalization context, not authorization: the verified roles/scopes beside each tool and\nthe server-owned customer route remain the enforcement boundaries. The public\n[runtime guide](https://docs.noodleseed.dev/docs/guides/product-agent-guides#use-the-guide-at-runtime)\nexplains how verified session claims constrain the guide content available to the model.\n\n```bash\nnoodle variables set ASSISTANT_ORIGIN https://app.example.com --scope env\nnoodle variables set ASSISTANT_MODEL_BASE_URL https://model.example.com/v1 --scope env\nnoodle variables set ASSISTANT_MODEL your-model --scope env\nnoodle secrets set ASSISTANT_MODEL_API_KEY --scope env\nnoodle variables set CUSTOMER_API_CLIENT_ID your-broker-client-id --scope env\nnoodle secrets set CUSTOMER_API_CLIENT_SECRET --scope env\nnoodle check --target embedded-assistant src/server.ts\n```\n\n`ASSISTANT_ORIGIN` is the operator-owned production embedding origin, so one source can serve every customer\nwithout an application fork. Assistant origins are exact. Production embedding origins must use HTTPS; plain HTTP is accepted only for\nloopback development origins such as `http://localhost:3000`, `http://127.0.0.1:3000`, or\n`http://[::1]:3000`. `noodle dev` serves the MCP project, not that separate embedding application.\n\nThe bounded `presentation` object configures the panel, launcher, header, composer, and messages. Its\nprimitives derive colors from shared server `branding`; raw HTML, CSS, inline SVG, renderer classes, and\ncallbacks are not accepted. This example omits `presentation.panel.surface`, so the renderer keeps the\nopaque default panel treatment while the example's light/dark `branding` surfaces provide its customer colors;\nset the bounded surface to `glass` only when translucency is intentional.\n\nThese TypeScript values remain the reusable developer defaults. After deployment, an environment operator\ncan adjust theme, logo, launcher style, position, and the bounded color palette from the Console's\n**Assistant** tab or `noodle assistant appearance` without changing the customer's embed code. See the\n[embedded assistant guide](https://docs.noodleseed.dev/docs/guides/embedded-assistant) for precedence and reset\nbehavior.\n\nCreate the backend credential after deployment. The CLI writes it to a mode-0600 file and never prints the\nsecret:\n\n```bash\nnoodle assistant clients create --name web --org noodleseed --app customer-auth --env prod\n```\n\nOnly the Noodle service URL, assistant client ID, and assistant client secret belong in the authenticated\ncustomer backend. The model URL, model name, and model API key remain managed by the Noodle deployment.\n\nThe customer's authenticated backend calls `createAssistantSession(...)` from\n`@noodleseed/assistant/server`, passing the already-verified user and browser origin. The browser then uses\nthe returned short-lived session through the managed Web Component/React renderer or a customer-owned UI:\n\n```bash\npnpm add @noodleseed/assistant\n```\n\n```tsx\nimport { NoodleAssistant } from '@noodleseed/assistant/react';\n\n<NoodleAssistant\n sessionEndpoint=\"/api/noodle-assistant/session\"\n theme={resolvedTheme}\n onSessionExpired={() => console.info('Assistant session renewed')}\n/>;\n```\n\n`resolvedTheme` is the application's current `'light' | 'dark'` value. Use `theme=\"auto\"` only when the\nbrowser operating-system preference is intentionally authoritative.\n\n### Minimal fail-closed custom renderer skeleton\n\nUse the renderer-free hook only when the product has a concrete reason to own the conversation UI. This\nminimal skeleton keeps the canonical client and App host, but intentionally refuses confirmation and input\nacceptance until the application implements their complete schema-aware presentation. Start with the managed\nrenderer unless the application accepts every obligation below.\n\n```tsx\n'use client';\n\nimport { useEffect, useState } from 'react';\nimport { NoodleAppView } from '@noodleseed/assistant/react';\nimport { useNoodleAssistant } from '@noodleseed/assistant/react/client';\n\nexport function CustomerAssistant({\n principalKey,\n resolvedTheme,\n onSignInRequested,\n}: {\n principalKey: string;\n resolvedTheme: 'light' | 'dark';\n onSignInRequested: (request: {\n signInTicket: string;\n expiresAt: string;\n }) => Promise<'started' | 'cancelled'>;\n}) {\n const [draft, setDraft] = useState('');\n const [sessionNotice, setSessionNotice] = useState('');\n const [turnNotice, setTurnNotice] = useState('');\n const [pendingSignInTicket, setPendingSignInTicket] = useState<string>();\n const { client, messages, suggestions, status, error } = useNoodleAssistant({\n sessionEndpoint: '/api/noodle-assistant/session',\n principalKey,\n });\n const busy = status === 'submitted' || status === 'streaming';\n const settle = (operation: Promise<void>) => {\n void operation.catch(() => {\n // The hook exposes this same structured failure through `error`.\n });\n };\n useEffect(\n () =>\n client.subscribe((event) => {\n if (event.event === 'session_expired') setSessionNotice('Session expired.');\n if (event.event === 'session_started' || event.event === 'session_reset') {\n setSessionNotice('');\n }\n }),\n [client],\n );\n\n return (\n <section aria-label=\"Assistant\" aria-busy={busy}>\n {messages.map((message) => (\n <article key={message.id} data-role={message.role}>\n {message.parts.map((part, index) => {\n if (part.type === 'text') return <p key={index}>{part.text}</p>;\n if (part.type === 'data-confirmation') {\n const review = part.data;\n return (\n <section key={review.id} aria-label=\"Review proposed action\">\n <h3>{review.title ?? 'Review proposed action'}</h3>\n {review.description ? <p>{review.description}</p> : null}\n <p>This custom renderer has not implemented a complete schema-aware review.</p>\n <button\n disabled={busy || review.status !== 'pending'}\n onClick={() => settle(client.respond(review.id, { action: 'decline' }))}\n >\n Don't proceed\n </button>\n <button\n disabled={busy || review.status !== 'pending'}\n onClick={() => settle(client.respond(review.id, { action: 'cancel' }))}\n >\n Cancel\n </button>\n </section>\n );\n }\n if (part.type === 'data-input-request') {\n const request = part.data;\n return (\n <section key={request.id} aria-label=\"Assistant needs input\">\n <p>{request.message}</p>\n {/* request.requestedSchema is the sole input-form contract. */}\n <p>This custom renderer has not implemented the requested schema form.</p>\n <button\n disabled={busy || request.status !== 'pending'}\n onClick={() => settle(client.respond(request.id, { action: 'decline' }))}\n >\n Don't proceed\n </button>\n <button\n disabled={busy || request.status !== 'pending'}\n onClick={() => settle(client.respond(request.id, { action: 'cancel' }))}\n >\n Cancel\n </button>\n </section>\n );\n }\n if (part.type === 'data-tool-result') {\n return (\n <p key={part.data.id} role=\"status\">\n A result is available, but this renderer has no trusted presentation for it.\n </p>\n );\n }\n if (part.type === 'data-view') {\n return (\n <NoodleAppView\n key={`${part.data.id}:${part.data.resourceUri}`}\n client={client}\n view={part.data}\n theme={resolvedTheme}\n />\n );\n }\n if (part.type === 'data-sign-in') {\n const request = part.data;\n return (\n <section key={request.id} aria-label=\"Sign in required\">\n <p>Continue with your account to use this capability.</p>\n <button\n disabled={busy || pendingSignInTicket !== undefined}\n onClick={() => {\n setPendingSignInTicket(request.signInTicket);\n void Promise.resolve()\n .then(() =>\n onSignInRequested({\n signInTicket: request.signInTicket,\n expiresAt: request.expiresAt,\n }),\n )\n .then(\n (result) => {\n if (result === 'cancelled') setPendingSignInTicket(undefined);\n },\n () => setPendingSignInTicket(undefined),\n );\n }}\n >\n Sign in\n </button>\n </section>\n );\n }\n return <p key={index}>Unsupported assistant content.</p>;\n })}\n </article>\n ))}\n <p role=\"status\" aria-live=\"polite\">\n {sessionNotice || turnNotice || (busy ? 'Assistant is working' : '')}\n </p>\n {suggestions?.prompts.length ? (\n <nav aria-label=\"Suggested messages\">\n {suggestions.prompts.map((prompt) => (\n <button\n key={prompt}\n type=\"button\"\n disabled={busy}\n onClick={() => {\n setTurnNotice('');\n settle(client.sendMessage(prompt));\n }}\n >\n {prompt}\n </button>\n ))}\n </nav>\n ) : null}\n {error ? <p role=\"alert\">The assistant could not complete that request.</p> : null}\n <form\n onSubmit={(event) => {\n event.preventDefault();\n const message = draft.trim();\n if (!message) return;\n setDraft('');\n setTurnNotice('');\n settle(client.sendMessage(message));\n }}\n >\n <input\n aria-label=\"Message\"\n value={draft}\n onChange={(event) => setDraft(event.currentTarget.value)}\n />\n {busy ? (\n <button\n type=\"button\"\n onClick={() => {\n client.abort();\n setTurnNotice('Response stopped. This does not undo a started action.');\n }}\n >\n Stop\n </button>\n ) : (\n <button type=\"submit\">Send</button>\n )}\n </form>\n </section>\n );\n}\n```\n\n`principalKey` stays in the browser. Change it whenever the authenticated user or tenant changes; the hook\nthen aborts and clears the prior session and transcript. The sample does not render Confirm until the host\nimplements a complete schema-aware review, and it does not accept elicitation until a portable form covers\n`requestedSchema`; use the managed renderer instead of shipping either unsupported branch. Suggestions are\nhook-owned and submit ordinary messages through `client.sendMessage`.\n\n`data-sign-in` has no status and is never passed to `client.respond`. Bind `signInTicket` to the host's\nshort-lived login transaction without putting it in URLs, logs, analytics, or durable browser storage. Resolve\nthe callback as `started` only after the host owns one active transaction; return `cancelled` or reject when no\ntransaction started so the renderer restores the sign-in affordance. A\nmixed renderer uses separate public and authenticated clients: the public shell uses `embedId`, `serviceUrl`,\nand a visitor principal key; after login, the destination mounts a new client against the same-origin session\nendpoint under the user/tenant principal key. Never pass both source options or mutate the public client's\nsource in place. `session_expired` is observational: the client owns its single safe pre-execution re-exchange.\nNever add a generic Retry button or replay an interaction decision automatically.\n\nFor `data-tool-result`, do not expose technical tool names or raw JSON. Prefer the linked `data-view`;\notherwise map a known bounded result to application-trusted UI or keep the explicit unsupported state. For\n`data-view`, use `<noodle-app-view>` or its React `NoodleAppView` adapter. The element's semantic lifecycle\nidentity is the client plus `view.id` plus `view.resourceUri`, so payload/callback rerenders keep the iframe\nand only a different view, disconnect, or App teardown request retires the bridge.\nApp views remain inline by default: the host advertises only inline presentation and rejects a widget's\nfullscreen request. A customer-owned renderer may opt in explicitly with `allowFullscreen` on\n`NoodleAppView` or `allow-fullscreen` on `<noodle-app-view>` only when fullscreen is part of its intended\nexperience. When fullscreen is accepted, the shared host adds a top-right exit control that returns the same\nmounted App to inline mode without discarding its state.\nNever inject `part.data.html`, assign it to `srcdoc`, fetch a `ui://` URI, or reproduce the bridge directly. Pages with a\nContent-Security-Policy must include the Noodle service origin in both `connect-src` and `frame-src`.\n\nBefore the production-equivalent host build, run the presence-only handoff check:\n\n```sh\nnoodle assistant embed --check --json\n```\n\nAdd application-owned delegated-exchange requirements with repeatable `--require-env NAME` flags. The JSON\nreports required and missing names, CSP status, and post-deploy probes without returning environment values\nor writing scaffold files. Map the names through the production secret manager, CI environment, and any\nsecret allowlist; regenerate existing framework-owned environment binding types before the build. Default\nDevtools/model exercises to synthetic data, and obtain approval before sending real connector data to an\nexternal model.\n\nRead `evidence.levels` in order: static host, local contract, hosted session, production browser, then\noperations. Stop at `evidence.firstUnproven`. A static result may be `passed`, `partial`, or `failed`; even\ntop-level `ready: true` means only that no static blocker was detected. It never proves the local session\ncontract or a production-browser flow. Value-free diagnostics call out an MCP endpoint used as the service\nURL, an HTML redirect risk, an SSR mount risk, and a cross-origin session endpoint. The command has no live\nor browser flag; its `postDeployProbes` are next actions, not executed evidence.\n\nAfter deployment, use the assistant doctor to verify the embed client, exact model transport, and static\nsession boundary:\n\n```sh\nnoodle assistant doctor --user-id <real-test-user> --origin \"$PUBLIC_APP_ORIGIN\" --org <org> --app <app> --env <env>\n```\n\nThe doctor makes one bounded synthetic model request without business tools or customer conversation data;\nfailures show only a redacted category, status, and retryability. It does not invent or test an\napplication-specific customer route. Prove routed assistant tools by\nhaving the authenticated embedding backend pass the user's server-verified endpoint during session\nexchange, then invoke one representative safe read.\n\nDo not send a first turn on mount by default. React effect cleanup can suppress one provisional Strict Mode\neffect, but it cannot make a remount, dependency change, or client replacement idempotent. Require an\nexplicit user action unless the host owns durable one-shot state and an application idempotency key that\nmakes repeated sends safe.\n\nFor a chat-first custom host, raw `tool_started` supplies the direct call `id` and technical tool name. Map\nknown tools to concise application copy and use a neutral fallback. Reserve a stable `role=\"status\"` region\nfor thinking, tool activity, and the view skeleton; switch to the ready `<noodle-app-view>` (or React\n`NoodleAppView`) on `view_available` or to `role=\"alert\"` on error. Decorative skeleton shapes stay hidden from assistive technology, and shimmer\nor transition motion is disabled under `prefers-reduced-motion`.\n\nUse `${view.id}:${view.resourceUri}` as transport identity. Different call IDs are distinct invocations and\nmust not be deduplicated generically. If this application intentionally owns one current panel for a known\nresource, declare an application-owned slot for that resource and replace only that slot.\n\n### Framework-neutral DOM client\n\nSubscribe to the DOM-free client directly without a component wrapper and use the isolated App host.\nIt exposes the same conversation as headless AI SDK `UIMessage` state, including typed confirmation, input,\ntool-result, and linked-view parts:\n\n```html\n<div id=\"assistant-app-views\"></div>\n```\n\n```ts\nimport '@noodleseed/assistant/app-view';\nimport {\n type AssistantViewAvailableDetail,\n type NoodleAppViewElement,\n} from '@noodleseed/assistant/app-view';\nimport { createAssistantClient } from '@noodleseed/assistant/client';\n\nconst assistant = createAssistantClient({\n sessionEndpoint: '/api/noodle-assistant/session',\n});\nconst appViews = document.querySelector('#assistant-app-views');\nif (!appViews) throw new Error('Missing App views host');\nconst mountedViews = new Map<string, NoodleAppViewElement>();\nconst readResolvedTheme = (): 'light' | 'dark' =>\n document.documentElement.classList.contains('dark') ? 'dark' : 'light';\nlet resolvedTheme: 'light' | 'dark' = readResolvedTheme();\nconst syncResolvedTheme = () => {\n resolvedTheme = readResolvedTheme();\n for (const mountedView of mountedViews.values()) mountedView.theme = resolvedTheme;\n};\nnew MutationObserver(syncResolvedTheme).observe(document.documentElement, {\n attributes: true,\n attributeFilter: ['class'],\n});\nconst appViewFor = (view: AssistantViewAvailableDetail) => {\n const key = `${view.id}:${view.resourceUri}`;\n let appView = mountedViews.get(key);\n if (!appView) {\n appView = document.createElement('noodle-app-view') as NoodleAppViewElement;\n appView.client = assistant;\n mountedViews.set(key, appView);\n appViews.append(appView);\n }\n appView.theme = resolvedTheme;\n return appView;\n};\n\nassistant.subscribeChat((state) => {\n renderUIMessageState(state, {\n respond: (id, response) => assistant.respond(id, response),\n });\n const activeViewKeys = new Set<string>();\n for (const message of state.messages) {\n for (const part of message.parts) {\n if (part.type === 'data-view') {\n const key = `${part.data.id}:${part.data.resourceUri}`;\n activeViewKeys.add(key);\n appViewFor(part.data).view = part.data;\n }\n }\n }\n for (const [key, mountedView] of mountedViews) {\n if (!activeViewKeys.has(key)) {\n mountedView.remove();\n mountedViews.delete(key);\n }\n }\n});\n```\n\n`renderUIMessageState` is application code. It must present a complete schema-aware confirmation or input\nform and require an explicit user gesture before using `respond`; never call `respond` while scanning a\ntranscript snapshot.\n\n`theme=\"auto\"` follows the operating-system preference, not a SaaS-owned toggle. Pass the resolved\n`light`/`dark` theme to `NoodleAssistant` and `<noodle-app-view>`/`NoodleAppView`; updates reach mounted MCP Apps without a\nremount. CSS custom properties inherit through the host, and documented `--ns-assistant-*` variables remain\nthe final integration escape hatch. Server `branding` is shared by widgets and the assistant; there is no\nsecond branding declaration. Text streams progressively. Expired turns re-exchange and retry once;\nconfirmations never replay automatically.\n\nThe customer IdP must place the full tenant API base URL in `tenant.api_base_url`. For example, one verified\ncustomer may receive `https://customer-a.api.noodleseed.dev/v1` and another\n`https://customer-b.api.noodleseed.dev/v1`; both satisfy the declared suffix policy. Application code,\ndeployment variables, and connector arguments do not select the tenant route.\n\n`CUSTOMER_API_CLIENT_ID` and `CUSTOMER_API_CLIENT_SECRET` authenticate only the broker to the fixed exchange\nendpoint. They are not customer API bearer tokens. The exchange endpoint verifies the platform-signed\nsubject assertion and mints a short-lived token scoped to the signed-in user and route binding.\n\n## Launch and qualified-usage proof\n\nUse the [embedded assistant guide](https://docs.noodleseed.dev/docs/guides/embedded-assistant) for the complete\npre-launch, browser-proof, qualified-usage, recovery, measurement, and operator procedure; do not copy those\ncommands into this example. Keep deployed capability, production-browser proof, qualified usage, and measured\noutcome separate; raw turn volume is utilization rather than outcome. Platform-owned completion events still\nrequire a separate human-approved analytics and customer-data contract.\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" },
|
|
58
59
|
{ relPath: "examples/customer-auth/noodle.json", content: "{\n \"entrypoint\": \"src/server.ts\",\n \"name\": \"customer-auth\"\n}\n" },
|
|
59
60
|
{ relPath: "examples/customer-auth/package.json", content: "{\n \"name\": \"customer-auth\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {\n \"test\": \"vitest run\",\n \"validate\": \"noodle validate\",\n \"dev\": \"noodle dev\",\n \"deploy\": \"noodle deploy\"\n },\n \"devDependencies\": {\n \"@noodleseed/one\": \"latest\",\n \"vitest\": \"latest\"\n }\n}\n" },
|
|
60
61
|
{ relPath: "examples/customer-auth/src/server.ts", content: "import {\n annotations,\n authenticatedWebsite,\n connector,\n customerAuth,\n customerEndpoint,\n embeddedAssistant,\n openAICompatible,\n 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 transport: 'responses',\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 sessionClaims: {\n accountTier: { exposeToModel: true },\n },\n }),\n theme: 'auto',\n layout: { mode: 'floating', position: 'bottom-center', panelWidth: 970 },\n behavior: { showPoweredBy: true, showConfirmationDetails: false },\n labels: {\n welcomeHeading: 'How can I help with Noodle Seed?',\n launcherPlaceholder: 'Ask Noodle Seed anything',\n composerPlaceholder: 'Ask about your apps…',\n },\n presentation: {\n panel: { elevation: 'soft', border: 'subtle' },\n launcher: {\n style: 'pill',\n icon: 'brand-mark',\n status: 'session',\n effect: 'pulse',\n },\n header: {\n mark: 'status',\n badge: { text: 'Workspace online', tone: 'success', indicator: true },\n },\n composer: { leadingIcon: 'brand-mark', shape: 'pill' },\n },\n }),\n },\n [\n 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" },
|
|
@@ -85,11 +86,24 @@ export const BUNDLED_EXAMPLE_FILES = [
|
|
|
85
86
|
{ relPath: "examples/google-bigquery/src/server.ts", content: "import {\n annotations,\n bind,\n connection,\n connector,\n googleWorkloadIdentity,\n server,\n tool,\n variable,\n z,\n} from '@noodleseed/one';\n\nconst BIGQUERY_ORIGIN = 'https://bigquery.googleapis.com';\nconst BIGQUERY_READONLY = 'https://www.googleapis.com/auth/bigquery.readonly';\n\nconst bigquery = connector('google_bigquery')\n .version('1.0.0')\n .http({\n baseUrl: BIGQUERY_ORIGIN,\n allowedOrigins: [BIGQUERY_ORIGIN],\n credentialProfiles: { google_wif: { kind: 'bearer' } },\n operations: {\n query: {\n type: 'read',\n method: 'POST',\n path: '/bigquery/v2/projects/${args.project_id}/queries',\n input: z.object({\n project_id: z.string().min(1),\n query: z.string().min(1),\n max_results: z.number().int().min(1).max(1000).optional(),\n }),\n output: z.object({\n job_complete: z.boolean().optional(),\n schema: z.unknown().optional(),\n rows: z.array(z.unknown()).optional(),\n total_rows: z.string().optional(),\n }),\n request: {\n query: '${args.query}',\n useLegacySql: false,\n maxResults: '${args.max_results}',\n },\n response: {\n job_complete: '${response.jobComplete}',\n schema: '${response.schema}',\n rows: '${response.rows}',\n total_rows: '${response.totalRows}',\n },\n credentials: {\n profiles: ['google_wif'],\n scopes: [BIGQUERY_READONLY],\n audience: BIGQUERY_ORIGIN,\n },\n },\n },\n });\n\nconst google = connection(\n 'customer_google_cloud',\n googleWorkloadIdentity({\n provider: variable('GOOGLE_WIF_PROVIDER'),\n access: {\n kind: 'serviceAccountImpersonation',\n serviceAccount: variable('GOOGLE_SERVICE_ACCOUNT'),\n },\n }),\n);\n\nexport default server(\n 'google_bigquery',\n {\n title: 'Google BigQuery Reader',\n version: '1.0.0',\n use: {\n bigquery: bind(bigquery, { profile: 'google_wif', connection: google }),\n },\n instructions:\n 'Run read-only GoogleSQL queries in the configured customer BigQuery project. Never invent project, dataset, or table names.',\n },\n [\n tool('query_bigquery', {\n title: 'Query BigQuery',\n description:\n 'Run one read-only GoogleSQL query in an explicitly named Google Cloud project and return the typed BigQuery rows.',\n input: z.object({\n project_id: z.string().min(1).meta({ title: 'Google Cloud project' }),\n query: z.string().min(1).meta({ title: 'GoogleSQL query' }),\n max_results: z.number().int().min(1).max(1000).optional(),\n }),\n output: z.object({\n job_complete: z.boolean(),\n schema: z.unknown().optional(),\n rows: z.array(z.unknown()),\n total_rows: z.string().optional(),\n }),\n annotations: annotations.readOnly(),\n fulfil({ input, connectors }) {\n const result = connectors.bigquery.query({\n project_id: input.project_id,\n query: input.query,\n max_results: input.max_results,\n });\n return {\n job_complete: result.job_complete,\n schema: result.schema.optional(),\n rows: result.rows,\n total_rows: result.total_rows.optional(),\n };\n },\n }),\n ],\n);\n" },
|
|
86
87
|
{ relPath: "examples/google-bigquery/test/server.test.ts", content: "import { describe, expect, it } from 'vitest';\nimport app from '../src/server.js';\n\ndescribe('Google BigQuery workload-identity flagship', () => {\n it('binds a read-only Google operation to keyless service-account impersonation', async () => {\n const manifest = await app.toManifest();\n expect(manifest.connectors?.bigquery).toMatchObject({\n id: 'google_bigquery',\n binding: {\n profile: 'google_wif',\n connection: {\n id: 'customer_google_cloud',\n source: {\n kind: 'googleWorkloadIdentity',\n provider: '${env.GOOGLE_WIF_PROVIDER}',\n access: {\n kind: 'serviceAccountImpersonation',\n serviceAccount: '${env.GOOGLE_SERVICE_ACCOUNT}',\n },\n },\n },\n },\n });\n expect(app.toConnectorCatalog()).toMatchObject({\n connectors: [\n {\n credentialProfiles: { google_wif: { kind: 'bearer' } },\n operations: {\n query: {\n credentials: {\n profiles: ['google_wif'],\n scopes: ['https://www.googleapis.com/auth/bigquery.readonly'],\n audience: 'https://bigquery.googleapis.com',\n },\n },\n },\n },\n ],\n });\n expect(JSON.stringify({ manifest, catalog: app.toConnectorCatalog() })).not.toMatch(\n /private[_-]?key|client[_-]?secret|access[_-]?token/i,\n );\n });\n});\n" },
|
|
87
88
|
{ relPath: "examples/google-bigquery/vitest.config.ts", content: "import { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n resolve: {\n alias: {\n '@noodleseed/one': new URL('../../packages/authoring/src/index.ts', import.meta.url).pathname,\n '@noodle-borg/capabilities': new URL(\n '../../packages/capabilities/src/index.ts',\n import.meta.url,\n ).pathname,\n '@noodle-borg/compiler': new URL('../../packages/compiler/src/index.ts', import.meta.url)\n .pathname,\n '@noodle-borg/connector-defs': new URL(\n '../../packages/connector-defs/src/index.ts',\n import.meta.url,\n ).pathname,\n },\n },\n test: { include: ['test/**/*.test.ts'] },\n});\n" },
|
|
88
|
-
{ relPath: "examples/hello/README.md", content: "# hello — minimal TypeScript quickstart\n\nThe smallest deployable Noodle app: a single `greet` tool authored in TypeScript with no\nconnectors, secrets, flows, widgets, or handoff policy. It still uses the current server options form\nso new authors see where server-level branding belongs. Use it
|
|
89
|
+
{ relPath: "examples/hello/README.md", content: "# hello — minimal TypeScript quickstart\n\nThe smallest deployable Noodle app: a single `greet` tool authored in TypeScript with no\nconnectors, secrets, flows, widgets, or handoff policy. It still uses the current server options form\nso new authors see where server-level branding belongs. Use it for a local read or first deploy.\nFor a new project, `noodle init --template hello` also supplies isolated behavior tests: compilation,\ntool discovery, an expected greeting and invalid-input rejection. Run `npm install` then `npm test`.\n\nProtocol negotiation deliberately does not appear in `src/server.ts` or `noodle.json`. MCP versions\nare platform-owned: the same deployed app automatically serves compatible legacy clients and modern\nclients from its existing endpoint, without an app setting or redeploy.\n\nWhen an installed Noodle Developer plugin drives this example, its skill performs mapped lifecycle\nsteps through the supported `noodle-readiness` tools and reports only stable public `noodle ...`\ncommands as recovery text. Do not install or update a global CLI: the coding agent writes and tests\nthis source while Noodle guides and operates the validate, preview, deploy, inspect, and debug workflow.\nPlugin sign-in uses one compact consent for the Developer MCP resource; it does not ask the user to\nchoose organizations or environments. For remote inspection, the agent calls `get_context`, resolves\nthe intended organization from the request or this project, and passes that explicit `org` to every\nscoped tool. The local CLI may keep its own default organization for command convenience.\nFor an approved implementation plan, the installed `executing-noodle-plans` skill owns the\ntest-first task, review, recovery, and final-verification loop.\nIf that agent discovers a Noodle Seed product gap while working, the installed skill prepares a\nsanitized `noodle feedback` proposal, discovers current fields from `noodle commands --json`, and\npreviews the exact normalized submission, diagnostics, and private destination through the typed\nplugin function or `--dry-run --json`. It includes its known `--agent` and `--model` identity without\nguessing unavailable values, keeps those fields structured, and submits once only after explicit user\napproval of that exact preview; it never composes a shell wrapper, auto-logs in, or retry-loops.\nEvery `--json` command writes its canonical success or failure envelope to stdout and leaves stderr\nempty. One-shot commands write one envelope; streaming commands write NDJSON snapshot, event, and\nterminal-failure envelopes so agents can parse each line independently.\n\n```sh\nnoodle test examples/hello/src/server.ts --tool greet --args '{\"name\":\"Ada\"}' --json\nnoodle dev examples/hello/src/server.ts --app hello\nnoodle deploy examples/hello/src/server.ts --org acme --app hello\n```\n\n`noodle export manifest examples/hello/src/server.ts` compiles the same entrypoint locally and prints\nthe portable, vendor-neutral manifest JSON — the eject path: your `server.ts` plus this manifest is\nthe whole app, yours to read, diff, and keep.\n\nIt is also the fixture for `pnpm smoke:dev` and the e2e harness, so keep its tool surface stable.\n" },
|
|
89
90
|
{ relPath: "examples/hello/noodle.json", content: "{\n \"entrypoint\": \"src/server.ts\",\n \"name\": \"hello\",\n \"template\": \"hello\"\n}\n" },
|
|
90
91
|
{ relPath: "examples/hello/package.json", content: "{\n \"name\": \"hello\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {\n \"test\": \"vitest run --dir test\",\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" },
|
|
91
92
|
{ relPath: "examples/hello/src/server.ts", content: "import { annotations, server, tool, z } from '@noodleseed/one';\n\n// Customer apps stay on the public SDK; @noodle-borg/* packages are runtime implementation details.\n\nexport default server(\n 'hello',\n {\n title: 'Hello',\n version: '1.0.0',\n branding: {\n name: 'Hello',\n accent: '#1D9E75',\n radius: 'md',\n density: 'comfortable',\n },\n },\n [\n tool('greet', {\n // Every model-visible tool declares a title: hosts show it in tool pickers and confirmation\n // prompts, and both consumer directories reject tools without one.\n title: 'Greet someone',\n description: 'Greet someone by name.',\n input: z.object({\n // Defaults are advertised to the model and applied at runtime when the argument is omitted.\n name: z.string().default('world'),\n }),\n output: z.object({\n message: z.string(),\n }),\n // Read-only, closed-world: assistant surfaces run this without a consent prompt.\n annotations: annotations.readOnly(),\n fulfil: ({ input }) => {\n return { message: `Hello, ${input.name}!` };\n },\n }),\n ],\n);\n" },
|
|
92
93
|
{ relPath: "examples/hello/test/server.test.ts", content: "import { describe, expect, it } from 'vitest';\nimport app from '../src/server.js';\n\ndescribe('hello example', () => {\n it('exports a Noodle server definition', () => {\n expect(typeof app.toManifest).toBe('function');\n });\n\n it('advertises the greet default and keeps the argument optional', async () => {\n const manifest = await app.toManifest();\n const greet = manifest.tools?.find((tool) => tool.name === 'greet');\n const schema = greet?.inputSchema as {\n properties?: { name?: { default?: unknown } };\n required?: string[];\n };\n expect(schema.properties?.name?.default).toBe('world');\n expect(schema.required ?? []).not.toContain('name');\n });\n});\n" },
|
|
94
|
+
{ relPath: "examples/stateful-draft/README.md", content: "# Stateful Draft\n\n**Owns:** The flagship for a useful brief before signup, authoritative caller state, and account continuation.\n**Read when:** You want to compose a small conversational onboarding flow from existing platform capabilities.\n**Do not put here:** Customer credentials, a new identity provider, or business-system records.\n**Update when:** The reference tools, state schema, or runnable journey changes.\n\nStart with the visitor's goal. Help them produce something useful before asking for an account. This\nsynthetic example collects a project title, audience, and desired outcome; those fields are illustrative,\nnot requirements for any particular SaaS product.\n\n## The journey\n\n1. The visitor describes a goal. The assistant asks only for missing information and proposes a brief.\n2. `open_draft` opens an editable review and reads authoritative state, including its current revision.\n3. `save_draft` saves the reviewed brief after confirmation. It sends the current revision and the complete\n value to the state API. Failed or missing responses do not appear as successful saves.\n4. The visitor may choose `continue_draft`. This read requires identity, so an anonymous visitor sees the\n sign-in/signup card. Your host application completes its existing login and spends the bound ticket.\n5. The authenticated assistant reads the same adopted draft. No project, subscription, or business record\n is created by signing in.\n\nThe widget's Continue button requests the identity-dependent tool through the conversation. The ordinary\nsignup route should remain available on the embedding page.\n\n## Run the reference\n\nFrom this repository:\n\n```sh\npnpm install\npnpm build\nnoodle validate examples/stateful-draft/src/server.ts\nnoodle test examples/stateful-draft/src/server.ts\nnoodle dev examples/stateful-draft/src/server.ts --org demo --app stateful-draft\n```\n\nThe local runtime and widget preview exercise the typed tools. A complete mixed-assistant journey also\nrequires two host pages and a backend that verifies the user. The declared development origins are\n`http://localhost:3001` for the public page and `http://localhost:3002` for the authenticated page; replace\nthese with your exact deployment origins. The assistant uses `noodleManaged()`, whose hosted availability\nand budget belong to the operator. Local tool tests do not require a model key.\n\nUse the SDK version declared in this example's package file. Older published SDKs may omit the state\nadoption flag when compiling; the example's tests check that the flag reaches the compiled declaration.\n\nUse [signup continuity](https://docs.noodleseed.dev/docs/guides/signup-continuity) for the complete host\nintegration and [customer-auth](../customer-auth/README.md) for customer-owned API authentication.\n\n### Loopback demonstration with a hosted assistant\n\nThe included host serves the public page on port 3001 and a **simulated** account page on port 3002.\nIt binds to loopback and must not be published as a production authentication implementation.\nAfter deploying this app, copy this directory outside the monorepo and run the commands below from that\ncopy. This keeps published dependencies from shadowing the monorepo's workspace SDK.\n\n```sh\npnpm install --ignore-workspace --lockfile=false\nexport NOODLE_EMBED_ID=<embed-id-printed-by-deploy>\npnpm site\n```\n\nThis is enough to test the anonymous conversation and saved brief. To exercise the synthetic account\nhandoff, create an assistant backend client for the same org/app/env with\n`noodle assistant clients create --name first-brief-demo --org <org> --app <app> --env <env> --json`.\nSet `NOODLE_ASSISTANT_CREDENTIALS_FILE` to the returned `secretFile` path and restart `pnpm site`.\nThe host consumes that private file without printing it or sending its contents to the browser.\nOverride `NOODLE_SERVICE_URL` only when deploying to a different hosted service.\n\nThe simulated signup chooses a random temporary demo identity and spends the ticket through the real\nbackend session helper. The host's login transaction lasts ten minutes; it is local process memory and\nis lost on restart. A production integration replaces it with the customer's existing verified login and\nlogin transaction. Do not copy the synthetic identity branch into a real application.\n\n## Customer integration map\n\n| Reference | Adaptation in the customer's application |\n| :--- | :--- |\n| Three-field brief | Select the smallest useful outcome and collect only its missing inputs |\n| Public mixed surface | Mount the public embed on the unauthenticated website with an exact allowlist |\n| Expiring `draft` handle | Keep only temporary, bounded coordination state; omit persistence if unnecessary |\n| `continue_draft` | Trigger the existing signup/login at the point the visitor chooses an account |\n| Host session endpoint | Verify the logged-in user, spend the bound ticket, return the SDK session response |\n| Final business action | Add a typed connector to the existing authorized, idempotent create/update API |\n\nThe final business action belongs in the customer backend. Show the resulting record or its identifier\nonly after that API confirms success. Signup and state adoption alone are not completed onboarding.\nResearch or document parsing can be added later when they remove a demonstrated user burden; they are not\nprerequisites for this reference.\n\n## State and failure behavior\n\nThe draft uses caller scope, a finite 24-hour TTL, and `claimOnAuthentication: true`. Its `v2` schema\nreplaces the earlier title/stage illustration. Widget state is only a display cache. Reads, validation,\nrevision checks, expiry, and persistence belong to the runtime.\n\nA stale edit requires an explicit reload and review before another save. Spending the single-use sign-in\nticket moves only opted-in state to the backend-verified account, preserving its revision and expiry.\nDestination conflicts fail rather than merging two drafts. Abandoned or expired signup leaves the\nanonymous state under its original limits. A 24-hour state TTL does not promise cross-device recovery or\nthat an arbitrary new anonymous visit can recover the conversation.\n\nThe save is a connector-backed side effect on a public/mixed surface, so it has `confirm: true`. Previewing\nthe brief has no side effect and needs neither signup nor confirmation. Do not add a confirmation to each\nconversational answer or treat confirmation as proof of identity.\n\n## Validate before a customer pilot\n\n- Show useful value with no account and without repeating already supplied information.\n- Save, reopen, and edit the actual record; test a stale revision and an unconfirmed save.\n- Verify the same draft after the customer's real signup and login, including cancellation and expiry.\n- Test an existing-account draft conflict and ensure it is not silently overwritten.\n- Ensure signup triggers no unintended business write and account A cannot read account B's draft.\n- Compare onboarding completion and first useful product outcome with the existing flow; count signups\n separately. A demo is not evidence of improved conversion.\n" },
|
|
95
|
+
{ relPath: "examples/stateful-draft/noodle.json", content: "{\n \"entrypoint\": \"src/server.ts\",\n \"name\": \"stateful-draft\",\n \"template\": \"widget\"\n}\n" },
|
|
96
|
+
{ relPath: "examples/stateful-draft/package.json", content: "{\n \"name\": \"stateful-draft\",\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 \"site\": \"node site/demo.mjs\"\n },\n \"devDependencies\": {\n \"@noodleseed/assistant\": \"^1.33.0\",\n \"@vitejs/plugin-react\": \"latest\",\n \"@noodleseed/one\": \"^0.154.0\",\n \"react\": \"latest\",\n \"react-dom\": \"latest\",\n \"vite\": \"latest\",\n \"vitest\": \"latest\"\n }\n}\n" },
|
|
97
|
+
{ relPath: "examples/stateful-draft/site/client.js", content: "import '/sdk/index.js';\n\nconst config = await fetch('/demo-config').then((response) => response.json());\nconst mount = document.querySelector('#assistant');\nconst status = document.querySelector('#status');\nif (config.authenticated && !config.signedIn) {\n document.querySelector('#demo-login').hidden = false;\n status.textContent =\n 'Your draft is ready to come with you. This next step simulates the product’s signup.';\n} else {\n const assistant = document.createElement('noodle-assistant');\n assistant.setAttribute('theme', 'light');\n if (config.authenticated) {\n assistant.setAttribute('session-endpoint', '/assistant-session');\n status.textContent =\n 'You are using a synthetic demo account. Open the chat to continue with your saved brief.';\n } else {\n assistant.setAttribute('embed-id', config.embedId);\n assistant.setAttribute('service-url', config.serviceUrl);\n status.textContent =\n 'Start without an account. Open the chat and describe what you want to achieve.';\n }\n mount.append(assistant);\n}\n\ndocument.addEventListener('assistant-sign-in-requested', async (event) => {\n status.textContent = 'Preparing your account handoff…';\n try {\n const response = await fetch('/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ signInTicket: event.detail.signInTicket }),\n });\n const result = await response.json();\n if (!response.ok) {\n status.textContent = result.error ?? 'Handoff not started';\n return;\n }\n window.location.assign(result.destination);\n } catch {\n status.textContent =\n 'The handoff did not start. Your brief has not been moved. Please try again.';\n }\n});\n" },
|
|
98
|
+
{ relPath: "examples/stateful-draft/site/demo.mjs", content: "/** Loopback-only synthetic host. Replace its demo identity with your existing login in a real app. */\nimport { randomUUID } from 'node:crypto';\nimport { readFile } from 'node:fs/promises';\nimport { createServer } from 'node:http';\nimport { dirname, join } from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport { createAssistantSession } from '@noodleseed/assistant/server';\n\nconst publicOrigin = 'http://localhost:3001';\nconst appOrigin = 'http://localhost:3002';\nconst serviceUrl = process.env.NOODLE_SERVICE_URL ?? 'https://cloud.noodleseed.dev';\nconst embedId = process.env.NOODLE_EMBED_ID;\nconst credentialFile = process.env.NOODLE_ASSISTANT_CREDENTIALS_FILE;\nif (!embedId?.match(/^pub_[a-z0-9]+$/i)) {\n throw new Error('Set NOODLE_EMBED_ID from the hosted deployment.');\n}\nif (new URL(serviceUrl).protocol !== 'https:')\n throw new Error('The hosted service must use HTTPS.');\n// Consume only the explicitly supplied backend credential file. Its content is never sent to the browser.\nconst credentials = credentialFile ? JSON.parse(await readFile(credentialFile, 'utf8')) : undefined;\nif (\n credentials &&\n (typeof credentials.clientId !== 'string' || typeof credentials.clientSecret !== 'string')\n) {\n throw new Error('The backend credential file is not a CLI-created assistant client file.');\n}\nconst sdkDir = dirname(fileURLToPath(import.meta.resolve('@noodleseed/assistant')));\nconst siteDir = dirname(fileURLToPath(import.meta.url));\nconst transactions = new Map();\nconst lifetime = 10 * 60 * 1000;\n\nfunction send(res, status, value, type = 'application/json') {\n res.writeHead(status, {\n 'Content-Type': type,\n 'Cache-Control': 'no-store',\n 'Referrer-Policy': 'no-referrer',\n 'X-Content-Type-Options': 'nosniff',\n });\n res.end(type === 'application/json' ? JSON.stringify(value) : value);\n}\nfunction redirect(res, destination) {\n res.writeHead(303, { Location: destination, 'Cache-Control': 'no-store' });\n res.end();\n}\nfunction current(req) {\n const id = /(?:^|;\\s*)brief_demo=([a-f0-9-]+)/.exec(req.headers.cookie ?? '')?.[1];\n return id ? transactions.get(id) : undefined;\n}\nasync function jsonBody(req) {\n let text = '';\n for await (const chunk of req) {\n text += chunk.toString();\n if (Buffer.byteLength(text) > 8192) throw new Error('Request too large');\n }\n return JSON.parse(text);\n}\n\nfunction handler(origin) {\n return async (req, res) => {\n // The fixed loopback host is intentional: never deploy this synthetic-login server to the internet.\n if (req.headers.host !== new URL(origin).host) return send(res, 403, { error: 'Wrong host' });\n for (const [id, transaction] of transactions) {\n if (transaction.expiresAt <= Date.now()) transactions.delete(id);\n }\n const path = new URL(req.url ?? '/', origin).pathname;\n try {\n if (req.method === 'GET' && path === '/') {\n return send(\n res,\n 200,\n await readFile(join(siteDir, 'index.html')),\n 'text/html; charset=utf-8',\n );\n }\n if (req.method === 'GET' && path === '/demo-config') {\n return send(res, 200, {\n serviceUrl,\n embedId,\n authenticated: origin === appOrigin,\n signedIn: Boolean(current(req)?.user),\n });\n }\n if (req.method === 'GET' && path === '/client.js') {\n return send(res, 200, await readFile(join(siteDir, 'client.js')), 'text/javascript');\n }\n if (req.method === 'GET' && /^\\/sdk\\/[a-z0-9._-]+\\.js$/i.test(path)) {\n return send(res, 200, await readFile(join(sdkDir, path.slice(5))), 'text/javascript');\n }\n if (req.method === 'POST') {\n if (req.headers.origin !== origin) return send(res, 403, { error: 'Wrong origin' });\n if (origin === publicOrigin && path === '/start') {\n if (!credentials)\n return send(res, 503, {\n error:\n 'Signup demonstration needs an authorized backend client. The anonymous brief is still available.',\n });\n if (transactions.size >= 40) return send(res, 429, { error: 'Please try again later' });\n const body = await jsonBody(req);\n if (typeof body.signInTicket !== 'string' || body.signInTicket.length > 4096) {\n return send(res, 400, { error: 'Missing continuation ticket' });\n }\n const id = randomUUID();\n transactions.set(id, {\n signInTicket: body.signInTicket,\n expiresAt: Date.now() + lifetime,\n });\n res.setHeader(\n 'Set-Cookie',\n `brief_demo=${id}; HttpOnly; SameSite=Lax; Path=/; Max-Age=600`,\n );\n return send(res, 200, { destination: appOrigin });\n }\n if (origin === appOrigin && path === '/demo-login') {\n const transaction = current(req);\n if (!transaction)\n return send(res, 410, { error: 'Demo expired. Start again on the public page.' });\n // Synthetic identity only. A production route must verify its existing application session.\n transaction.user ??= { id: `demo_${randomUUID()}`, name: 'Demo visitor' };\n return redirect(res, '/');\n }\n }\n if (origin === appOrigin && req.method === 'GET' && path === '/assistant-session') {\n if (!credentials) return send(res, 503, { error: 'Configure the backend client first' });\n if (req.headers['sec-fetch-site'] !== 'same-origin')\n return send(res, 403, { error: 'Same-origin request required' });\n const transaction = current(req);\n if (!transaction?.user)\n return send(res, 401, { error: 'Choose the synthetic demo account first' });\n // Reuse the in-flight result so concurrent mounts cannot spend the single-use ticket twice.\n transaction.session ??= createAssistantSession({\n serviceUrl,\n clientId: credentials.clientId,\n clientSecret: credentials.clientSecret,\n origin: appOrigin,\n user: transaction.user,\n signInTicket: transaction.signInTicket,\n });\n const session = await transaction.session;\n delete transaction.signInTicket;\n return send(res, 200, session);\n }\n return send(res, 404, { error: 'Not found' });\n } catch {\n // Do not log credentials, tickets, requests, or raw provider errors.\n return send(res, 409, {\n error: 'Continuation was not completed. Return to the public page and start again.',\n });\n }\n };\n}\nfor (const [port, origin] of [\n [3001, publicOrigin],\n [3002, appOrigin],\n]) {\n createServer(handler(origin)).listen(port, '127.0.0.1');\n}\nconsole.log(`Synthetic onboarding demo: ${publicOrigin}`);\nconsole.log('Loopback only. Uses the hosted assistant; demo signup is not a real account.');\n" },
|
|
99
|
+
{ relPath: "examples/stateful-draft/site/index.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\">\n <title>First Brief · Conversational onboarding demo</title>\n <style>\n :root { font-family: system-ui, sans-serif; color: #172033; background: #f6f8fc; }\n body { margin: 0; }\n main { max-width: 880px; margin: 0 auto; padding: 64px 28px; }\n .label { font-size: 12px; font-weight: 700; letter-spacing: .14em; color: #526077; }\n h1 { max-width: 640px; font-size: clamp(36px, 6vw, 64px); line-height: 1.08; letter-spacing: -.055em; }\n p { max-width: 570px; line-height: 1.6; color: #526077; }\n .sample { padding: 24px; border: 1px solid #dce3ee; border-radius: 16px; background: white; margin: 28px 0; max-width: 550px; }\n a { color: #174bbd; }\n button { border: none; background: #2563eb; color: white; padding: 14px 20px; border-radius: 9px; font: inherit; cursor: pointer; }\n :focus-visible { outline: 3px solid #2563eb; outline-offset: 4px; }\n .notice { font-size: 13px; }\n </style>\n </head>\n <body>\n <main>\n <span class=\"label\">FIRST BRIEF · INTERNAL DEMONSTRATION</span>\n <h1>Make something useful.<br>Then decide to sign up.</h1>\n <p>Describe a goal. Refine a short brief. See the result before deciding whether to take it into an account.</p>\n <div class=\"sample\">\n <strong>Try this in the chat</strong>\n <p>“I want to help new teammates complete their first useful project in a week.”</p>\n <p id=\"status\" role=\"status\" aria-live=\"polite\">Connecting…</p>\n </div>\n <form id=\"demo-login\" action=\"/demo-login\" method=\"post\" hidden>\n <p><strong>Simulated signup</strong><br>This creates a temporary demo identity. It does not register an account with any SaaS product.</p>\n <button type=\"submit\">Continue as a demo user</button>\n <p><a href=\"http://localhost:3001\">Cancel and return to the public page</a></p>\n </form>\n <p class=\"notice\">Use synthetic information only. The assistant and draft storage are hosted by Noodle Seed. Signup is simulated locally; no customer backend is connected. Drafts expire within 24 hours. <a href=\"https://noodleseed.com/privacy\" target=\"_blank\" rel=\"noreferrer\">Privacy</a></p>\n </main>\n <div id=\"assistant\"></div>\n <script type=\"module\" src=\"/client.js\"></script>\n </body>\n</html>\n" },
|
|
100
|
+
{ relPath: "examples/stateful-draft/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, useToolInfo, useSendFollowUpMessage } = generateHelpers<AppType>();\n" },
|
|
101
|
+
{ relPath: "examples/stateful-draft/src/server.ts", content: "import {\n annotations,\n authenticatedWebsite,\n connector,\n embeddedAssistant,\n noodleManaged,\n publicWebsite,\n server,\n tool,\n z,\n} from '@noodleseed/one';\n\nconst draft = z.object({\n title: z.string().trim().min(1).max(120).optional(),\n audience: z.string().trim().min(1).max(240).optional(),\n goal: z.string().trim().min(1).max(240).optional(),\n});\n\nconst snapshot = z.object({\n value: draft,\n revision: z.number().int(),\n status: z.string(),\n});\n\nconst state = connector('noodle_state')\n .version('1.0.0')\n .operation('read_state', {\n type: 'read',\n input: z.object({ handle: z.string() }),\n output: snapshot,\n })\n .operation('patch_state', {\n type: 'action',\n input: z.object({\n handle: z.string(),\n expectedRevision: z.number().int().min(0),\n value: draft,\n }),\n output: snapshot,\n });\n\nconst openDraft = tool('open_draft', {\n title: 'Review your brief',\n description:\n 'Preview a proposed brief and read the saved brief with its revision. Pass the proposed title, audience, and goal in proposal to prefill the editable preview without saving it. Omit proposal to reload the saved record after a conflict.',\n input: z.object({ proposal: draft.default({}) }),\n output: snapshot.extend({ proposal: draft }),\n fulfil: ({ input, connectors }) => {\n const saved = connectors.state.readState({ handle: 'draft' });\n return {\n value: saved.value,\n revision: saved.revision,\n status: saved.status,\n proposal: input.proposal,\n };\n },\n viewTitle: 'Your project brief',\n invoking: 'Loading your saved brief…',\n invoked: 'Your brief is ready to review',\n view: { component: 'draft-card', entry: './views/draft-card.tsx' },\n annotations: annotations.readOnly(),\n viewDescription: 'Review and edit a saved brief before deciding whether to create an account.',\n csp: { connectDomains: [], resourceDomains: [], frameDomains: [] },\n});\n\nconst saveDraft = tool('save_draft', {\n title: 'Save your brief',\n description:\n 'Save this reviewed brief for up to 24 hours. This does not create an account or a project.',\n input: draft.required().extend({\n expectedRevision: z.number().int().min(0).meta({ title: 'Saved version' }),\n }),\n output: snapshot,\n annotations: annotations.localAction({ destructive: false, confirm: true }),\n fulfil: ({ input, connectors }) => {\n const saved = connectors.state.patchState({\n handle: 'draft',\n expectedRevision: input.expectedRevision,\n value: { title: input.title, audience: input.audience, goal: input.goal },\n });\n return { value: saved.value, revision: saved.revision, status: saved.status };\n },\n});\n\nconst continueDraft = tool('continue_draft', {\n title: 'Continue with an account',\n description:\n 'Read the brief in the signed-in account. Call only when the visitor chooses to continue with an account, after showing useful value. Sign-in carries the saved draft forward; it does not submit, publish, or create a project.',\n input: z.object({}),\n output: snapshot.extend({ accountId: z.string() }),\n annotations: annotations.readOnly(),\n fulfil: ({ connectors, user }) => {\n const saved = connectors.state.readState({ handle: 'draft' });\n return {\n value: saved.value,\n revision: saved.revision,\n status: saved.status,\n accountId: user.id,\n };\n },\n});\n\nexport default server(\n 'stateful_draft',\n {\n title: 'Your first useful brief',\n version: '1.0.0',\n instructions:\n 'Help someone turn their goal into a concise project brief: a title, audience, and desired outcome. Base the brief on information they supply. Propose a sensible editable title; never require a naming question. If the audience and outcome are already clear, immediately show the complete proposed brief. Otherwise ask one short question for missing information and skip anything already answered. Show the useful proposed brief in chat before offering to save it. Do not request an email, password, document, or external research. Read open_draft before save_draft. Saving requires review and confirmation; an unconfirmed save is not persisted. Never claim an account or project was created. After saving, offer continue_draft only as an optional next step; never require signup to see the brief. This is a synthetic onboarding example, not an integration with a specific SaaS product.',\n branding: {\n name: 'First Brief',\n accent: '#2563EB',\n surface: '#F8FAFC',\n surfaceDark: '#111827',\n radius: 'md',\n density: 'comfortable',\n typography: 'system',\n colorScheme: 'auto',\n },\n use: { state },\n state: {\n handles: {\n draft: {\n kind: 'draft',\n version: 'v2',\n scope: 'caller',\n ttlSeconds: 86400,\n claimOnAuthentication: true,\n schema: draft,\n },\n },\n },\n assistant: embeddedAssistant({\n model: noodleManaged(),\n privacyUrl: 'https://noodleseed.com/privacy',\n behavior: { showConfirmationDetails: false },\n access: [\n publicWebsite({\n origins: ['http://localhost:3001'],\n capabilities: [openDraft, saveDraft, continueDraft],\n signIn: true,\n }),\n authenticatedWebsite({\n origins: ['http://localhost:3002'],\n capabilities: [openDraft, saveDraft, continueDraft],\n }),\n ],\n labels: {\n welcomeHeading: 'What would you like to achieve?',\n composerPlaceholder: 'Describe your goal…',\n signInHeading: 'Take your brief with you',\n signInBody: 'Continue with an account to use your saved brief inside the product.',\n signInAction: 'Sign in',\n signUpAction: 'Create account',\n },\n }),\n },\n [openDraft, saveDraft, continueDraft],\n);\n" },
|
|
102
|
+
{ relPath: "examples/stateful-draft/src/views/draft-card.tsx", content: "import { useEffect, useState } from 'react';\nimport { useCallTool, useSendFollowUpMessage, useToolInfo } from '../helpers.js';\nimport './widget-style.css';\n\ntype Brief = { title?: string; audience?: string; goal?: string };\ntype Snapshot = { value: Brief; revision: number; status: string; proposal?: Brief };\n\nfunction displayBrief(data: Snapshot | undefined): Brief {\n const proposal = data?.proposal;\n return proposal && (proposal.title || proposal.audience || proposal.goal)\n ? proposal\n : (data?.value ?? {});\n}\n\nfunction snapshot(result: unknown): Snapshot | undefined {\n if (!result || typeof result !== 'object') return;\n const envelope = result as { isError?: boolean; structuredContent?: Partial<Snapshot> };\n const data = envelope.structuredContent;\n if (\n !envelope.isError &&\n data &&\n data.value &&\n typeof data.value === 'object' &&\n Number.isInteger(data.revision) &&\n typeof data.status === 'string'\n ) {\n return data as Snapshot;\n }\n}\n\nexport default function DraftCard() {\n const info = useToolInfo('open_draft');\n const read = useCallTool('open_draft');\n const save = useCallTool('save_draft');\n const followUp = useSendFollowUpMessage();\n const [saved, setSaved] = useState(() => snapshot(info));\n const [brief, setBrief] = useState<Brief>(() => displayBrief(snapshot(info)));\n const [busy, setBusy] = useState(false);\n const [reloadRequired, setReloadRequired] = useState(false);\n const [message, setMessage] = useState('Review your brief. An account is optional.');\n useEffect(() => {\n const next = snapshot({ structuredContent: info.structuredContent });\n if (next) {\n setSaved(next);\n setBrief(displayBrief(next));\n }\n }, [info.structuredContent]);\n const complete = Boolean(brief.title?.trim() && brief.audience?.trim() && brief.goal?.trim());\n const unchanged = ['title', 'audience', 'goal'].every(\n (key) => brief[key as keyof Brief] === saved?.value[key as keyof Brief],\n );\n\n async function run(operation: 'load' | 'save') {\n setBusy(true);\n try {\n const result =\n operation === 'load'\n ? await read.callTool({})\n : await save.callTool({ ...brief, expectedRevision: saved?.revision });\n const next = snapshot(result);\n if (!next) throw new Error('No authoritative result');\n setSaved(next);\n setBrief(displayBrief(next));\n setReloadRequired(false);\n setMessage(operation === 'save' ? 'Your brief is saved.' : 'Saved brief loaded.');\n } catch {\n setReloadRequired(true);\n setMessage(\n 'No save was confirmed. Reload saved to check the latest brief before trying again.',\n );\n } finally {\n setBusy(false);\n }\n }\n\n return (\n <main className=\"brief-shell\">\n <header>\n <p className=\"brief-eyebrow\">YOUR FIRST USEFUL STEP</p>\n <h1>Your project brief</h1>\n <p>Make something useful before creating an account.</p>\n </header>\n <section className=\"brief-fields\">\n <label>\n Project title\n <input\n required\n maxLength={120}\n value={brief.title ?? ''}\n placeholder=\"A better first week\"\n onChange={(event) => setBrief({ ...brief, title: event.currentTarget.value })}\n />\n </label>\n <label>\n Who is this for?\n <textarea\n required\n maxLength={240}\n rows={2}\n value={brief.audience ?? ''}\n placeholder=\"New teammates joining our product team\"\n onChange={(event) => setBrief({ ...brief, audience: event.currentTarget.value })}\n />\n </label>\n <label>\n What would success look like?\n <textarea\n required\n maxLength={240}\n rows={3}\n value={brief.goal ?? ''}\n placeholder=\"Complete their first useful project in a week\"\n onChange={(event) => setBrief({ ...brief, goal: event.currentTarget.value })}\n />\n </label>\n <p role=\"status\" aria-live=\"polite\">\n {busy ? 'Waiting for the result…' : message}\n </p>\n <div className=\"brief-actions\">\n <button type=\"button\" disabled={busy} onClick={() => void run('load')}>\n Reload saved\n </button>\n <button\n className=\"brief-primary\"\n type=\"button\"\n onClick={() => void run('save')}\n disabled={busy || !saved || !complete || reloadRequired}\n >\n Save brief\n </button>\n </div>\n </section>\n <footer>\n <p>\n A saved brief lasts up to 24 hours and can follow you when you sign in. No project has\n been created.\n </p>\n <button\n type=\"button\"\n disabled={\n busy || !complete || !unchanged || !saved || saved.revision === 0 || reloadRequired\n }\n onClick={() =>\n void followUp({ prompt: 'I would like to continue with my saved brief in an account.' })\n }\n >\n Continue with an account\n </button>\n </footer>\n </main>\n );\n}\n" },
|
|
103
|
+
{ relPath: "examples/stateful-draft/src/views/widget-style.css", content: "@layer stateful-draft {\n .brief-shell {\n box-sizing: border-box;\n max-width: 600px;\n margin: 0 auto;\n padding: 24px;\n color: var(--ns-text-primary, #172033);\n background: var(--ns-surface, #f8fafc);\n font-family: var(--ns-font, system-ui, sans-serif);\n line-height: 1.5;\n }\n .brief-shell * {\n box-sizing: border-box;\n }\n .brief-shell h1 {\n margin: 4px 0;\n font-size: 26px;\n letter-spacing: -0.03em;\n }\n .brief-shell p {\n color: var(--ns-text-secondary, #526077);\n }\n .brief-eyebrow {\n font-size: 11px;\n font-weight: 700;\n letter-spacing: 0.12em;\n }\n .brief-fields {\n display: grid;\n gap: 16px;\n margin: 24px 0;\n }\n .brief-shell label {\n display: grid;\n gap: 6px;\n font-size: 14px;\n font-weight: 600;\n }\n .brief-shell input,\n .brief-shell textarea {\n width: 100%;\n padding: 10px 12px;\n border: 1px solid var(--ns-border, #cbd5e1);\n border-radius: var(--ns-radius-md, 8px);\n color: inherit;\n background: var(--ns-surface-raised, #fff);\n font: inherit;\n font-weight: 400;\n resize: vertical;\n }\n .brief-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n }\n .brief-shell button {\n padding: 10px 16px;\n border: 1px solid var(--ns-border, #cbd5e1);\n border-radius: var(--ns-radius-md, 8px);\n background: var(--ns-surface-raised, #fff);\n color: inherit;\n font: inherit;\n font-size: 14px;\n font-weight: 600;\n cursor: pointer;\n }\n .brief-shell .brief-primary {\n background: var(--ns-accent, #2563eb);\n color: var(--ns-accent-text, #fff);\n border-color: transparent;\n }\n .brief-shell button:disabled {\n opacity: 0.5;\n cursor: default;\n }\n .brief-shell :focus-visible {\n outline: 2px solid var(--ns-focus, #2563eb);\n outline-offset: 3px;\n }\n .brief-shell footer {\n border-top: 1px solid var(--ns-border, #cbd5e1);\n padding-top: 12px;\n font-size: 13px;\n }\n @media (max-width: 380px) {\n .brief-shell {\n padding: 16px;\n }\n .brief-actions button {\n width: 100%;\n }\n }\n}\n" },
|
|
104
|
+
{ relPath: "examples/stateful-draft/test/draft-card.test.tsx", content: "// @vitest-environment happy-dom\nimport { act } from 'react';\nimport { createRoot } from 'react-dom/client';\nimport { afterEach, beforeEach, expect, it, vi } from 'vitest';\n\nconst callTool = vi.fn();\nconst followUp = vi.fn();\nconst initial = {\n value: { title: 'Team launch', audience: 'New teammates', goal: 'Complete their first project' },\n revision: 4,\n status: 'active',\n};\nlet entry: unknown = initial;\nvi.mock('../src/helpers.js', () => ({\n useToolInfo: () => ({ structuredContent: entry }),\n useCallTool: () => ({ callTool }),\n useSendFollowUpMessage: () => followUp,\n}));\n\nimport DraftCard from '../src/views/draft-card.js';\n\nlet host: HTMLDivElement;\nlet root: ReturnType<typeof createRoot>;\nbeforeEach(async () => {\n vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);\n callTool.mockReset();\n followUp.mockReset();\n entry = initial;\n host = document.createElement('div');\n document.body.append(host);\n root = createRoot(host);\n await act(async () => root.render(<DraftCard />));\n});\nafterEach(() => {\n act(() => root.unmount());\n host.remove();\n});\nfunction button(label: string) {\n const found = [...host.querySelectorAll('button')].find((entry) => entry.textContent === label);\n if (!found) throw new Error(`Missing button: ${label}`);\n return found;\n}\n\nit('saves using the server revision and displays only the returned result', async () => {\n callTool.mockResolvedValue({ structuredContent: { ...initial, revision: 8 } });\n await act(async () => button('Save brief').click());\n expect(callTool).toHaveBeenCalledWith({ ...initial.value, expectedRevision: 4 });\n expect(host.textContent).toContain('Your brief is saved.');\n expect(button('Continue with an account').disabled).toBe(false);\n});\n\nit('shows a proposed brief without pretending it is already saved', async () => {\n entry = { value: {}, revision: 0, status: 'active', proposal: initial.value };\n await act(async () => root.render(<DraftCard />));\n expect(host.querySelector('input')?.value).toBe('Team launch');\n expect(button('Continue with an account').disabled).toBe(true);\n callTool.mockResolvedValue({ structuredContent: { ...initial, revision: 1 } });\n await act(async () => button('Save brief').click());\n expect(callTool).toHaveBeenCalledWith({ ...initial.value, expectedRevision: 0 });\n});\n\nit('does not invent a save when confirmation is pending or the response is missing', async () => {\n callTool.mockResolvedValue({});\n await act(async () => button('Save brief').click());\n expect(host.textContent).not.toContain('Your brief is saved.');\n expect(host.textContent).toContain('No save was confirmed.');\n});\n\nit('retains edits on a stale write and requires a reload before another save', async () => {\n callTool.mockResolvedValue({ isError: true });\n await act(async () => button('Save brief').click());\n expect(host.querySelector('input')?.value).toBe('Team launch');\n expect(button('Save brief').disabled).toBe(true);\n expect(host.textContent).toContain('Reload saved');\n callTool.mockResolvedValue({ structuredContent: { ...initial, revision: 7 } });\n await act(async () => button('Reload saved').click());\n await act(async () => button('Save brief').click());\n expect(callTool).toHaveBeenLastCalledWith({ ...initial.value, expectedRevision: 7 });\n});\n\nit('keeps continuing separate from saving and makes no project-creation claim', async () => {\n await act(async () => button('Continue with an account').click());\n expect(callTool).not.toHaveBeenCalled();\n expect(followUp).toHaveBeenCalledWith({\n prompt: 'I would like to continue with my saved brief in an account.',\n });\n expect(host.textContent).not.toContain('Project created');\n});\n" },
|
|
105
|
+
{ relPath: "examples/stateful-draft/test/server.test.ts", content: "import { fileURLToPath } from 'node:url';\nimport { validate } from '@noodleseed/one';\nimport { describe, expect, it } from 'vitest';\nimport app from '../src/server.js';\n\ndescribe('stateful draft onboarding reference', () => {\n it('reads and saves authoritative state instead of a widget-only copy', async () => {\n const manifest = await app.toManifest();\n expect(manifest.tools.find((entry) => entry.name === 'open_draft')?.fulfilment.steps).toEqual([\n expect.objectContaining({ use: 'state.read_state', args: { handle: 'draft' } }),\n ]);\n expect(manifest.tools.find((entry) => entry.name === 'save_draft')).toMatchObject({\n annotations: { readOnlyHint: false, confirm: true },\n fulfilment: {\n steps: [\n expect.objectContaining({\n use: 'state.patch_state',\n args: {\n handle: 'draft',\n expectedRevision: '${input.expectedRevision}',\n value: {\n title: '${input.title}',\n audience: '${input.audience}',\n goal: '${input.goal}',\n },\n },\n }),\n ],\n },\n });\n });\n\n it('limits anonymous access and transfers only an expiring draft after verified login', async () => {\n const manifest = await app.toManifest();\n expect(manifest.state?.handles.draft).toMatchObject({\n scope: 'caller',\n ttlSeconds: 86400,\n claimOnAuthentication: true,\n });\n expect(manifest.server.assistant?.surfaces?.map((surface) => surface.mode)).toEqual([\n 'mixed',\n 'authenticated',\n ]);\n const continued = manifest.tools.find((entry) => entry.name === 'continue_draft');\n expect(continued?.annotations?.readOnlyHint).toBe(true);\n expect(continued?.fulfilment.output).toMatchObject({ accountId: '${user.id}' });\n expect(continued?.fulfilment.steps).toEqual([\n expect.objectContaining({ use: 'state.read_state', args: { handle: 'draft' } }),\n ]);\n });\n\n it('compiles through the public validator, including anonymous action confirmation', async () => {\n const result = await validate({\n manifestPath: fileURLToPath(new URL('../src/server.ts', import.meta.url)),\n });\n expect(result.ok, JSON.stringify(result.ok ? [] : result.errors)).toBe(true);\n });\n});\n" },
|
|
106
|
+
{ relPath: "examples/stateful-draft/vitest.config.ts", content: "import { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n oxc: { jsx: { runtime: 'automatic' } },\n test: { include: ['test/**/*.test.{ts,tsx}'], testTimeout: 30_000, maxWorkers: 2 },\n});\n" },
|
|
93
107
|
{ relPath: "examples/weather/README.md", content: "# Weather Briefing\n\nTwo declarative tools that show the runtime's breadth working together, with **no auth and no API\nkeys**. The `weather_briefing` tool takes a city name and runs a **three-step flow**:\n\nCapability slots: HTTP connector authoring, ordered fulfilment flows, query/response mapping,\n**list-returning connector output** (a connector that returns a live, variable-length array), and\nsandboxed compute, including an explicit least-privilege per-operation response-size bound.\n\n1. **`geo.search`** → geocode the city to coordinates (Open-Meteo Geocoding API)\n2. **`forecast.current`** → fetch current weather for those coordinates (Open-Meteo Forecast API)\n3. **`brief.summarize`** → derive a human-readable briefing in a **WASM/QuickJS compute sandbox**\n\nThe second tool, `search_places`, shows a connector returning a **live, variable-length list**: it binds\nthe whole Open-Meteo geocoding `results` array with `${response.results}`, then narrows each match to\n`{ id, label }` in a compute connector — the \"search → a list of options the model can pick from\"\npattern. Narrowing lives in compute because a `${...}` response mapping cannot iterate an array and a\ntool's Zod output does not strip fields at runtime.\n\nIt exercises, in one TypeScript-authored app:\n\n- **Server-level branding** with semantic tokens carried through the runtime artifact for any generated\n app surface.\n- **Ordered flow execution** with outputs threaded between steps (`${steps.geo.latitude}` → next step).\n- **Two HTTP connectors on two different hosts**, each with its own egress allowlist.\n- **Query parameters** (`query: [...]`) and a constant query baked into the path (`?current_weather=true`).\n- **Deep response mapping** with the `${...}` language — single-element indexing\n (`${response.results[0].latitude}`, `${response.current_weather.temperature}`) **and** whole-array\n binding (`${response.results}` returns the entire list verbatim).\n- **A list-returning connector + compute narrowing** — `geo.search_list` binds the whole `results`\n array; `places.narrow` reduces each element to `{ id, label }` and normalizes the no-results case\n to `[]`.\n- **A per-operation transport bound** — `search_list` sets\n `limits: { maxResponseBytes: 256 * 1024 }`, tightening this known-small endpoint below the 1 MiB default.\n The authoring ceiling is 6 MiB, but grant only the bytes representative evidence proves this operation\n needs.\n- **Sandboxed compute** (no network/fs/env/clock) turning raw numbers into conditions + advice.\n- **Typed input/output schemas** emitted as JSON Schema 2020-12.\n\n## APIs that require form-urlencoded search bodies\n\nThe live Open-Meteo calls above are GET requests. For APIs whose search endpoint is a POST expecting\n`application/x-www-form-urlencoded`, keep authoring a request object and select the encoding explicitly:\n\n```ts\nsearch_quotes: {\n type: 'read',\n method: 'POST',\n path: '/quotes/search',\n requestEncoding: 'form-urlencoded',\n input: z.object({\n fromAirportId: z.string(),\n categories: z.array(z.string()),\n }),\n request: {\n 'from airport id': '${args.fromAirportId}',\n 'aircraft[categories]': '${args.categories}',\n },\n // output and response mapping omitted\n},\n```\n\nNoodle builds a `URLSearchParams` body: spaces and punctuation in field names are encoded normally, while\neach array or nested object is JSON-stringified into its individual form field. Do not pre-encode the body\nor set `Content-Type` manually; the connector owns both.\n\n## Run it locally\n\nFrom the repo root, with the workspace built (`pnpm build`):\n\n```bash\n: # 1. boot the local loopback dev server\nnode packages/cli/dist/cli.js dev examples/weather/src/server.ts --app weather\n\n: # 2. in another shell, call the printed local endpoint\nURL=http://127.0.0.1:<port>/o/local/weather/dev/mcp\ncurl -s \"$URL\" \\\n -H 'content-type: application/json' \\\n -H 'accept: application/json, text/event-stream' \\\n -H 'mcp-protocol-version: 2025-11-25' \\\n -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"weather_briefing\",\"arguments\":{\"city\":\"Paris\"}}}'\n```\n\nExample result (live data, abbreviated):\n\n```json\n{\n \"place\": \"Paris\", \"country\": \"France\",\n \"temperature_c\": 25.1, \"windspeed_kmh\": 8.3,\n \"conditions\": \"overcast\",\n \"headline\": \"Paris, France: 25°C, overcast.\",\n \"advice\": \"Comfortable conditions — no special prep needed.\"\n}\n```\n\nTry other cities (`Reykjavik`, `Singapore`, `Denver`) to see the conditions and advice change.\n\nCall `search_places` to see the **list-returning** tool — one query, many matches:\n\n```bash\ncurl -s \"$URL\" \\\n -H 'content-type: application/json' \\\n -H 'accept: application/json, text/event-stream' \\\n -H 'mcp-protocol-version: 2025-11-25' \\\n -d '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"search_places\",\"arguments\":{\"query\":\"Springfield\"}}}'\n```\n\n```json\n{\n \"places\": [\n { \"id\": \"4951788\", \"label\": \"Springfield, Massachusetts, United States\" },\n { \"id\": \"4250542\", \"label\": \"Springfield, Illinois, United States\" },\n { \"id\": \"4508722\", \"label\": \"Springfield, Ohio, United States\" }\n ]\n}\n```\n" },
|
|
94
108
|
{ relPath: "examples/weather/noodle.json", content: "{\n \"entrypoint\": \"src/server.ts\",\n \"name\": \"weather\"\n}\n" },
|
|
95
109
|
{ relPath: "examples/weather/package.json", content: "{\n \"name\": \"weather\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {\n \"test\": \"vitest run\",\n \"validate\": \"noodle validate\",\n \"dev\": \"noodle dev\",\n \"deploy\": \"noodle deploy\"\n },\n \"devDependencies\": {\n \"@noodleseed/one\": \"latest\",\n \"vitest\": \"latest\"\n }\n}\n" },
|