@noodleseed/one 0.151.0 → 0.152.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assistant-embed-scaffold-template.d.ts +1 -0
- package/dist/assistant-embed-scaffold-template.d.ts.map +1 -1
- package/dist/assistant-embed-scaffold-template.js +80 -12
- package/dist/assistant-embed-scaffold-template.js.map +1 -1
- package/dist/commands/assistant-embed-ops.d.ts.map +1 -1
- package/dist/commands/assistant-embed-ops.js +141 -4
- package/dist/commands/assistant-embed-ops.js.map +1 -1
- package/dist/commands/assistant-ops.js +2 -1
- package/dist/commands/assistant-ops.js.map +1 -1
- package/node_modules/@noodle-borg/agent-kit/dist/behavior-skills.js +10 -10
- package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +6 -5
- package/node_modules/@noodle-borg/agent-kit/dist/index.js +13 -1
- package/node_modules/@noodle-borg/agent-kit/dist/skill-authoring-refs.js +8 -0
- package/node_modules/@noodle-borg/agent-kit/dist/skill-content.js +1 -1
- package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-operations-ref.js +78 -0
- package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-ref.js +156 -135
- package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-renderer-ref.js +109 -0
- package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-browser-fields.d.ts +3 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-browser-fields.js +15 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-configuration.d.ts +1 -1
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-configuration.js +1 -1
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-store.d.ts +19 -11
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-store.js +24 -23
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-transcript-events.d.ts +24 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-transcript-events.js +39 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-transcript.d.ts +6 -2
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-transcript.js +11 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-view-availability.d.ts +8 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-view-availability.js +8 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/elevation-store.d.ts +3 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/elevation.d.ts +7 -1
- package/node_modules/@noodle-borg/assistant-gateway/dist/elevation.js +1 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/in-memory-elevation-store.d.ts +1 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/in-memory-elevation-store.js +1 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/managed-spend.d.ts +29 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/managed-spend.js +27 -1
- package/node_modules/@noodle-borg/assistant-gateway/dist/portable.d.ts +1 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/portable.js +1 -0
- package/node_modules/@noodle-borg/assistant-gateway/package.json +1 -0
- package/node_modules/@noodle-borg/authoring/dist/server.d.ts +2 -0
- package/node_modules/@noodle-borg/authoring/dist/server.js +3 -0
- package/node_modules/@noodle-borg/compiler/dist/artifact/types.d.ts +1 -0
- package/node_modules/@noodle-borg/compiler/dist/artifact/version.d.ts +4 -1
- package/node_modules/@noodle-borg/compiler/dist/artifact/version.js +4 -1
- package/node_modules/@noodle-borg/compiler/dist/manifest/schema.d.ts +4 -0
- package/node_modules/@noodle-borg/compiler/dist/manifest/schema.js +20 -1
- package/node_modules/@noodle-borg/compiler/dist/state-handles.js +1 -0
- package/node_modules/@noodle-borg/runtime/dist/index.d.ts +1 -0
- package/node_modules/@noodle-borg/runtime/dist/index.js +1 -0
- package/node_modules/@noodle-borg/runtime/dist/state-handle-ownership.d.ts +21 -0
- package/node_modules/@noodle-borg/runtime/dist/state-handle-ownership.js +10 -0
- package/node_modules/@noodle-borg/runtime/package.json +6 -1
- package/node_modules/@noodle-borg/service/dist/routes/assistant-agent.js +8 -3
- package/node_modules/@noodle-borg/service/dist/routes/assistant-elevation.js +14 -7
- package/node_modules/@noodle-borg/service/dist/routes/assistant-interaction-replay.js +4 -2
- package/node_modules/@noodle-borg/service/dist/routes/assistant-interactions.js +4 -2
- package/node_modules/@noodle-borg/service/dist/routes/assistant-transcript.js +48 -9
- package/node_modules/@noodle-borg/service/dist/service.js +3 -0
- package/node_modules/@noodle-borg/service/package.json +1 -1
- package/node_modules/@noodle-borg/wire-contracts/dist/assistant.d.ts +19 -25
- package/node_modules/@noodle-borg/wire-contracts/dist/assistant.js +20 -34
- package/node_modules/@noodleseed/assistant/README.md +1 -1
- package/package.json +2 -2
|
@@ -52,17 +52,18 @@ export const BUNDLED_EXAMPLE_FILES = [
|
|
|
52
52
|
{ relPath: "examples/acme-tasks/src/views/widget-style.css", content: ":root {\n color-scheme: light dark;\n font-family:\n Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n --nw-bg: #ffffff;\n --nw-surface: #f7f5ff;\n --nw-text: #1c1830;\n --nw-muted: #635d80;\n --nw-border: #e2ddf5;\n --nw-accent: #7c3aed;\n --nw-accent-strong: #6d28d9;\n --nw-accent-soft: #efe9ff;\n --nw-high: #dc2626;\n --nw-medium: #b45309;\n --nw-low: #2563eb;\n --nw-radius: 10px;\n --nw-shadow: 0 18px 50px rgb(30 20 60 / 12%);\n}\n\n.dark,\n[data-theme=\"dark\"] {\n --nw-bg: #161228;\n --nw-surface: #1d1735;\n --nw-text: #f2eeff;\n --nw-muted: #a99fce;\n --nw-border: #342a54;\n --nw-accent: #a78bfa;\n --nw-accent-strong: #8b5cf6;\n --nw-accent-soft: #2a2148;\n --nw-high: #f87171;\n --nw-medium: #fbbf24;\n --nw-low: #60a5fa;\n --nw-shadow: 0 18px 50px rgb(0 0 0 / 32%);\n}\n\n* {\n box-sizing: border-box;\n}\n\nbody {\n margin: 0;\n background: var(--nw-bg);\n color: var(--nw-text);\n}\n\nbutton,\ninput,\nselect {\n font: inherit;\n}\n\n.nw-shell {\n min-height: 100vh;\n padding: 14px;\n background: var(--nw-bg);\n color: var(--nw-text);\n}\n\n.nw-card {\n max-width: 620px;\n margin: 0 auto;\n background: var(--nw-surface);\n border: 1px solid var(--nw-border);\n border-radius: var(--nw-radius);\n box-shadow: var(--nw-shadow);\n overflow: hidden;\n}\n\n.nw-header {\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 16px;\n border-bottom: 1px solid var(--nw-border);\n}\n\n.nw-icon svg {\n width: 24px;\n height: 24px;\n fill: none;\n stroke: var(--nw-accent);\n stroke-width: 2;\n stroke-linecap: round;\n stroke-linejoin: round;\n}\n\n.nw-title-block {\n flex: 1;\n min-width: 0;\n}\n\n.nw-title {\n margin: 0;\n font-size: 17px;\n font-weight: 700;\n}\n\n.nw-subtitle {\n margin: 2px 0 0;\n font-size: 13px;\n color: var(--nw-muted);\n}\n\n.nw-chip {\n padding: 4px 10px;\n border-radius: 999px;\n background: var(--nw-accent-soft);\n color: var(--nw-accent-strong);\n font-size: 12px;\n font-weight: 600;\n}\n\n.nw-capture {\n display: flex;\n gap: 8px;\n padding: 14px 16px 4px;\n}\n\n.nw-input {\n flex: 1;\n padding: 9px 12px;\n border: 1px solid var(--nw-border);\n border-radius: 10px;\n background: var(--nw-bg);\n color: var(--nw-text);\n}\n\n.nw-button {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 9px 14px;\n border: 1px solid var(--nw-border);\n border-radius: 10px;\n background: var(--nw-bg);\n color: var(--nw-text);\n cursor: pointer;\n}\n\n.nw-button-primary {\n background: var(--nw-accent);\n border-color: var(--nw-accent);\n color: #ffffff;\n font-weight: 600;\n}\n\n.nw-button-primary:disabled {\n opacity: 0.6;\n cursor: default;\n}\n\n.nw-list {\n list-style: none;\n margin: 0;\n padding: 8px 16px 4px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n}\n\n.nw-task {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 10px 12px;\n border: 1px solid var(--nw-border);\n border-radius: 12px;\n background: var(--nw-bg);\n}\n\n.nw-task-title {\n flex: 1;\n min-width: 0;\n}\n\n.nw-task-done {\n opacity: 0.55;\n}\n\n.nw-task-done .nw-task-title {\n text-decoration: line-through;\n}\n\n.nw-check {\n width: 22px;\n height: 22px;\n border: 1.5px solid var(--nw-border);\n border-radius: 999px;\n background: transparent;\n color: #ffffff;\n cursor: pointer;\n flex: none;\n}\n\n.nw-check-on {\n background: var(--nw-accent);\n border-color: var(--nw-accent);\n}\n\n.nw-priority {\n padding: 5px 8px;\n border: 1px solid var(--nw-border);\n border-radius: 8px;\n background: var(--nw-bg);\n color: var(--nw-text);\n font-size: 12px;\n font-weight: 600;\n}\n\n.nw-priority-high {\n color: var(--nw-high);\n}\n\n.nw-priority-medium {\n color: var(--nw-medium);\n}\n\n.nw-priority-low {\n color: var(--nw-low);\n}\n\n.nw-note {\n margin: 0;\n padding: 8px 16px 16px;\n font-size: 12px;\n color: var(--nw-muted);\n}\n" },
|
|
53
53
|
{ relPath: "examples/acme-tasks/test/server.test.ts", content: "import { describe, expect, it } from 'vitest';\nimport app from '../src/server.js';\n\ndescribe('acme-tasks example', () => {\n it('exports a Noodle server definition', () => {\n expect(typeof app.toManifest).toBe('function');\n });\n\n it('exposes a tool for each of the top-3 prioritized flows', async () => {\n // Capture → add_task, Prioritize → list_today (+ set_priority helper), Complete → complete_task.\n const text = JSON.stringify(await app.toManifest());\n expect(text).toContain('add_task');\n expect(text).toContain('list_today');\n expect(text).toContain('complete_task');\n expect(text).toContain('set_priority');\n });\n\n it('seeds today’s list highest-priority first', async () => {\n const text = JSON.stringify(await app.toManifest());\n expect(text).toMatch(/\"tasks\":\\[\\{\"id\":\"email_vendor\".*\"priority\":\"high\"/);\n });\n\n it('opts the conversational completion action into runtime confirmation', async () => {\n const manifest = await app.toManifest();\n const completeTask = manifest.tools.find((candidate) => candidate.name === 'complete_task');\n const addTask = manifest.tools.find((candidate) => candidate.name === 'add_task');\n const setPriority = manifest.tools.find((candidate) => candidate.name === 'set_priority');\n\n expect(completeTask?.annotations?.confirm).toBe(true);\n expect(addTask?.annotations).not.toHaveProperty('confirm');\n expect(setPriority?.visibility).toEqual(['app']);\n });\n\n it('teaches its three product workflows through one host-neutral agent guide', async () => {\n const manifest = await app.toManifest();\n const guide = manifest.server.agentGuide;\n\n expect(guide?.workflows.map((workflow) => workflow.id)).toEqual([\n 'review_tasks',\n 'capture_task',\n 'complete_task',\n ]);\n expect(\n guide?.workflows.flatMap((workflow) => workflow.steps.map((step) => step.capability.name)),\n ).toEqual(expect.arrayContaining(['list_today', 'set_priority', 'add_task', 'complete_task']));\n expect(\n guide?.workflows\n .find((workflow) => workflow.id === 'review_tasks')\n ?.steps.map((step) => step.capability.name),\n ).toContain('set_priority');\n expect(\n guide?.examples.every((example) =>\n guide.workflows.some((workflow) => workflow.id === example.workflow),\n ),\n ).toBe(true);\n expect(guide?.boundaries.some((boundary) => boundary.toLowerCase().includes('confirm'))).toBe(\n true,\n );\n });\n});\n" },
|
|
54
54
|
{ relPath: "examples/acme-tasks/vitest.config.ts", content: "import { defineConfig } from 'vitest/config';\n\n// Local config so `npm test` (vitest run) discovers this example's own tests instead of inheriting a\n// parent monorepo config's include globs.\nexport default defineConfig({\n test: { include: ['test/**/*.test.ts'] },\n});\n" },
|
|
55
|
-
{ relPath: "examples/customer-auth/README.md", content: "# Customer Auth - OIDC identity and customer-routed APIs\n\nThis curated example owns the customer/end-user authentication capability slot. It proves that a SaaS app\ncan protect an MCP endpoint with direct OIDC, retain role/scope-based tool authorization, and route ordinary\nreads and confirmed actions to the API origin selected by the verified customer's identity provider.\n\nIt also owns the customer-branded embedded-assistant presentation showcase. Direct MCP calls obtain the\nroute from the verified OIDC claim; embedded sessions obtain it from the authenticated customer backend's\nsession exchange. Both paths keep the URL outside tool/model/browser-visible state. 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\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/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\nFor an entirely application-owned React renderer, use the renderer-free hook. It creates no custom element\nand returns the AI SDK transcript plus the canonical client commands:\n\n```tsx\n'use client';\n\nimport { useEffect, useState } from 'react';\nimport { NoodleAppView } from '@noodleseed/assistant/react';\nimport { useNoodleAssistant } from '@noodleseed/assistant/react/client';\n\nexport function CustomerAssistant({\n principalKey,\n resolvedTheme,\n}: {\n principalKey: string;\n resolvedTheme: 'light' | 'dark';\n}) {\n const [draft, setDraft] = useState('');\n const { client, messages, status, error } = useNoodleAssistant({\n sessionEndpoint: '/api/noodle-assistant/session',\n principalKey,\n });\n const busy = status === 'submitted' || status === 'streaming';\n const settle = (operation: Promise<void>) => {\n void operation.catch(() => {\n // The hook exposes this same structured failure through `error`.\n });\n };\n\n return (\n <section aria-label=\"Assistant\" aria-busy={busy}>\n {messages.map((message) => (\n <article key={message.id} data-role={message.role}>\n {message.parts.map((part, index) => {\n if (part.type === 'text') return <p key={index}>{part.text}</p>;\n if (part.type === 'data-confirmation') {\n const review = part.data;\n return (\n <section key={review.id} aria-label=\"Review proposed action\">\n <h3>{review.title ?? 'Review proposed action'}</h3>\n {review.description ? <p>{review.description}</p> : null}\n <pre aria-label=\"Proposed action arguments\">\n {JSON.stringify(review.arguments ?? {}, null, 2)}\n </pre>\n <button\n disabled={busy || review.status !== 'pending'}\n onClick={() => settle(client.respond(review.id, { action: 'accept' }))}\n >\n Confirm\n </button>\n <button\n disabled={busy || review.status !== 'pending'}\n onClick={() => settle(client.respond(review.id, { action: 'decline' }))}\n >\n Don't proceed\n </button>\n </section>\n );\n }\n if (part.type === 'data-input-request') {\n const request = part.data;\n return (\n <section key={request.id} aria-label=\"Assistant needs input\">\n <p>{request.message}</p>\n <p>This renderer has not implemented the requested form.</p>\n <button\n disabled={busy || request.status !== 'pending'}\n onClick={() => settle(client.respond(request.id, { action: 'decline' }))}\n >\n Cancel request\n </button>\n </section>\n );\n }\n if (part.type === 'data-tool-result') {\n return (\n <pre key={part.data.id} aria-label={`${part.data.tool} result`}>\n {JSON.stringify(part.data.result, null, 2)}\n </pre>\n );\n }\n if (part.type === 'data-view') {\n return (\n <NoodleAppView\n key={`${part.data.id}:${part.data.resourceUri}`}\n client={client}\n view={part.data}\n theme={resolvedTheme}\n />\n );\n }\n return <p key={index}>Unsupported assistant content.</p>;\n })}\n </article>\n ))}\n {error ? <p role=\"alert\">{error.message}</p> : null}\n <form\n onSubmit={(event) => {\n event.preventDefault();\n const message = draft.trim();\n if (!message) return;\n setDraft('');\n settle(client.sendMessage(message));\n }}\n >\n <input\n aria-label=\"Message\"\n value={draft}\n onChange={(event) => setDraft(event.currentTarget.value)}\n />\n {busy ? (\n <button type=\"button\" onClick={() => client.abort()}>\n Stop\n </button>\n ) : (\n <button type=\"submit\">Send</button>\n )}\n </form>\n </section>\n );\n}\n```\n\n`principalKey` stays in the browser. Change it whenever the authenticated user or tenant changes; the hook\nthen aborts and clears the prior session and transcript. The sample fails closed on input requests until its\nfallback is replaced with a form generated from `requestedSchema`. A production renderer must show the\ncomplete confirmation review and both decisions. For `data-view`, map `resourceUri` or `tool` and the\nbounded/redacted result to a component already trusted by this application only when intentionally replacing\nthe linked App with a native UI. Otherwise use `<noodle-app-view>` or its React `NoodleAppView` adapter;\nJSON result data is not the App UI. The element's semantic lifecycle identity is the client plus `view.id`\nplus `view.resourceUri`, so parent payload/callback rerenders keep the iframe and only a different view,\ndisconnect, or App teardown request retires the bridge.\nApp views remain inline by default: the host advertises only inline presentation and rejects a widget's\nfullscreen request. A customer-owned renderer may opt in explicitly with `allowFullscreen` on\n`NoodleAppView` or `allow-fullscreen` on `<noodle-app-view>` only when fullscreen is part of its intended\nexperience. When fullscreen is accepted, the shared host adds a top-right exit control that returns the same\nmounted App to inline mode without discarding its state.\nNever inject `part.data.html`, assign it to `srcdoc`, fetch a `ui://` URI, or reproduce the bridge directly. Pages with a\nContent-Security-Policy must include the Noodle service origin in both `connect-src` and `frame-src`.\n\nBefore the production-equivalent host build, run the presence-only handoff check:\n\n```sh\nnoodle assistant embed --check --json\n```\n\nAdd application-owned delegated-exchange requirements with repeatable `--require-env NAME` flags. The JSON\nreports required and missing names, CSP status, and post-deploy probes without returning environment values\nor writing scaffold files. Map the names through the production secret manager, CI environment, and any\nsecret allowlist; regenerate existing framework-owned environment binding types before the build. Default\nDevtools/model exercises to synthetic data, and obtain approval before sending real connector data to an\nexternal model.\n\nAfter deployment, use the assistant doctor to verify the embed client, 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\nIf the application deliberately sends a first turn on mount, do not combine a persistent \"sent\" ref with a\nmount effect. React Strict Mode can abort that provisional request and then suppress the stable remount.\nSchedule the send after the provisional cleanup and settle its promise:\n\n```tsx\nuseEffect(() => {\n let active = true;\n queueMicrotask(() => {\n if (active) settle(client.sendMessage(initialMessage));\n });\n return () => {\n active = false;\n };\n}, [client, initialMessage]);\n```\n\nFor a chat-first custom host, raw `tool_started` supplies the direct call `id` and technical tool name. Map\nknown tools to concise application copy and use a neutral fallback. Reserve a stable `role=\"status\"` region\nfor thinking, tool activity, and the view skeleton; switch to the ready `<noodle-app-view>` (or React\n`NoodleAppView`) on `view_available` or to `role=\"alert\"` on error. Decorative skeleton shapes stay hidden from assistive technology, and shimmer\nor transition motion is disabled under `prefers-reduced-motion`.\n\nUse `${view.id}:${view.resourceUri}` as transport identity. Different call IDs are distinct invocations and\nmust not be deduplicated generically. If this application intentionally owns one current panel for a known\nresource, declare an application-owned slot for that resource and replace only that slot.\n\nOutside React, subscribe to the DOM-free client directly and use the isolated framework-neutral App host.\nIt exposes the same conversation as headless AI SDK `UIMessage` state, including typed confirmation, input,\ntool-result, and linked-view parts, without installing React:\n\n```html\n<noodle-app-view id=\"assistant-app-view\"></noodle-app-view>\n```\n\n```ts\nimport '@noodleseed/assistant/app-view';\nimport { createAssistantClient } from '@noodleseed/assistant/client';\n\nconst assistant = createAssistantClient({\n sessionEndpoint: '/api/noodle-assistant/session',\n});\nconst appView = document.querySelector('#assistant-app-view');\nif (!appView) throw new Error('Missing App view host');\nappView.client = assistant;\nappView.theme = resolvedTheme;\n\nassistant.subscribeChat((state) => {\n renderUIMessageState(state);\n for (const message of state.messages) {\n for (const part of message.parts) {\n if (part.type === 'data-confirmation' && part.data.status === 'pending') {\n renderConfirmation(part.data, (response) => assistant.respond(part.data.id, response));\n }\n if (part.type === 'data-view') appView.view = part.data;\n }\n }\n});\n```\n\n`theme=\"auto\"` follows the operating-system preference, not a SaaS-owned toggle. Pass the resolved\n`light`/`dark` theme to `NoodleAssistant` and `<noodle-app-view>`/`NoodleAppView`; updates reach mounted MCP Apps without a\nremount. CSS custom properties inherit through the host, and documented `--ns-assistant-*` variables remain\nthe final integration escape hatch. Server `branding` is shared by widgets and the assistant; there is no\nsecond branding declaration. Text streams progressively. Expired turns re-exchange and retry once;\nconfirmations never replay automatically.\n\nThe customer IdP must place the full tenant API base URL in `tenant.api_base_url`. For example, one verified\ncustomer may receive `https://customer-a.api.noodleseed.dev/v1` and another\n`https://customer-b.api.noodleseed.dev/v1`; both satisfy the declared suffix policy. Application code,\ndeployment variables, and connector arguments do not select the tenant route.\n\n`CUSTOMER_API_CLIENT_ID` and `CUSTOMER_API_CLIENT_SECRET` authenticate only the broker to the fixed exchange\nendpoint. They are not customer API bearer tokens. The exchange endpoint verifies the platform-signed\nsubject assertion and mints a short-lived token scoped to the signed-in user and route binding.\n\n## Deploy customer-protected to Noodle Seed Cloud\n\n```bash\nnoodle deploy examples/customer-auth/src/server.ts \\\n --org noodleseed \\\n --app customer-auth \\\n --env prod \\\n --access customers\n```\n\nEndpoint:\n\n```text\nhttps://cloud.noodleseed.dev/o/noodleseed/customer-auth/mcp\n```\n\n## MCP Primitives\n\n- Tool `list_my_organizations`: calls `GET /api/organizations` and returns the organizations the signed-in\n customer is a member of. Takes no arguments; the org set is scoped by the verified customer session.\n- Tool `list_org_apps`: calls `GET /api/organizations/{org_id}/apps` for one organization `org_id`.\n- Tool `archive_org_app`: after confirmation, calls\n `POST /api/organizations/{org_id}/apps/{app_id}/archive`.\n\n## Auth boundary\n\nNoodle Seed verifies the configured OIDC issuer and stable audience, then binds the exact transport-derived\nMCP resource before reading identity or routing claims. Public caller identity contains the user/role/scope\nprojection; the customer route remains private request state.\n\nConnector-backed tools ask the broker for a route-bound delegated credential; only the endpoint key and\nfingerprint enter broker cache/single-flight state or the assertion. The route claim and inbound MCP bearer\ntoken never reach tools, connectors, widgets, model output, or downstream systems. Confirmed actions keep\nthe same URL-blind binding only in private continuation state and reject acceptance-time drift.\n" },
|
|
55
|
+
{ relPath: "examples/customer-auth/README.md", content: "# Customer Auth - OIDC identity and customer-routed APIs\n\nThis curated example owns the customer/end-user authentication capability slot. It proves that a SaaS app\ncan protect an MCP endpoint with direct OIDC, retain role/scope-based tool authorization, and route ordinary\nreads and confirmed actions to the API origin selected by the verified customer's identity provider.\n\nIt also owns the customer-branded embedded-assistant presentation showcase. Direct MCP calls obtain the\nroute from the verified OIDC claim; embedded sessions obtain it from the authenticated customer backend's\nsession exchange. Both paths keep the URL outside tool/model/browser-visible state. 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" },
|
|
56
56
|
{ relPath: "examples/customer-auth/noodle.json", content: "{\n \"entrypoint\": \"src/server.ts\",\n \"name\": \"customer-auth\"\n}\n" },
|
|
57
57
|
{ relPath: "examples/customer-auth/package.json", content: "{\n \"name\": \"customer-auth\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {\n \"test\": \"vitest run\",\n \"validate\": \"noodle validate\",\n \"dev\": \"noodle dev\",\n \"deploy\": \"noodle deploy\"\n },\n \"devDependencies\": {\n \"@noodleseed/one\": \"latest\",\n \"vitest\": \"latest\"\n }\n}\n" },
|
|
58
|
-
{ 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 }),\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" },
|
|
59
|
-
{ relPath: "examples/customer-auth/test/server.test.ts", content: "import { describe, expect, it } from 'vitest';\nimport app from '../src/server.js';\n\ndescribe('customer-auth example', () => {\n it('exports a customer-authenticated, customer-branded embedded assistant', async () => {\n expect(typeof app.toManifest).toBe('function');\n const manifest = await app.toManifest();\n expect(manifest.server.assistant).toMatchObject({\n model: { kind: 'openai-compatible', apiKey: 'ASSISTANT_MODEL_API_KEY' },\n layout: { mode: 'floating' },\n presentation: {\n panel: { elevation: 'soft', border: 'subtle' },\n launcher: { icon: 'brand-mark', status: 'session', effect: 'pulse' },\n header: { mark: 'status', badge: { text: 'Workspace online', tone: 'success' } },\n },\n });\n expect(manifest.server.assistant?.allowedOrigins).toEqual([\n '${env.ASSISTANT_ORIGIN}',\n 'https://dev.noodleseed.com',\n 'http://localhost:3000',\n ]);\n expect(manifest.server.branding).toMatchObject({\n name: 'Noodle Seed Assistant',\n colorScheme: 'auto',\n });\n expect(manifest.server.auth).toEqual({\n kind: 'oidc',\n issuer: 'https://id.noodleseed.dev',\n audience: 'noodleseed-customer-auth-prod',\n claims: {\n id: 'sub',\n email: 'email',\n name: 'name',\n orgs: 'permissions.orgs',\n roles: 'permissions.roles',\n scopes: 'permissions.scopes',\n },\n routing: {\n endpoints: {\n customer_api: { claim: 'tenant.api_base_url' },\n },\n },\n });\n expect(manifest.server.interactions).toEqual({ confirmationFallback: 'host' });\n expect(manifest.server.agentGuide?.workflows.map((workflow) => workflow.id)).toEqual([\n 'find_organizations',\n 'review_organization_apps',\n 'archive_organization_app',\n ]);\n expect(\n manifest.server.agentGuide?.workflows.find(\n (workflow) => workflow.id === 'archive_organization_app',\n )?.steps,\n ).toEqual([\n { capability: { kind: 'tool', name: 'list_my_organizations' } },\n { capability: { kind: 'tool', name: 'list_org_apps' } },\n {\n capability: { kind: 'tool', name: 'archive_org_app' },\n guidance: 'Archive only the exact app the customer selected after confirmation.',\n },\n ]);\n const catalog = app.toConnectorCatalog();\n expect(catalog?.connectors).toHaveLength(1);\n expect(catalog?.connectors[0]?.http).toMatchObject({\n baseUrl: {\n kind: 'customerEndpoint',\n name: 'customer_api',\n policy: { allowedHttpsHostSuffixes: ['api.noodleseed.dev'] },\n },\n auth: {\n kind: 'delegatedTokenExchange',\n tokenUrl: 'https://id.noodleseed.dev/oauth/token',\n clientId: '${env.CUSTOMER_API_CLIENT_ID}',\n clientSecret: 'CUSTOMER_API_CLIENT_SECRET',\n },\n });\n expect(catalog?.connectors[0]?.operations).toMatchObject({\n list_org_apps: { type: 'read' },\n list_organizations: { type: 'read' },\n archive_org_app: { type: 'action', method: 'POST' },\n });\n expect(catalog?.connectors[0]?.http).not.toHaveProperty('allowedOrigins');\n expect(JSON.stringify({ manifest, catalog })).not.toContain('tenant-a.api.noodleseed.dev');\n expect(manifest.tools.find((tool) => tool.name === 'list_org_apps')?.authorization).toEqual({\n requiredScopes: ['org_apps:read'],\n allowedRoles: ['org_admin', 'org_member'],\n });\n expect(\n manifest.tools.find((tool) => tool.name === 'list_my_organizations')?.authorization,\n ).toBeUndefined();\n expect(manifest.tools.find((tool) => tool.name === 'archive_org_app')).toMatchObject({\n authorization: {\n requiredScopes: ['org_apps:write'],\n allowedRoles: ['org_admin'],\n },\n annotations: {\n readOnlyHint: false,\n destructiveHint: false,\n openWorldHint: true,\n confirm: true,\n },\n });\n });\n});\n" },
|
|
60
|
-
{ relPath: "examples/food-ordering/README.md", content: "# Food Ordering\n\n**Owns:** The flagship consumer ordering MCP App example: React view authoring, app-only helper tools,\ncaller-scoped cart state handles, invocation context, model-visible widget state/lifecycle, packaged image\nassets, portable structured elicitation, checkout handoff policy, host actions, CSP/permissions metadata,\nproduct-agent guidance, host-neutral distribution metadata, and widget preview coverage.\n\nFood Ordering is a generic, synthetic version of a live marketplace ordering app. It lets a user search\nstores, browse menus, customize an item, build a multi-line cart, review the order, and hand off checkout to\nan allowlisted example domain. It does not use real restaurant APIs, real checkout, customer credentials, or\nprivate customer data.\n\n## What It Shows\n\n| Capability | Example |\n| :--- | :--- |\n| Public entry tool | `open_ordering` returns structured fallback content and renders the React widget |\n| Product and distribution projections | `agentGuide` supplies grounded cross-capability guidance; `distribution` supplies listing, publisher, legal, image, and review facts separately from the runtime manifest |\n| App-only helper tools | `search_stores`, `load_menu`, `load_item`, `read_cart`, `sync_cart`, `prepare_checkout`; mutating widget-owned helpers use `confirm: false` (equivalent to omission) and execute directly because action hints alone never gate |\n| Durable cart state | `server(..., { state: { handles: { cart } }, use: { state } })` with caller scope and revision checks |\n| React app runtime kit | `@noodleseed/one/react` supplies app flow, shell/nav/view, async state, form, quantity, choice, and handoff primitives |\n| Multi-step widget flow | One React shell navigates stores, menu, item customization, cart, review, and handoff views through `useAppFlow` |\n| Invocation context | `server.context` sets locale/time-zone defaults, derives an ambient service area/date, and exposes optional host-supplied coordinates to tools and the reserved `noodle_context` MCP adapter; location is an untrusted convenience hint, never an authorization signal or a substitute for explicit input |\n| Structured missing input | `plan_order` uses `ctx.elicit` to collect a fulfilment method and date through embedded/headless forms, standard bidirectional elicitation, a linked MCP App form, or an exact structured conversational retry on stateless hosts |\n| Model-visible widget state | `useUpdateModelContext` publishes one cohesive replacement snapshot when supported; `useWidgetLifecycle` auto-publishes mounted/cancelled/dismissed and reports author-owned submitted milestones for future context (not host-presentation proof), while the user-triggered submit pairs `useSendFollowUpMessage` for an immediate reply |\n| Handoff | `handoff.allowedDomains` allows only `https://orders.example.com` checkout URLs |\n| Progressive enhancement | Non-Apps hosts still receive stores, featured items, and a readable fallback summary |\n| Fail-closed hydration | The React view treats only the unhydrated, pre-result `{}` envelope as pending; a hydrated empty success remains distinct. It surfaces `isError`, validates required records and identifiers, and withholds ordering actions from malformed results |\n| Upstream MCP composition | This synthetic example keeps its data local. For the canonical frozen-tool import, governed upstream invocation, response normalization, and Noodle-owned widget pattern, use the repository's `shopify-storefront` flagship rather than copying another composition surface here |\n\nThe example is intentionally richer than the generated starter, but each inline view still follows the\nsame default: one immediate purpose, one primary action, at most one subordinate action, and progressive\ndisclosure for the rest. Preview it at 280px before adding navigation or local CSS; loading, empty, stale,\nerror/retry, and success states must remain readable without nested vertical scrolling.\n\nLike the comprehensive default `noodle init my-app` scaffold, this flagship keeps the server feature-rich\nwhile making each individual widget view focused; server capability breadth and screen density are separate.\nThe compiled initial widget should normally remain under the 1 MiB performance recommendation; Noodle Seed's\nhard ceilings are 10 MiB per compiled widget and 20 MiB across one deployment. Run `noodle check` to see raw\nand gzip-estimated sizes. Deploy requests are gzip-compressed as one stream so repeated self-contained React\nruntime bytes deduplicate on the wire without a cross-tenant CDN. Keep menu images or large live datasets in assets/resources and app-only tools\nrather than embedding them into the initial HTML bundle.\n\n## Local Author Loop\n\n```sh\nnoodle validate\nnoodle test\nnoodle dev\n```\n\nThe same `server.ts` declares `distribution` metadata for host adapters. It references real packaged images\nand keeps listing copy, support/legal URLs, and positive/negative review scenarios outside the canonical App\nPackage and Runtime Artifact. Explicit OpenAI and Claude adapters project those facts with the generated\nproduct skill; installable plugin archives and directory-submission dossiers remain separate outputs.\n\nIn another terminal:\n\n```sh\nnoodle tools list\nnoodle tools call open_ordering --args '{\"customer\":\"Asha\",\"query\":\"noodles\"}'\nnoodle tools call summarize_ordering_options --args '{}'\n```\n\nWhen a developer finalizes visual feedback in the local Design experience, a coding agent can inspect the\nlatest project-local brief without a path or session id:\n\n```sh\nnoodle design inspect --latest --json\n```\n\nThe agent should locate the captured elements in this example's authored React source, preserve the listed\nbehavior and accessibility constraints, and verify every acceptance check before changing unrelated UI.\n\nFor Apps metadata conformance, start `noodle dev`, copy the loopback MCP endpoint, then run:\n\n```sh\nnpx @mcpjam/cli@latest apps conformance --url http://127.0.0.1:<port>/o/demo/food-ordering/mcp --quiet --format json\n```\n\n## Export an OpenAI plugin\n\nThis flagship includes the guided workflows, listing metadata, review cases, and image assets needed to test\nOpenAI export. See the public [product-agent guide](https://docs.noodleseed.dev/docs/guides/product-agent-guides#export-an-openai-package)\nfor the current package workflow and boundaries.\n\nAgainst its deployed MCP URL, generate the Food Ordering submission candidate with:\n\n```sh\nnoodle export plugin openai \\\n --state submission \\\n --mcp-url https://food-ordering.noodleseed.app/mcp \\\n --category \"Food & Drink\" \\\n --output food-ordering-openai.zip\n```\n\nExtract `food-ordering-openai.zip` before using the portal. Upload\n`submission/chatgpt-app-submission.json` to the Codex-assisted import field and\n`submission/food-ordering-skill.zip` to **With MCP → Skills**. The outer ZIP is the complete review kit and\nis not itself a valid skill upload; `submission/README.md` repeats the portal steps.\n\nAfter registering that same URL in ChatGPT developer mode, substitute its real technical ID to generate the\nFood Ordering local test package:\n\n```sh\nnoodle export plugin openai \\\n --state local \\\n --mcp-url https://food-ordering.noodleseed.app/mcp \\\n --category \"Food & Drink\" \\\n --registered-app-id plugin_asdk_app_0123456789abcdef0123456789abcdef \\\n --output food-ordering-openai-local.zip\n```\n\n## Export for Claude\n\nClaude Code plugin packaging and Anthropic Connector Directory review are separate outputs. Generate the\ninstallable plugin repository with:\n\n```sh\nnoodle export plugin claude \\\n --mcp-url https://food-ordering.noodleseed.app/mcp \\\n --output food-ordering-claude.zip\n```\n\nGenerate the credential-free operator dossier for the remote Connector Directory with:\n\n```sh\nnoodle export connector claude \\\n --mcp-url https://food-ordering.noodleseed.app/mcp \\\n --auth none \\\n --category \"Food & Drink\" \\\n --output food-ordering-anthropic-connector.zip\n```\n\nThe dossier is deliberately marked `portalUploadable: false`: it gathers the listing, tool annotations,\nuse cases, allowed-link candidates, test-account guidance, and MCP App screenshot evidence, but a human must\nverify ownership/compliance and enter the final answers in Anthropic's portal. The plugin ZIP does not\ncontain this dossier.\n\n## Client Setup\n\nUse the CLI to print the exact setup flow for your MCP client:\n\n```sh\nnoodle connect claude\nnoodle connect chatgpt\nnoodle connect inspector\n```\n\n## Deploy\n\n```sh\nnoodle deploy --org demo --app food-ordering --env prod --access owner-only\nnoodle open\n```\n\nThat one deploy command preflights the complete target, creates a missing app/environment, and verifies\nhosted readiness. If it is interrupted, rerun the same command to resume the unfinished operation without a\nduplicate deployment. Use `--access org-members` for an org-wide internal demo. This example has no\nconnector secrets and does not include tokens, caller-key mechanisms, or `.env.noodle` values.\n\n### Publish an immutable host archive\n\nOnly when this demo is intentionally being prepared for an external directory, deploy it with exact public\naccess and use the returned deployment ID to publish the matching local source:\n\n```sh\nnoodle deploy --org demo --app food-ordering --env prod --access public\nnoodle distributions publish <deployment-id> src/server.ts --target openai --category \"Food & Drink\"\nnoodle distributions list <deployment-id> --target openai\nnoodle distributions readiness <distribution-id> --status ready --note \"Archive and review evidence checked\"\nnoodle distributions release <distribution-id> --visibility private\nnoodle distributions grant <distribution-id> --expires-in 900\nnoodle distributions download <distribution-id> --output food-ordering-openai.zip\n```\n\nPublish fails if local `src/server.ts` no longer compiles to that deployment's package snapshot. Readiness is\nan explicit operator claim, the private release keeps anonymous discovery off, and the grant prints one\nsensitive exact-version reviewer URL. Record `noodle distributions review` only after a human observes the\nreal portal state. Public Noodle delivery, rollback, deprecation, and terminal revocation are separate explicit\nactions; none submits to a directory or claims acceptance.\n\n## Demo Assets\n\nThe packaged demo images live under `assets/`. The current app uses `assets/noodle-bowl.jpg` as the server\nbranding image. Its three distribution screenshots are real, response-only MCP App captures from Noodle\nDevtools at 2× device scale; each is 1640×970 PNG and has the producing user prompt next to its `asset(...)`\nreference in `server.ts`.\n\nImage sources:\n\n- `assets/noodle-bowl.jpg` — Unsplash photo\n [`IRv8V9Hb8gI`](https://unsplash.com/photos/IRv8V9Hb8gI), downloaded from Unsplash.\n- `assets/food-ordering-stores.png` — store-discovery state produced by “Help me build a noodle order for\n pickup.”\n- `assets/food-ordering-menu.png` — Harbor Noodles menu state produced by “Show me the Harbor Noodles\n menu.”\n- `assets/food-ordering-handoff.png` — checkout-handoff state produced by “Review my spicy miso bowl order\n before checkout.”\n\nThe Unsplash branding photo is free to use under the [Unsplash License](https://unsplash.com/license);\nattribution is not required, but the source note is kept here for provenance.\n" },
|
|
58
|
+
{ relPath: "examples/customer-auth/src/server.ts", content: "import {\n annotations,\n authenticatedWebsite,\n connector,\n customerAuth,\n customerEndpoint,\n embeddedAssistant,\n openAICompatible,\n secret,\n server,\n tool,\n variable,\n z,\n} from '@noodleseed/one';\n\nconst customerApi = customerEndpoint('customer_api', {\n allowedHttpsHostSuffixes: ['api.noodleseed.dev'],\n});\nconst assistantOrigin = variable('ASSISTANT_ORIGIN');\n\nconst noodleseedApi = connector('noodleseed_app_api')\n .version('1.0.0')\n .http({\n baseUrl: customerApi,\n auth: {\n kind: 'delegatedTokenExchange',\n tokenUrl: 'https://id.noodleseed.dev/oauth/token',\n clientId: variable('CUSTOMER_API_CLIENT_ID'),\n clientSecret: secret('CUSTOMER_API_CLIENT_SECRET'),\n scopes: ['organizations:read', 'org_apps:read', 'org_apps:write'],\n audience: 'noodleseed-customer-api',\n },\n operations: {\n list_org_apps: {\n type: 'read',\n method: 'GET',\n path: '/api/organizations/${args.org_id}/apps',\n query: ['skip', 'limit'],\n input: z.object({\n org_id: z.string(),\n skip: z.number().optional(),\n limit: z.number().optional(),\n }),\n output: z.object({ result: z.unknown().optional() }),\n response: {\n result: '${response}',\n },\n },\n list_organizations: {\n type: 'read',\n method: 'GET',\n path: '/api/organizations',\n output: z.object({ organizations: z.array(z.unknown()).optional() }),\n response: {\n organizations: '${response.organizations}',\n },\n },\n archive_org_app: {\n type: 'action',\n method: 'POST',\n path: '/api/organizations/${args.org_id}/apps/${args.app_id}/archive',\n input: z.object({\n org_id: z.string(),\n app_id: z.string(),\n }),\n output: z.object({ archived: z.boolean() }),\n response: {\n archived: '${response.archived}',\n },\n },\n },\n });\n\nconst CUSTOMER_AUTH_AGENT_GUIDE = {\n description:\n 'Use the signed-in customer context to discover organizations, review their Noodle Seed apps, and archive a selected app when authorized.',\n useWhen: [\n 'A signed-in customer asks which organizations or apps they can access.',\n 'An organization administrator asks to archive one selected app.',\n ],\n workflows: [\n {\n id: 'find_organizations',\n title: 'Find the customer organizations',\n intent: 'Ground later organization-scoped work in the verified customer membership.',\n steps: [\n {\n capability: { kind: 'tool', name: 'list_my_organizations' },\n guidance: 'Use an organization identifier returned by this read in later steps.',\n },\n ],\n },\n {\n id: 'review_organization_apps',\n title: 'Review apps in one organization',\n steps: [\n { capability: { kind: 'tool', name: 'list_my_organizations' } },\n {\n capability: { kind: 'tool', name: 'list_org_apps' },\n guidance: 'List apps only for an organization returned for the signed-in customer.',\n },\n ],\n },\n {\n id: 'archive_organization_app',\n title: 'Archive one organization app',\n steps: [\n { capability: { kind: 'tool', name: 'list_my_organizations' } },\n { capability: { kind: 'tool', name: 'list_org_apps' } },\n {\n capability: { kind: 'tool', name: 'archive_org_app' },\n guidance: 'Archive only the exact app the customer selected after confirmation.',\n },\n ],\n },\n ],\n boundaries: [\n 'Never infer an organization or app identifier that was not returned for the signed-in customer.',\n 'Never claim an app was archived until the confirmed action succeeds.',\n ],\n examples: [\n { prompt: 'Which organizations can I access?', workflow: 'find_organizations' },\n { prompt: 'Show me the apps in this organization.', workflow: 'review_organization_apps' },\n { prompt: 'Archive the app I selected.', workflow: 'archive_organization_app' },\n ],\n} as const;\n\nexport default server(\n 'noodleseed_customer_auth',\n {\n title: 'NoodleSeed.com Customer Auth',\n version: '1.0.0',\n branding: {\n name: 'Noodle Seed Assistant',\n accent: '#E85D24',\n surface: '#FFFFFF',\n surfaceDark: '#171310',\n colorScheme: 'auto',\n theme: {\n light: { accentText: '#FFFFFF', text: '#1C1714' },\n dark: { accent: '#FF8A4C', accentText: '#1C100A', text: '#FFF8F2' },\n },\n },\n use: { app_api: noodleseedApi },\n agentGuide: CUSTOMER_AUTH_AGENT_GUIDE,\n interactions: { confirmationFallback: 'host' },\n auth: customerAuth.oidc({\n issuer: 'https://id.noodleseed.dev',\n audience: 'noodleseed-customer-auth-prod',\n claims: {\n id: 'sub',\n email: 'email',\n name: 'name',\n orgs: 'permissions.orgs',\n roles: 'permissions.roles',\n scopes: 'permissions.scopes',\n },\n routing: {\n endpoints: {\n customer_api: { claim: 'tenant.api_base_url' },\n },\n },\n }),\n instructions:\n 'Direct/federated MCP OIDC demo. The customer IdP proves identity and privately selects the tenant API base URL, while the broker supplies delegated credentials and confirmed actions stay bound to the reviewed route.',\n assistant: embeddedAssistant({\n model: openAICompatible({\n baseUrl: variable('ASSISTANT_MODEL_BASE_URL'),\n model: variable('ASSISTANT_MODEL'),\n apiKey: secret('ASSISTANT_MODEL_API_KEY'),\n 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" },
|
|
59
|
+
{ relPath: "examples/customer-auth/test/server.test.ts", content: "import { describe, expect, it } from 'vitest';\nimport app from '../src/server.js';\n\ndescribe('customer-auth example', () => {\n it('exports a customer-authenticated, customer-branded embedded assistant', async () => {\n expect(typeof app.toManifest).toBe('function');\n const manifest = await app.toManifest();\n expect(manifest.server.assistant).toMatchObject({\n model: { kind: 'openai-compatible', apiKey: 'ASSISTANT_MODEL_API_KEY' },\n layout: { mode: 'floating' },\n presentation: {\n panel: { elevation: 'soft', border: 'subtle' },\n launcher: { icon: 'brand-mark', status: 'session', effect: 'pulse' },\n header: { mark: 'status', badge: { text: 'Workspace online', tone: 'success' } },\n },\n });\n expect(manifest.server.assistant?.allowedOrigins).toEqual([\n '${env.ASSISTANT_ORIGIN}',\n 'https://dev.noodleseed.com',\n 'http://localhost:3000',\n ]);\n expect(manifest.server.assistant?.sessionClaims).toEqual({\n accountTier: { exposeToModel: true },\n });\n expect(manifest.server.branding).toMatchObject({\n name: 'Noodle Seed Assistant',\n colorScheme: 'auto',\n });\n expect(manifest.server.auth).toEqual({\n kind: 'oidc',\n issuer: 'https://id.noodleseed.dev',\n audience: 'noodleseed-customer-auth-prod',\n claims: {\n id: 'sub',\n email: 'email',\n name: 'name',\n orgs: 'permissions.orgs',\n roles: 'permissions.roles',\n scopes: 'permissions.scopes',\n },\n routing: {\n endpoints: {\n customer_api: { claim: 'tenant.api_base_url' },\n },\n },\n });\n expect(manifest.server.interactions).toEqual({ confirmationFallback: 'host' });\n expect(manifest.server.agentGuide?.workflows.map((workflow) => workflow.id)).toEqual([\n 'find_organizations',\n 'review_organization_apps',\n 'archive_organization_app',\n ]);\n expect(\n manifest.server.agentGuide?.workflows.find(\n (workflow) => workflow.id === 'archive_organization_app',\n )?.steps,\n ).toEqual([\n { capability: { kind: 'tool', name: 'list_my_organizations' } },\n { capability: { kind: 'tool', name: 'list_org_apps' } },\n {\n capability: { kind: 'tool', name: 'archive_org_app' },\n guidance: 'Archive only the exact app the customer selected after confirmation.',\n },\n ]);\n const catalog = app.toConnectorCatalog();\n expect(catalog?.connectors).toHaveLength(1);\n expect(catalog?.connectors[0]?.http).toMatchObject({\n baseUrl: {\n kind: 'customerEndpoint',\n name: 'customer_api',\n policy: { allowedHttpsHostSuffixes: ['api.noodleseed.dev'] },\n },\n auth: {\n kind: 'delegatedTokenExchange',\n tokenUrl: 'https://id.noodleseed.dev/oauth/token',\n clientId: '${env.CUSTOMER_API_CLIENT_ID}',\n clientSecret: 'CUSTOMER_API_CLIENT_SECRET',\n },\n });\n expect(catalog?.connectors[0]?.operations).toMatchObject({\n list_org_apps: { type: 'read' },\n list_organizations: { type: 'read' },\n archive_org_app: { type: 'action', method: 'POST' },\n });\n expect(catalog?.connectors[0]?.http).not.toHaveProperty('allowedOrigins');\n expect(JSON.stringify({ manifest, catalog })).not.toContain('tenant-a.api.noodleseed.dev');\n expect(manifest.tools.find((tool) => tool.name === 'list_org_apps')?.authorization).toEqual({\n requiredScopes: ['org_apps:read'],\n allowedRoles: ['org_admin', 'org_member'],\n });\n expect(\n manifest.tools.find((tool) => tool.name === 'list_my_organizations')?.authorization,\n ).toBeUndefined();\n expect(manifest.tools.find((tool) => tool.name === 'archive_org_app')).toMatchObject({\n authorization: {\n requiredScopes: ['org_apps:write'],\n allowedRoles: ['org_admin'],\n },\n annotations: {\n readOnlyHint: false,\n destructiveHint: false,\n openWorldHint: true,\n confirm: true,\n },\n });\n expect(\n manifest.tools.find((tool) => tool.name === 'list_org_apps')?.annotations?.readOnlyHint,\n ).toBe(true);\n });\n});\n" },
|
|
60
|
+
{ relPath: "examples/customer-auth/vitest.config.ts", content: "import { defineConfig } from 'vitest/config';\n\n// Keep the flagship's own contract test executable instead of inheriting the monorepo package-only glob.\nexport default defineConfig({\n test: { include: ['test/**/*.test.ts'] },\n});\n" },
|
|
61
|
+
{ relPath: "examples/food-ordering/README.md", content: "# Food Ordering\n\n**Owns:** The flagship consumer ordering MCP App example: React view authoring, app-only helper tools,\ncaller-scoped cart state handles, invocation context, model-visible widget state/lifecycle, packaged image\nassets, portable structured elicitation, checkout handoff policy, host actions, CSP/permissions metadata,\nproduct-agent guidance, host-neutral distribution metadata, and widget preview coverage.\n\nFood Ordering is a generic, synthetic version of a live marketplace ordering app. It lets a user search\nstores, browse menus, customize an item, build a multi-line cart, review the order, and hand off checkout to\nan allowlisted example domain. It does not use real restaurant APIs, real checkout, customer credentials, or\nprivate customer data.\n\n## What It Shows\n\n| Capability | Example |\n| :--- | :--- |\n| Public entry tool | `open_ordering` returns structured fallback content and renders the React widget |\n| Product and distribution projections | `agentGuide` supplies grounded cross-capability guidance; `distribution` supplies listing, publisher, legal, image, and review facts separately from the runtime manifest |\n| App-only helper tools | `search_stores`, `load_menu`, `load_item`, `read_cart`, `sync_cart`, `prepare_checkout`; mutating widget-owned helpers use `confirm: false` (equivalent to omission) and execute directly because action hints alone never gate |\n| Durable cart state | `server(..., { state: { handles: { cart } }, use: { state } })` with caller scope, revision checks, and explicit ticket-bound adoption when an anonymous visitor authenticates |\n| React app runtime kit | `@noodleseed/one/react` supplies app flow, shell/nav/view, async state, form, quantity, choice, and handoff primitives |\n| Multi-step widget flow | One React shell navigates stores, menu, item customization, cart, review, and handoff views through `useAppFlow` |\n| Invocation context | `server.context` sets locale/time-zone defaults, derives an ambient service area/date, and exposes optional host-supplied coordinates to tools and the reserved `noodle_context` MCP adapter; location is an untrusted convenience hint, never an authorization signal or a substitute for explicit input |\n| Structured missing input | `plan_order` uses `ctx.elicit` to collect a fulfilment method and date through embedded/headless forms, standard bidirectional elicitation, a linked MCP App form, or an exact structured conversational retry on stateless hosts |\n| Model-visible widget state | `useUpdateModelContext` publishes one cohesive replacement snapshot when supported; `useWidgetLifecycle` auto-publishes mounted/cancelled/dismissed and reports author-owned submitted milestones for future context (not host-presentation proof), while the user-triggered submit pairs `useSendFollowUpMessage` for an immediate reply |\n| Handoff | `handoff.allowedDomains` allows only `https://orders.example.com` checkout URLs |\n| Progressive enhancement | Non-Apps hosts still receive stores, featured items, and a readable fallback summary |\n| Fail-closed hydration | The React view treats only the unhydrated, pre-result `{}` envelope as pending; a hydrated empty success remains distinct. It surfaces `isError`, validates required records and identifiers, and withholds ordering actions from malformed results |\n| Upstream MCP composition | This synthetic example keeps its data local. For the canonical frozen-tool import, governed upstream invocation, response normalization, and Noodle-owned widget pattern, use the repository's `shopify-storefront` flagship rather than copying another composition surface here |\n\nThe example is intentionally richer than the generated starter, but each inline view still follows the\nsame default: one immediate purpose, one primary action, at most one subordinate action, and progressive\ndisclosure for the rest. Preview it at 280px before adding navigation or local CSS; loading, empty, stale,\nerror/retry, and success states must remain readable without nested vertical scrolling.\n\nLike the comprehensive default `noodle init my-app` scaffold, this flagship keeps the server feature-rich\nwhile making each individual widget view focused; server capability breadth and screen density are separate.\nThe compiled initial widget should normally remain under the 1 MiB performance recommendation; Noodle Seed's\nhard ceilings are 10 MiB per compiled widget and 20 MiB across one deployment. Run `noodle check` to see raw\nand gzip-estimated sizes. Deploy requests are gzip-compressed as one stream so repeated self-contained React\nruntime bytes deduplicate on the wire without a cross-tenant CDN. Keep menu images or large live datasets in assets/resources and app-only tools\nrather than embedding them into the initial HTML bundle.\n\n## Local Author Loop\n\n```sh\nnoodle validate\nnoodle test\nnoodle dev\n```\n\nThe same `server.ts` declares `distribution` metadata for host adapters. It references real packaged images\nand keeps listing copy, support/legal URLs, and positive/negative review scenarios outside the canonical App\nPackage and Runtime Artifact. Explicit OpenAI and Claude adapters project those facts with the generated\nproduct skill; installable plugin archives and directory-submission dossiers remain separate outputs.\n\nIn another terminal:\n\n```sh\nnoodle tools list\nnoodle tools call open_ordering --args '{\"customer\":\"Asha\",\"query\":\"noodles\"}'\nnoodle tools call summarize_ordering_options --args '{}'\n```\n\nWhen a developer finalizes visual feedback in the local Design experience, a coding agent can inspect the\nlatest project-local brief without a path or session id:\n\n```sh\nnoodle design inspect --latest --json\n```\n\nThe agent should locate the captured elements in this example's authored React source, preserve the listed\nbehavior and accessibility constraints, and verify every acceptance check before changing unrelated UI.\n\nFor Apps metadata conformance, start `noodle dev`, copy the loopback MCP endpoint, then run:\n\n```sh\nnpx @mcpjam/cli@latest apps conformance --url http://127.0.0.1:<port>/o/demo/food-ordering/mcp --quiet --format json\n```\n\n## Export an OpenAI plugin\n\nThis flagship includes the guided workflows, listing metadata, review cases, and image assets needed to test\nOpenAI export. See the public [product-agent guide](https://docs.noodleseed.dev/docs/guides/product-agent-guides#export-an-openai-package)\nfor the current package workflow and boundaries.\n\nAgainst its deployed MCP URL, generate the Food Ordering submission candidate with:\n\n```sh\nnoodle export plugin openai \\\n --state submission \\\n --mcp-url https://food-ordering.noodleseed.app/mcp \\\n --category \"Food & Drink\" \\\n --output food-ordering-openai.zip\n```\n\nExtract `food-ordering-openai.zip` before using the portal. Upload\n`submission/chatgpt-app-submission.json` to the Codex-assisted import field and\n`submission/food-ordering-skill.zip` to **With MCP → Skills**. The outer ZIP is the complete review kit and\nis not itself a valid skill upload; `submission/README.md` repeats the portal steps.\n\nAfter registering that same URL in ChatGPT developer mode, substitute its real technical ID to generate the\nFood Ordering local test package:\n\n```sh\nnoodle export plugin openai \\\n --state local \\\n --mcp-url https://food-ordering.noodleseed.app/mcp \\\n --category \"Food & Drink\" \\\n --registered-app-id plugin_asdk_app_0123456789abcdef0123456789abcdef \\\n --output food-ordering-openai-local.zip\n```\n\n## Export for Claude\n\nClaude Code plugin packaging and Anthropic Connector Directory review are separate outputs. Generate the\ninstallable plugin repository with:\n\n```sh\nnoodle export plugin claude \\\n --mcp-url https://food-ordering.noodleseed.app/mcp \\\n --output food-ordering-claude.zip\n```\n\nGenerate the credential-free operator dossier for the remote Connector Directory with:\n\n```sh\nnoodle export connector claude \\\n --mcp-url https://food-ordering.noodleseed.app/mcp \\\n --auth none \\\n --category \"Food & Drink\" \\\n --output food-ordering-anthropic-connector.zip\n```\n\nThe dossier is deliberately marked `portalUploadable: false`: it gathers the listing, tool annotations,\nuse cases, allowed-link candidates, test-account guidance, and MCP App screenshot evidence, but a human must\nverify ownership/compliance and enter the final answers in Anthropic's portal. The plugin ZIP does not\ncontain this dossier.\n\n## Client Setup\n\nUse the CLI to print the exact setup flow for your MCP client:\n\n```sh\nnoodle connect claude\nnoodle connect chatgpt\nnoodle connect inspector\n```\n\n## Deploy\n\n```sh\nnoodle deploy --org demo --app food-ordering --env prod --access owner-only\nnoodle open\n```\n\nThat one deploy command preflights the complete target, creates a missing app/environment, and verifies\nhosted readiness. If it is interrupted, rerun the same command to resume the unfinished operation without a\nduplicate deployment. Use `--access org-members` for an org-wide internal demo. This example has no\nconnector secrets and does not include tokens, caller-key mechanisms, or `.env.noodle` values.\n\n### Publish an immutable host archive\n\nOnly when this demo is intentionally being prepared for an external directory, deploy it with exact public\naccess and use the returned deployment ID to publish the matching local source:\n\n```sh\nnoodle deploy --org demo --app food-ordering --env prod --access public\nnoodle distributions publish <deployment-id> src/server.ts --target openai --category \"Food & Drink\"\nnoodle distributions list <deployment-id> --target openai\nnoodle distributions readiness <distribution-id> --status ready --note \"Archive and review evidence checked\"\nnoodle distributions release <distribution-id> --visibility private\nnoodle distributions grant <distribution-id> --expires-in 900\nnoodle distributions download <distribution-id> --output food-ordering-openai.zip\n```\n\nPublish fails if local `src/server.ts` no longer compiles to that deployment's package snapshot. Readiness is\nan explicit operator claim, the private release keeps anonymous discovery off, and the grant prints one\nsensitive exact-version reviewer URL. Record `noodle distributions review` only after a human observes the\nreal portal state. Public Noodle delivery, rollback, deprecation, and terminal revocation are separate explicit\nactions; none submits to a directory or claims acceptance.\n\n## Demo Assets\n\nThe packaged demo images live under `assets/`. The current app uses `assets/noodle-bowl.jpg` as the server\nbranding image. Its three distribution screenshots are real, response-only MCP App captures from Noodle\nDevtools at 2× device scale; each is 1640×970 PNG and has the producing user prompt next to its `asset(...)`\nreference in `server.ts`.\n\nImage sources:\n\n- `assets/noodle-bowl.jpg` — Unsplash photo\n [`IRv8V9Hb8gI`](https://unsplash.com/photos/IRv8V9Hb8gI), downloaded from Unsplash.\n- `assets/food-ordering-stores.png` — store-discovery state produced by “Help me build a noodle order for\n pickup.”\n- `assets/food-ordering-menu.png` — Harbor Noodles menu state produced by “Show me the Harbor Noodles\n menu.”\n- `assets/food-ordering-handoff.png` — checkout-handoff state produced by “Review my spicy miso bowl order\n before checkout.”\n\nThe Unsplash branding photo is free to use under the [Unsplash License](https://unsplash.com/license);\nattribution is not required, but the source note is kept here for provenance.\n" },
|
|
61
62
|
{ relPath: "examples/food-ordering/noodle.json", content: "{\n \"entrypoint\": \"src/server.ts\",\n \"name\": \"food-ordering\",\n \"template\": \"widget\"\n}\n" },
|
|
62
63
|
{ relPath: "examples/food-ordering/package.json", content: "{\n \"name\": \"food-ordering\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {\n \"test\": \"vitest run test\",\n \"validate\": \"noodle validate\",\n \"dev\": \"noodle dev\",\n \"deploy\": \"noodle deploy\"\n },\n \"devDependencies\": {\n \"@vitejs/plugin-react\": \"latest\",\n \"@noodleseed/one\": \"latest\",\n \"@types/react\": \"latest\",\n \"@types/react-dom\": \"latest\",\n \"react\": \"latest\",\n \"react-dom\": \"latest\",\n \"vite\": \"latest\",\n \"vitest\": \"latest\"\n }\n}\n" },
|
|
63
64
|
{ relPath: "examples/food-ordering/src/agent-guide.ts", content: "import type { AgentGuideSource } from '@noodleseed/one';\n\n/** Product guidance for the model-visible ordering and planning workflows. */\nexport const FOOD_ORDERING_AGENT_GUIDE = {\n description:\n 'Use Food Ordering to browse synthetic local options, build a reviewable cart, and hand checkout to the user.',\n useWhen: [\n 'The user wants to browse nearby food or assemble an order.',\n 'The user wants a structured pickup or delivery plan before ordering.',\n ],\n workflows: [\n {\n id: 'build_order',\n title: 'Browse and build an order',\n steps: [\n {\n capability: { kind: 'tool', name: 'open_ordering' },\n guidance:\n 'Open the ordering app so the user can choose a store, review the cart, and control checkout handoff.',\n },\n ],\n },\n {\n id: 'summarize_options',\n title: 'Summarize available options',\n steps: [{ capability: { kind: 'tool', name: 'summarize_ordering_options' } }],\n },\n {\n id: 'plan_fulfilment',\n title: 'Plan pickup or delivery',\n steps: [\n {\n capability: { kind: 'tool', name: 'plan_order' },\n guidance: 'Collect the user’s fulfilment preference without claiming to place an order.',\n },\n ],\n },\n ],\n boundaries: [\n 'Treat every store, menu item, price, and service area in this example as synthetic.',\n 'Never claim checkout or payment completed; the final order happens only after the external handoff.',\n ],\n examples: [\n { prompt: 'Help me put together a noodle order.', workflow: 'build_order' },\n { prompt: 'What food options are available?', workflow: 'summarize_options' },\n { prompt: 'Plan a delivery for Friday.', workflow: 'plan_fulfilment' },\n ],\n} as const satisfies AgentGuideSource;\n" },
|
|
64
65
|
{ relPath: "examples/food-ordering/src/helpers.ts", content: "import type { ServerDefinition } from '@noodleseed/one';\n\nexport {\n ActionBar,\n AppShell,\n AsyncBoundary,\n ChoiceGroup,\n createViewStore,\n DataCard,\n DataList,\n Feedback,\n Field,\n Form,\n HandoffButton,\n QuantityStepper,\n ShellNav,\n StatusBadge,\n SubmitButton,\n View,\n ViewStack,\n} from '@noodleseed/one/react';\n\nimport { generateHelpers } from '@noodleseed/one/react';\n\nexport type AppType = ServerDefinition;\n\nexport const {\n useCallTool,\n useAppFlow,\n useHandoff,\n useLayout,\n useOpenExternal,\n useSendFollowUpMessage,\n useToolInfo,\n useUpdateModelContext,\n useViewState,\n useWidgetLifecycle,\n useWidgetReady,\n} = generateHelpers<AppType>();\n" },
|
|
65
|
-
{ relPath: "examples/food-ordering/src/server.ts", content: "import { annotations, asset, connector, resource, server, tool, z } from '@noodleseed/one';\nimport { FOOD_ORDERING_AGENT_GUIDE } from './agent-guide.js';\n\nconst heroImage = asset('assets/noodle-bowl.jpg');\nconst storesScreenshot = asset('assets/food-ordering-stores.png');\nconst menuScreenshot = asset('assets/food-ordering-menu.png');\nconst handoffScreenshot = asset('assets/food-ordering-handoff.png');\n\nconst state = connector('noodle_state')\n .version('1.0.0')\n .operation('read_state', {\n type: 'read',\n input: z.object({\n handle: z.string(),\n key: z.string().optional(),\n }),\n output: z.object({\n value: z.record(z.string(), z.unknown()),\n revision: z.number().int(),\n status: z.string(),\n }),\n })\n .operation('patch_state', {\n type: 'action',\n input: z.object({\n handle: z.string(),\n expectedRevision: z.number().int(),\n value: z.record(z.string(), z.unknown()),\n }),\n output: z.object({\n value: z.record(z.string(), z.unknown()),\n revision: z.number().int(),\n status: z.string(),\n }),\n });\n\nconst stores = [\n {\n id: 'harbor-noodles',\n name: 'Harbor Noodles',\n cuisine: 'Noodles',\n address: '18 Pier Lane',\n open: true,\n etaMinutes: 24,\n rating: 4.8,\n },\n {\n id: 'garden-wraps',\n name: 'Garden Wraps',\n cuisine: 'Vegetarian',\n address: '44 Market Street',\n open: true,\n etaMinutes: 18,\n rating: 4.6,\n },\n {\n id: 'midnight-tacos',\n name: 'Midnight Tacos',\n cuisine: 'Mexican',\n address: '7 Station Road',\n open: false,\n etaMinutes: 35,\n rating: 4.7,\n },\n] as const;\n\nconst menu = [\n {\n id: 'spicy_miso',\n storeId: 'harbor-noodles',\n category: 'Bowls',\n name: 'Spicy Miso Bowl',\n price: 16,\n description: 'Miso broth, wheat noodles, chili crisp, egg, and greens.',\n modifiers: ['extra_noodles', 'soft_egg', 'chili_crisp'],\n },\n {\n id: 'ginger_tofu',\n storeId: 'harbor-noodles',\n category: 'Bowls',\n name: 'Ginger Tofu Bowl',\n price: 15,\n description: 'Tofu, ginger broth, mushrooms, and scallions.',\n modifiers: ['extra_tofu', 'brown_rice', 'no_mushroom'],\n },\n {\n id: 'green_falafel',\n storeId: 'garden-wraps',\n category: 'Wraps',\n name: 'Green Falafel Wrap',\n price: 13,\n description: 'Falafel, herbs, pickles, tahini, and crisp vegetables.',\n modifiers: ['extra_tahini', 'add_fries', 'gluten_free_wrap'],\n },\n {\n id: 'sweet_potato',\n storeId: 'garden-wraps',\n category: 'Plates',\n name: 'Sweet Potato Plate',\n price: 14,\n description: 'Roasted sweet potato, grains, greens, and lemon yogurt.',\n modifiers: ['vegan_yogurt', 'extra_greens', 'hot_sauce'],\n },\n] as const;\n\nconst cartLine = z.object({\n itemId: z.string(),\n quantity: z.number().int().min(1),\n modifiers: z.array(z.string()).default([]),\n note: z.string().optional(),\n});\n\nconst cartInput = z.object({\n selectedStoreId: z.string().optional(),\n lines: z.array(cartLine).default([]),\n customer: z.string().default('Guest'),\n notes: z.string().optional(),\n subtotal: z.number().default(0),\n expectedRevision: z.number().int().min(0).default(0),\n});\n\nconst storeShape = z.object({\n id: z.string(),\n name: z.string(),\n cuisine: z.string(),\n address: z.string(),\n open: z.boolean(),\n etaMinutes: z.number(),\n rating: z.number(),\n});\n\nconst menuItemShape = z.object({\n id: z.string(),\n storeId: z.string(),\n category: z.string(),\n name: z.string(),\n price: z.number(),\n description: z.string(),\n // Nested lists count toward the output budget too: unbounded modifiers multiply by every item in a\n // menu payload, so the ceiling is declared here rather than only on the outer array.\n modifiers: z.array(z.string()).max(10),\n});\n\nconst cartOutput = z.object({\n selectedStoreId: z.string().optional(),\n lines: z.array(cartLine),\n customer: z.string(),\n notes: z.string().optional(),\n subtotal: z.number(),\n status: z.enum(['draft', 'review', 'handoff']),\n checkoutUrl: z.string().optional(),\n});\n\nconst cartStateSchema = z.object({\n selectedStoreId: z.string().optional(),\n lines: z.array(cartLine),\n customer: z.string(),\n notes: z.string().optional(),\n subtotal: z.number(),\n // `.default()`/`.optional()` state fields are optional on write: a cart save that omits\n // `status` still validates, and a fresh cart starts in `draft`.\n status: z.enum(['draft', 'review', 'handoff']).default('draft'),\n checkoutUrl: z.string().optional(),\n});\n\ntype CartLine = {\n readonly itemId: string;\n readonly quantity: number;\n readonly modifiers: readonly string[];\n readonly note?: string;\n};\n\ntype CartInput = {\n readonly selectedStoreId?: string;\n readonly lines: readonly CartLine[];\n readonly customer: string;\n readonly notes?: string;\n readonly subtotal: number;\n readonly expectedRevision: number;\n};\n\nconst readOnly = annotations.readOnly();\n// These writes are app-only controls inside the cart widget. The widget already presents the\n// reviewed state and explicit button; `confirm: false` documents direct execution and is equivalent\n// to omission because action/open-world hints alone never enable the confirmation gate.\nconst action = annotations.openAction({ destructive: false, confirm: false });\n\nfunction checkoutUrl(customer: string): string {\n return `https://orders.example.com/checkout?customer=${encodeURIComponent(customer)}`;\n}\n\nfunction cartValue(input: CartInput, status: 'draft' | 'review' | 'handoff') {\n return {\n selectedStoreId: input.selectedStoreId,\n lines: input.lines,\n customer: input.customer,\n notes: input.notes,\n subtotal: input.subtotal,\n status,\n ...(status === 'handoff' ? { checkoutUrl: 'https://orders.example.com/checkout' } : {}),\n };\n}\n\nexport default server(\n 'food_ordering',\n {\n title: 'Food Ordering',\n version: '1.0.0',\n agentGuide: FOOD_ORDERING_AGENT_GUIDE,\n distribution: {\n listing: {\n summary: 'Build a pickup noodle order.',\n description:\n 'Food Ordering is a synthetic MCP App that demonstrates store discovery, menu browsing, a caller-scoped cart, fulfilment planning, and explicit checkout handoff.',\n keywords: ['food', 'ordering', 'delivery'],\n },\n publisher: {\n name: 'Noodle Seed Examples',\n websiteUrl: 'https://noodleseed.com',\n },\n support: {\n documentationUrl: 'https://docs.noodleseed.com/examples/food-ordering',\n supportUrl: 'https://noodleseed.com/support',\n },\n legal: {\n privacyPolicyUrl: 'https://noodleseed.com/privacy',\n termsOfServiceUrl: 'https://noodleseed.com/terms',\n },\n assets: {\n icon: { source: heroImage, alt: 'Food Ordering noodle bowl' },\n screenshots: [\n {\n source: storesScreenshot,\n alt: 'Food Ordering MCP App showing nearby stores',\n prompt: 'Help me build a noodle order for pickup.',\n },\n {\n source: menuScreenshot,\n alt: 'Food Ordering MCP App showing the Harbor Noodles menu',\n prompt: 'Show me the Harbor Noodles menu.',\n },\n {\n source: handoffScreenshot,\n alt: 'Food Ordering MCP App reviewing a checkout handoff',\n prompt: 'Review my spicy miso bowl order before checkout.',\n },\n ],\n },\n review: {\n instructions:\n 'Use the synthetic menu and guest cart. No account or reviewer credential is required.',\n scenarios: [\n {\n id: 'build_order',\n prompt: 'Help me build a noodle order for pickup.',\n expected:\n 'The ordering app opens with stores and menu items; checkout remains a handoff.',\n shouldInvoke: true,\n tools: ['open_ordering'],\n },\n {\n id: 'browse_menu',\n prompt: 'Show me vegetarian menu options nearby.',\n expected: 'The app shows matching stores and bounded menu choices.',\n shouldInvoke: true,\n tools: ['search_stores', 'load_menu'],\n },\n {\n id: 'compare_options',\n prompt: 'Compare the quickest open food options for me.',\n expected: 'The app grounds its comparison in the synthetic store data.',\n shouldInvoke: true,\n tools: ['search_stores', 'summarize_ordering_options'],\n },\n {\n id: 'plan_pickup',\n prompt: 'Plan a pickup order for Friday.',\n expected: 'The app collects the missing fulfilment details before planning the order.',\n shouldInvoke: true,\n tools: ['plan_order'],\n },\n {\n id: 'review_checkout',\n prompt: 'Review my cart before I continue to checkout.',\n expected: 'The app shows the cart and keeps payment on the explicit external handoff.',\n shouldInvoke: true,\n tools: ['read_cart', 'prepare_checkout'],\n },\n // Negative scenarios are non-invocation cases, so they never declare expected tools.\n {\n id: 'unrelated_weather',\n prompt: 'Will it rain tomorrow?',\n expected: 'Food Ordering is not invoked.',\n shouldInvoke: false,\n },\n {\n id: 'unrelated_email',\n prompt: 'Draft an email to my manager.',\n expected: 'Food Ordering is not invoked.',\n shouldInvoke: false,\n },\n {\n id: 'unrelated_travel',\n prompt: 'Book me a flight to Lisbon.',\n expected: 'Food Ordering is not invoked.',\n shouldInvoke: false,\n },\n ],\n },\n },\n use: { state },\n context: {\n defaults: { locale: 'en-US', timeZone: 'America/New_York' },\n ambient: {\n output: z.object({ serviceArea: z.string(), orderingDate: z.string() }),\n fulfil: ({ context }) => ({\n serviceArea: 'Harbor District',\n orderingDate: context.temporal.localDate,\n }),\n },\n },\n state: {\n handles: {\n cart: {\n kind: 'cart',\n version: 'v1',\n scope: 'caller',\n ttlSeconds: 7200,\n schema: cartStateSchema,\n },\n },\n },\n branding: {\n name: 'Food Ordering',\n accent: '#0F8F5F',\n surface: '#F7F7F5',\n surfaceDark: '#111820',\n logo: {\n uri: heroImage,\n alt: 'Food Ordering noodle bowl',\n },\n radius: 'lg',\n density: 'comfortable',\n },\n handoff: {\n allowedDomains: ['https://orders.example.com'],\n },\n },\n [\n tool('open_ordering', {\n title: 'Open food ordering',\n description:\n 'Open a complete food-ordering widget with store discovery, menu browsing, cart review, and checkout handoff.',\n annotations: readOnly,\n modelVisibility: {\n latestMessageIncludesAny: [\n 'order',\n 'food',\n 'menu',\n 'restaurant',\n 'cart',\n 'pickup',\n 'delivery',\n 'checkout',\n ],\n oncePerSession: true,\n },\n input: z.object({\n query: z.string().optional(),\n customer: z.string().default('Guest'),\n }),\n // List outputs declare a ceiling so a host and the model both know the payload is bounded.\n // A recorded `fulfil` cannot slice, so the cap belongs on the shape; connector-backed lists take\n // a pagination input instead. `noodle check` reports `tool_design_output_bounds` without one.\n output: z.object({\n status: z.string(),\n customer: z.string(),\n stores: z.array(storeShape).max(20),\n featuredItems: z.array(menuItemShape).max(20),\n localDate: z.string(),\n serviceArea: z.string(),\n location: z.object({\n latitude: z.number().optional(),\n longitude: z.number().optional(),\n }),\n fallback: z.string(),\n }),\n fulfil: ({ input, context }) => ({\n status: 'Ready to build a food order.',\n customer: input.customer,\n stores,\n featuredItems: menu,\n localDate: context.temporal.localDate,\n serviceArea: context.ambient.serviceArea,\n location: {\n latitude: context.location.latitude.optional(),\n longitude: context.location.longitude.optional(),\n },\n fallback: 'Open stores: Harbor Noodles (Noodles), Garden Wraps (Vegetarian).',\n }),\n viewTitle: 'Food ordering',\n domain: 'https://orders.example.com',\n view: {\n component: 'ordering-flow',\n entry: './views/ordering-flow.tsx',\n },\n viewDescription:\n 'A complete consumer ordering surface with app-only helper tools, cart state, and checkout handoff.',\n csp: {\n connectDomains: ['https://orders.example.com'],\n resourceDomains: ['https://orders.example.com'],\n frameDomains: ['https://orders.example.com'],\n },\n permissions: { clipboardWrite: {} },\n }),\n tool('search_stores', {\n title: 'Search stores',\n visibility: ['app'],\n description: 'Filter synthetic restaurants for the ordering widget.',\n annotations: readOnly,\n input: z.object({\n query: z.string().optional(),\n openOnly: z.boolean().default(false),\n }),\n output: z.object({ stores: z.array(storeShape) }),\n fulfil: () => ({ stores }),\n }),\n tool('load_menu', {\n title: 'Load store menu',\n visibility: ['app'],\n description: 'Load synthetic menu categories and items for one store.',\n annotations: readOnly,\n input: z.object({ storeId: z.string() }),\n output: z.object({\n storeId: z.string(),\n stores: z.array(storeShape),\n items: z.array(menuItemShape),\n }),\n fulfil: ({ input }) => ({ storeId: input.storeId, stores, items: menu }),\n }),\n tool('load_item', {\n title: 'Load menu item',\n visibility: ['app'],\n description: 'Load item details and modifier options for the ordering widget.',\n annotations: readOnly,\n input: z.object({ itemId: z.string() }),\n output: z.object({ itemId: z.string(), items: z.array(menuItemShape) }),\n fulfil: ({ input }) => ({ itemId: input.itemId, items: menu }),\n }),\n tool('read_cart', {\n title: 'Read ordering cart',\n visibility: ['app'],\n description: 'Read the caller-scoped ordering cart state.',\n annotations: readOnly,\n input: z.object({}),\n output: z.object({\n value: z.unknown(),\n revision: z.number(),\n status: z.string(),\n }),\n fulfil: ({ connectors }) => {\n const state = connectors.state.readState({ handle: 'cart' });\n return { value: state.value, revision: state.revision, status: state.status };\n },\n }),\n tool('sync_cart', {\n title: 'Update ordering cart',\n visibility: ['app'],\n description: 'Patch the caller-scoped ordering cart with the widget cart mirror.',\n annotations: action,\n input: cartInput,\n output: z.object({\n cart: cartOutput,\n revision: z.number(),\n status: z.string(),\n }),\n fulfil: ({ input, connectors }) => {\n const cart = cartValue(input, 'draft');\n const state = connectors.state.patchState({\n handle: 'cart',\n expectedRevision: input.expectedRevision,\n value: cart,\n });\n return { cart, revision: state.revision, status: state.status };\n },\n }),\n tool('prepare_checkout', {\n title: 'Prepare checkout handoff',\n visibility: ['app'],\n description: 'Prepare the caller-scoped cart for checkout handoff.',\n annotations: action,\n input: cartInput,\n output: z.object({\n cart: cartOutput,\n revision: z.number(),\n checkoutUrl: z.string(),\n }),\n fulfil: ({ input, connectors }) => {\n const cart = cartValue(input, 'handoff');\n const state = connectors.state.patchState({\n handle: 'cart',\n expectedRevision: input.expectedRevision,\n value: cart,\n });\n return {\n cart,\n revision: state.revision,\n checkoutUrl: cart.checkoutUrl ?? checkoutUrl(input.customer),\n };\n },\n }),\n tool('summarize_ordering_options', {\n title: 'Summarize ordering options',\n description: 'Summarize available stores and menu examples without opening the widget.',\n annotations: readOnly,\n input: z.object({}),\n output: z.object({\n stores: z.array(storeShape).max(20),\n featuredItems: z.array(menuItemShape).max(20),\n }),\n fulfil: () => ({ stores, featuredItems: menu }),\n }),\n tool('plan_order', {\n title: 'Plan an order',\n description:\n 'Collect a fulfilment method and requested date as structured input, then return a reviewable order plan without placing an order.',\n annotations: readOnly,\n input: z.object({ customer: z.string().default('Guest') }),\n output: z.object({\n customer: z.string(),\n method: z.enum(['pickup', 'delivery']),\n requestedDate: z.string(),\n serviceArea: z.string(),\n }),\n fulfil: ({ input, context, elicit }) => {\n const preference = elicit({\n id: 'choose_fulfilment',\n message: 'How should we fulfil this order?',\n input: z.object({\n method: z.enum(['pickup', 'delivery']).describe('Fulfilment method'),\n requestedDate: z.string().describe('Requested date').meta({ format: 'date' }),\n }),\n });\n return {\n customer: input.customer,\n method: preference.method,\n requestedDate: preference.requestedDate,\n serviceArea: context.ambient.serviceArea,\n };\n },\n }),\n tool('show_capabilities', {\n title: 'Show capabilities',\n description: 'Return a concise summary for the standalone widget capability preview.',\n annotations: readOnly,\n input: z.object({}),\n output: z.object({ status: z.string(), note: z.string() }),\n fulfil: () => ({\n status: 'Food Ordering widget capabilities are ready.',\n note: 'Standalone preview covers React views, helper tools, cart state, handoff, CSP, and permissions.',\n }),\n viewName: 'capabilities_card',\n viewTitle: 'Food Ordering capabilities',\n viewDescription: 'Standalone widget resource for previewing the ordering capability surface.',\n domain: 'https://orders.example.com',\n view: { component: 'capabilities-card', entry: './views/capabilities-card.tsx' },\n csp: {\n connectDomains: ['https://orders.example.com'],\n resourceDomains: ['https://orders.example.com'],\n frameDomains: ['https://orders.example.com'],\n },\n permissions: { clipboardWrite: {} },\n }),\n resource('food_ordering_guide', {\n uri: 'docs://food-ordering',\n title: 'Food Ordering widget guide',\n description: 'Synthetic guide resource for the consumer ordering flagship.',\n mimeType: 'text/markdown',\n // Return the resource body directly; the runtime maps it into MCP `contents` using the\n // resource's own uri + mimeType. Do not return a `{ contents: [...] }` wrapper — that double-wraps.\n fulfil: () =>\n [\n '# Food Ordering Widget Guide',\n '',\n '- Demonstrates a multi-step ordering widget, app-only helper tools, typed cart state, and handoff.',\n '- Store, menu, and checkout data are synthetic and contain no customer credentials.',\n '- Checkout opens an allowlisted example URL; payment and final ordering remain out of scope.',\n ].join('\\n'),\n }),\n ],\n);\n" },
|
|
66
|
+
{ relPath: "examples/food-ordering/src/server.ts", content: "import { annotations, asset, connector, resource, server, tool, z } from '@noodleseed/one';\nimport { FOOD_ORDERING_AGENT_GUIDE } from './agent-guide.js';\n\nconst heroImage = asset('assets/noodle-bowl.jpg');\nconst storesScreenshot = asset('assets/food-ordering-stores.png');\nconst menuScreenshot = asset('assets/food-ordering-menu.png');\nconst handoffScreenshot = asset('assets/food-ordering-handoff.png');\n\nconst state = connector('noodle_state')\n .version('1.0.0')\n .operation('read_state', {\n type: 'read',\n input: z.object({\n handle: z.string(),\n key: z.string().optional(),\n }),\n output: z.object({\n value: z.record(z.string(), z.unknown()),\n revision: z.number().int(),\n status: z.string(),\n }),\n })\n .operation('patch_state', {\n type: 'action',\n input: z.object({\n handle: z.string(),\n expectedRevision: z.number().int(),\n value: z.record(z.string(), z.unknown()),\n }),\n output: z.object({\n value: z.record(z.string(), z.unknown()),\n revision: z.number().int(),\n status: z.string(),\n }),\n });\n\nconst stores = [\n {\n id: 'harbor-noodles',\n name: 'Harbor Noodles',\n cuisine: 'Noodles',\n address: '18 Pier Lane',\n open: true,\n etaMinutes: 24,\n rating: 4.8,\n },\n {\n id: 'garden-wraps',\n name: 'Garden Wraps',\n cuisine: 'Vegetarian',\n address: '44 Market Street',\n open: true,\n etaMinutes: 18,\n rating: 4.6,\n },\n {\n id: 'midnight-tacos',\n name: 'Midnight Tacos',\n cuisine: 'Mexican',\n address: '7 Station Road',\n open: false,\n etaMinutes: 35,\n rating: 4.7,\n },\n] as const;\n\nconst menu = [\n {\n id: 'spicy_miso',\n storeId: 'harbor-noodles',\n category: 'Bowls',\n name: 'Spicy Miso Bowl',\n price: 16,\n description: 'Miso broth, wheat noodles, chili crisp, egg, and greens.',\n modifiers: ['extra_noodles', 'soft_egg', 'chili_crisp'],\n },\n {\n id: 'ginger_tofu',\n storeId: 'harbor-noodles',\n category: 'Bowls',\n name: 'Ginger Tofu Bowl',\n price: 15,\n description: 'Tofu, ginger broth, mushrooms, and scallions.',\n modifiers: ['extra_tofu', 'brown_rice', 'no_mushroom'],\n },\n {\n id: 'green_falafel',\n storeId: 'garden-wraps',\n category: 'Wraps',\n name: 'Green Falafel Wrap',\n price: 13,\n description: 'Falafel, herbs, pickles, tahini, and crisp vegetables.',\n modifiers: ['extra_tahini', 'add_fries', 'gluten_free_wrap'],\n },\n {\n id: 'sweet_potato',\n storeId: 'garden-wraps',\n category: 'Plates',\n name: 'Sweet Potato Plate',\n price: 14,\n description: 'Roasted sweet potato, grains, greens, and lemon yogurt.',\n modifiers: ['vegan_yogurt', 'extra_greens', 'hot_sauce'],\n },\n] as const;\n\nconst cartLine = z.object({\n itemId: z.string(),\n quantity: z.number().int().min(1),\n modifiers: z.array(z.string()).default([]),\n note: z.string().optional(),\n});\n\nconst cartInput = z.object({\n selectedStoreId: z.string().optional(),\n lines: z.array(cartLine).default([]),\n customer: z.string().default('Guest'),\n notes: z.string().optional(),\n subtotal: z.number().default(0),\n expectedRevision: z.number().int().min(0).default(0),\n});\n\nconst storeShape = z.object({\n id: z.string(),\n name: z.string(),\n cuisine: z.string(),\n address: z.string(),\n open: z.boolean(),\n etaMinutes: z.number(),\n rating: z.number(),\n});\n\nconst menuItemShape = z.object({\n id: z.string(),\n storeId: z.string(),\n category: z.string(),\n name: z.string(),\n price: z.number(),\n description: z.string(),\n // Nested lists count toward the output budget too: unbounded modifiers multiply by every item in a\n // menu payload, so the ceiling is declared here rather than only on the outer array.\n modifiers: z.array(z.string()).max(10),\n});\n\nconst cartOutput = z.object({\n selectedStoreId: z.string().optional(),\n lines: z.array(cartLine),\n customer: z.string(),\n notes: z.string().optional(),\n subtotal: z.number(),\n status: z.enum(['draft', 'review', 'handoff']),\n checkoutUrl: z.string().optional(),\n});\n\nconst cartStateSchema = z.object({\n selectedStoreId: z.string().optional(),\n lines: z.array(cartLine),\n customer: z.string(),\n notes: z.string().optional(),\n subtotal: z.number(),\n // `.default()`/`.optional()` state fields are optional on write: a cart save that omits\n // `status` still validates, and a fresh cart starts in `draft`.\n status: z.enum(['draft', 'review', 'handoff']).default('draft'),\n checkoutUrl: z.string().optional(),\n});\n\ntype CartLine = {\n readonly itemId: string;\n readonly quantity: number;\n readonly modifiers: readonly string[];\n readonly note?: string;\n};\n\ntype CartInput = {\n readonly selectedStoreId?: string;\n readonly lines: readonly CartLine[];\n readonly customer: string;\n readonly notes?: string;\n readonly subtotal: number;\n readonly expectedRevision: number;\n};\n\nconst readOnly = annotations.readOnly();\n// These writes are app-only controls inside the cart widget. The widget already presents the\n// reviewed state and explicit button; `confirm: false` documents direct execution and is equivalent\n// to omission because action/open-world hints alone never enable the confirmation gate.\nconst action = annotations.openAction({ destructive: false, confirm: false });\n\nfunction checkoutUrl(customer: string): string {\n return `https://orders.example.com/checkout?customer=${encodeURIComponent(customer)}`;\n}\n\nfunction cartValue(input: CartInput, status: 'draft' | 'review' | 'handoff') {\n return {\n selectedStoreId: input.selectedStoreId,\n lines: input.lines,\n customer: input.customer,\n notes: input.notes,\n subtotal: input.subtotal,\n status,\n ...(status === 'handoff' ? { checkoutUrl: 'https://orders.example.com/checkout' } : {}),\n };\n}\n\nexport default server(\n 'food_ordering',\n {\n title: 'Food Ordering',\n version: '1.0.0',\n agentGuide: FOOD_ORDERING_AGENT_GUIDE,\n distribution: {\n listing: {\n summary: 'Build a pickup noodle order.',\n description:\n 'Food Ordering is a synthetic MCP App that demonstrates store discovery, menu browsing, a caller-scoped cart, fulfilment planning, and explicit checkout handoff.',\n keywords: ['food', 'ordering', 'delivery'],\n },\n publisher: {\n name: 'Noodle Seed Examples',\n websiteUrl: 'https://noodleseed.com',\n },\n support: {\n documentationUrl: 'https://docs.noodleseed.com/examples/food-ordering',\n supportUrl: 'https://noodleseed.com/support',\n },\n legal: {\n privacyPolicyUrl: 'https://noodleseed.com/privacy',\n termsOfServiceUrl: 'https://noodleseed.com/terms',\n },\n assets: {\n icon: { source: heroImage, alt: 'Food Ordering noodle bowl' },\n screenshots: [\n {\n source: storesScreenshot,\n alt: 'Food Ordering MCP App showing nearby stores',\n prompt: 'Help me build a noodle order for pickup.',\n },\n {\n source: menuScreenshot,\n alt: 'Food Ordering MCP App showing the Harbor Noodles menu',\n prompt: 'Show me the Harbor Noodles menu.',\n },\n {\n source: handoffScreenshot,\n alt: 'Food Ordering MCP App reviewing a checkout handoff',\n prompt: 'Review my spicy miso bowl order before checkout.',\n },\n ],\n },\n review: {\n instructions:\n 'Use the synthetic menu and guest cart. No account or reviewer credential is required.',\n scenarios: [\n {\n id: 'build_order',\n prompt: 'Help me build a noodle order for pickup.',\n expected:\n 'The ordering app opens with stores and menu items; checkout remains a handoff.',\n shouldInvoke: true,\n tools: ['open_ordering'],\n },\n {\n id: 'browse_menu',\n prompt: 'Show me vegetarian menu options nearby.',\n expected: 'The app shows matching stores and bounded menu choices.',\n shouldInvoke: true,\n tools: ['search_stores', 'load_menu'],\n },\n {\n id: 'compare_options',\n prompt: 'Compare the quickest open food options for me.',\n expected: 'The app grounds its comparison in the synthetic store data.',\n shouldInvoke: true,\n tools: ['search_stores', 'summarize_ordering_options'],\n },\n {\n id: 'plan_pickup',\n prompt: 'Plan a pickup order for Friday.',\n expected: 'The app collects the missing fulfilment details before planning the order.',\n shouldInvoke: true,\n tools: ['plan_order'],\n },\n {\n id: 'review_checkout',\n prompt: 'Review my cart before I continue to checkout.',\n expected: 'The app shows the cart and keeps payment on the explicit external handoff.',\n shouldInvoke: true,\n tools: ['read_cart', 'prepare_checkout'],\n },\n // Negative scenarios are non-invocation cases, so they never declare expected tools.\n {\n id: 'unrelated_weather',\n prompt: 'Will it rain tomorrow?',\n expected: 'Food Ordering is not invoked.',\n shouldInvoke: false,\n },\n {\n id: 'unrelated_email',\n prompt: 'Draft an email to my manager.',\n expected: 'Food Ordering is not invoked.',\n shouldInvoke: false,\n },\n {\n id: 'unrelated_travel',\n prompt: 'Book me a flight to Lisbon.',\n expected: 'Food Ordering is not invoked.',\n shouldInvoke: false,\n },\n ],\n },\n },\n use: { state },\n context: {\n defaults: { locale: 'en-US', timeZone: 'America/New_York' },\n ambient: {\n output: z.object({ serviceArea: z.string(), orderingDate: z.string() }),\n fulfil: ({ context }) => ({\n serviceArea: 'Harbor District',\n orderingDate: context.temporal.localDate,\n }),\n },\n },\n state: {\n handles: {\n cart: {\n kind: 'cart',\n version: 'v1',\n scope: 'caller',\n ttlSeconds: 7200,\n claimOnAuthentication: true,\n schema: cartStateSchema,\n },\n },\n },\n branding: {\n name: 'Food Ordering',\n accent: '#0F8F5F',\n surface: '#F7F7F5',\n surfaceDark: '#111820',\n logo: {\n uri: heroImage,\n alt: 'Food Ordering noodle bowl',\n },\n radius: 'lg',\n density: 'comfortable',\n },\n handoff: {\n allowedDomains: ['https://orders.example.com'],\n },\n },\n [\n tool('open_ordering', {\n title: 'Open food ordering',\n description:\n 'Open a complete food-ordering widget with store discovery, menu browsing, cart review, and checkout handoff.',\n annotations: readOnly,\n modelVisibility: {\n latestMessageIncludesAny: [\n 'order',\n 'food',\n 'menu',\n 'restaurant',\n 'cart',\n 'pickup',\n 'delivery',\n 'checkout',\n ],\n oncePerSession: true,\n },\n input: z.object({\n query: z.string().optional(),\n customer: z.string().default('Guest'),\n }),\n // List outputs declare a ceiling so a host and the model both know the payload is bounded.\n // A recorded `fulfil` cannot slice, so the cap belongs on the shape; connector-backed lists take\n // a pagination input instead. `noodle check` reports `tool_design_output_bounds` without one.\n output: z.object({\n status: z.string(),\n customer: z.string(),\n stores: z.array(storeShape).max(20),\n featuredItems: z.array(menuItemShape).max(20),\n localDate: z.string(),\n serviceArea: z.string(),\n location: z.object({\n latitude: z.number().optional(),\n longitude: z.number().optional(),\n }),\n fallback: z.string(),\n }),\n fulfil: ({ input, context }) => ({\n status: 'Ready to build a food order.',\n customer: input.customer,\n stores,\n featuredItems: menu,\n localDate: context.temporal.localDate,\n serviceArea: context.ambient.serviceArea,\n location: {\n latitude: context.location.latitude.optional(),\n longitude: context.location.longitude.optional(),\n },\n fallback: 'Open stores: Harbor Noodles (Noodles), Garden Wraps (Vegetarian).',\n }),\n viewTitle: 'Food ordering',\n domain: 'https://orders.example.com',\n view: {\n component: 'ordering-flow',\n entry: './views/ordering-flow.tsx',\n },\n viewDescription:\n 'A complete consumer ordering surface with app-only helper tools, cart state, and checkout handoff.',\n csp: {\n connectDomains: ['https://orders.example.com'],\n resourceDomains: ['https://orders.example.com'],\n frameDomains: ['https://orders.example.com'],\n },\n permissions: { clipboardWrite: {} },\n }),\n tool('search_stores', {\n title: 'Search stores',\n visibility: ['app'],\n description: 'Filter synthetic restaurants for the ordering widget.',\n annotations: readOnly,\n input: z.object({\n query: z.string().optional(),\n openOnly: z.boolean().default(false),\n }),\n output: z.object({ stores: z.array(storeShape) }),\n fulfil: () => ({ stores }),\n }),\n tool('load_menu', {\n title: 'Load store menu',\n visibility: ['app'],\n description: 'Load synthetic menu categories and items for one store.',\n annotations: readOnly,\n input: z.object({ storeId: z.string() }),\n output: z.object({\n storeId: z.string(),\n stores: z.array(storeShape),\n items: z.array(menuItemShape),\n }),\n fulfil: ({ input }) => ({ storeId: input.storeId, stores, items: menu }),\n }),\n tool('load_item', {\n title: 'Load menu item',\n visibility: ['app'],\n description: 'Load item details and modifier options for the ordering widget.',\n annotations: readOnly,\n input: z.object({ itemId: z.string() }),\n output: z.object({ itemId: z.string(), items: z.array(menuItemShape) }),\n fulfil: ({ input }) => ({ itemId: input.itemId, items: menu }),\n }),\n tool('read_cart', {\n title: 'Read ordering cart',\n visibility: ['app'],\n description: 'Read the caller-scoped ordering cart state.',\n annotations: readOnly,\n input: z.object({}),\n output: z.object({\n value: z.unknown(),\n revision: z.number(),\n status: z.string(),\n }),\n fulfil: ({ connectors }) => {\n const state = connectors.state.readState({ handle: 'cart' });\n return { value: state.value, revision: state.revision, status: state.status };\n },\n }),\n tool('sync_cart', {\n title: 'Update ordering cart',\n visibility: ['app'],\n description: 'Patch the caller-scoped ordering cart with the widget cart mirror.',\n annotations: action,\n input: cartInput,\n output: z.object({\n cart: cartOutput,\n revision: z.number(),\n status: z.string(),\n }),\n fulfil: ({ input, connectors }) => {\n const cart = cartValue(input, 'draft');\n const state = connectors.state.patchState({\n handle: 'cart',\n expectedRevision: input.expectedRevision,\n value: cart,\n });\n return { cart, revision: state.revision, status: state.status };\n },\n }),\n tool('prepare_checkout', {\n title: 'Prepare checkout handoff',\n visibility: ['app'],\n description: 'Prepare the caller-scoped cart for checkout handoff.',\n annotations: action,\n input: cartInput,\n output: z.object({\n cart: cartOutput,\n revision: z.number(),\n checkoutUrl: z.string(),\n }),\n fulfil: ({ input, connectors }) => {\n const cart = cartValue(input, 'handoff');\n const state = connectors.state.patchState({\n handle: 'cart',\n expectedRevision: input.expectedRevision,\n value: cart,\n });\n return {\n cart,\n revision: state.revision,\n checkoutUrl: cart.checkoutUrl ?? checkoutUrl(input.customer),\n };\n },\n }),\n tool('summarize_ordering_options', {\n title: 'Summarize ordering options',\n description: 'Summarize available stores and menu examples without opening the widget.',\n annotations: readOnly,\n input: z.object({}),\n output: z.object({\n stores: z.array(storeShape).max(20),\n featuredItems: z.array(menuItemShape).max(20),\n }),\n fulfil: () => ({ stores, featuredItems: menu }),\n }),\n tool('plan_order', {\n title: 'Plan an order',\n description:\n 'Collect a fulfilment method and requested date as structured input, then return a reviewable order plan without placing an order.',\n annotations: readOnly,\n input: z.object({ customer: z.string().default('Guest') }),\n output: z.object({\n customer: z.string(),\n method: z.enum(['pickup', 'delivery']),\n requestedDate: z.string(),\n serviceArea: z.string(),\n }),\n fulfil: ({ input, context, elicit }) => {\n const preference = elicit({\n id: 'choose_fulfilment',\n message: 'How should we fulfil this order?',\n input: z.object({\n method: z.enum(['pickup', 'delivery']).describe('Fulfilment method'),\n requestedDate: z.string().describe('Requested date').meta({ format: 'date' }),\n }),\n });\n return {\n customer: input.customer,\n method: preference.method,\n requestedDate: preference.requestedDate,\n serviceArea: context.ambient.serviceArea,\n };\n },\n }),\n tool('show_capabilities', {\n title: 'Show capabilities',\n description: 'Return a concise summary for the standalone widget capability preview.',\n annotations: readOnly,\n input: z.object({}),\n output: z.object({ status: z.string(), note: z.string() }),\n fulfil: () => ({\n status: 'Food Ordering widget capabilities are ready.',\n note: 'Standalone preview covers React views, helper tools, cart state, handoff, CSP, and permissions.',\n }),\n viewName: 'capabilities_card',\n viewTitle: 'Food Ordering capabilities',\n viewDescription: 'Standalone widget resource for previewing the ordering capability surface.',\n domain: 'https://orders.example.com',\n view: { component: 'capabilities-card', entry: './views/capabilities-card.tsx' },\n csp: {\n connectDomains: ['https://orders.example.com'],\n resourceDomains: ['https://orders.example.com'],\n frameDomains: ['https://orders.example.com'],\n },\n permissions: { clipboardWrite: {} },\n }),\n resource('food_ordering_guide', {\n uri: 'docs://food-ordering',\n title: 'Food Ordering widget guide',\n description: 'Synthetic guide resource for the consumer ordering flagship.',\n mimeType: 'text/markdown',\n // Return the resource body directly; the runtime maps it into MCP `contents` using the\n // resource's own uri + mimeType. Do not return a `{ contents: [...] }` wrapper — that double-wraps.\n fulfil: () =>\n [\n '# Food Ordering Widget Guide',\n '',\n '- Demonstrates a multi-step ordering widget, app-only helper tools, typed cart state, and handoff.',\n '- Store, menu, and checkout data are synthetic and contain no customer credentials.',\n '- Checkout opens an allowlisted example URL; payment and final ordering remain out of scope.',\n ].join('\\n'),\n }),\n ],\n);\n" },
|
|
66
67
|
{ relPath: "examples/food-ordering/src/views/capabilities-card.tsx", content: "import { useLayout, useOpenExternal, useSendFollowUpMessage, useToolInfo } from '../helpers.js';\nimport './widget-style.css';\n\nconst capabilities: readonly {\n readonly name: string;\n readonly description: string;\n}[] = [\n {\n name: 'React view resource',\n description: 'Compiled widget HTML with a hydrated React entrypoint.',\n },\n {\n name: 'Widget helper calls',\n description: 'App-only tools route through the same host-mediated path.',\n },\n {\n name: 'Durable cart state',\n description: 'Caller-scoped cart records use Noodle state handles with revisions.',\n },\n {\n name: 'External handoff',\n description: 'Checkout opens through declared allowlisted domains.',\n },\n {\n name: 'Model context',\n description: 'Relevant UI state is mirrored through data-llm.',\n },\n {\n name: 'CSP and permissions',\n description: 'Resource metadata declares network and clipboard needs.',\n },\n] as const;\n\nexport default function CapabilitiesCard() {\n const { theme } = useLayout();\n const toolInfo = useToolInfo('show_capabilities');\n const openExternal = useOpenExternal();\n const sendFollowUpMessage = useSendFollowUpMessage();\n const structured = toolInfo.structuredContent as\n | { readonly status?: string; readonly note?: string }\n | undefined;\n\n return (\n <main\n className={`nw-shell${theme === 'dark' ? ' dark' : ''}`}\n data-llm={`Food Ordering capabilities: ${capabilities.map((item) => item.name).join(', ')}`}\n >\n <section className=\"nw-card\">\n <header className=\"nw-header\">\n <span className=\"nw-icon\" aria-hidden=\"true\">\n <PuzzleIcon />\n </span>\n <div className=\"nw-title-block\">\n <h1 className=\"nw-title\">MCP capabilities</h1>\n <p className=\"nw-subtitle\">\n {structured?.status ?? 'Food Ordering widget capabilities are ready.'}\n </p>\n </div>\n <span className=\"nw-chip\">Connected</span>\n </header>\n <div className=\"nw-body\">\n <p className=\"nw-section-title\">Available features</p>\n <ul className=\"nw-feature-list\">\n {capabilities.map((capability) => (\n <li className=\"nw-feature\" key={capability.name}>\n <span className=\"nw-check\" aria-hidden=\"true\">\n <CheckIcon />\n </span>\n <span>\n <span className=\"nw-feature-name\">{capability.name}</span>\n <span className=\"nw-feature-desc\">{capability.description}</span>\n </span>\n </li>\n ))}\n </ul>\n <div className=\"nw-actions\">\n <button\n className=\"nw-button nw-button-primary\"\n type=\"button\"\n onClick={() =>\n sendFollowUpMessage({\n prompt: 'Summarize what the Food Ordering MCP App widget demonstrates.',\n })\n }\n >\n <SparkIcon />\n Ask assistant\n </button>\n <button\n className=\"nw-button\"\n type=\"button\"\n onClick={() => openExternal('https://example.com/noodle-widget-docs')}\n >\n <ExternalIcon />\n Open docs\n </button>\n </div>\n {structured?.note ? <p className=\"nw-note\">{structured.note}</p> : null}\n </div>\n <footer className=\"nw-footer\">\n <span className=\"nw-meta\">\n <ShieldIcon />\n Secure widget surface\n </span>\n <span>v1.0.0</span>\n </footer>\n </section>\n </main>\n );\n}\n\nfunction PuzzleIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M8 4h5v4h3a2 2 0 0 1 0 4h-3v3H9v-3H6a2 2 0 0 1 0-4h2V4Z\" />\n <path d=\"M13 15v5H4v-5\" />\n <path d=\"M13 20h7v-8\" />\n </svg>\n );\n}\n\nfunction CheckIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"m6 12 4 4 8-8\" />\n </svg>\n );\n}\n\nfunction SparkIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"m12 3 1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8L12 3Z\" />\n </svg>\n );\n}\n\nfunction ExternalIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M14 4h6v6\" />\n <path d=\"m20 4-9 9\" />\n <path d=\"M20 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5\" />\n </svg>\n );\n}\n\nfunction ShieldIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M12 3 5 6v5c0 4.4 2.8 8.3 7 10 4.2-1.7 7-5.6 7-10V6l-7-3Z\" />\n <path d=\"m9 12 2 2 4-4\" />\n </svg>\n );\n}\n" },
|
|
67
68
|
{ relPath: "examples/food-ordering/src/views/ordering-flow.tsx", content: "import { useEffect, useMemo, useState } from 'react';\nimport {\n ActionBar,\n AppShell,\n AsyncBoundary,\n ChoiceGroup,\n createViewStore,\n DataCard,\n DataList,\n Feedback,\n Field,\n Form,\n HandoffButton,\n QuantityStepper,\n ShellNav,\n StatusBadge,\n SubmitButton,\n useAppFlow,\n useCallTool,\n useHandoff,\n useLayout,\n useSendFollowUpMessage,\n useToolInfo,\n useUpdateModelContext,\n useViewState,\n useWidgetLifecycle,\n useWidgetReady,\n View,\n ViewStack,\n} from '../helpers.js';\nimport { isOrderingEntryResult, type MenuItem, type Store } from './ordering-result.js';\nimport './widget-style.css';\n\ntype ViewName = 'stores' | 'menu' | 'item' | 'cart' | 'review' | 'handoff';\n\ntype CartLine = {\n readonly itemId: string;\n readonly quantity: number;\n readonly modifiers: readonly string[];\n readonly note?: string;\n};\n\ntype CartState = {\n readonly selectedStoreId?: string;\n readonly lines: readonly CartLine[];\n readonly customer: string;\n readonly notes?: string;\n readonly subtotal: number;\n readonly status: 'draft' | 'review' | 'handoff';\n readonly checkoutUrl?: string;\n};\n\nconst zeroCart: CartState = {\n lines: [],\n customer: 'Guest',\n subtotal: 0,\n status: 'draft',\n};\n\nconst useCartStore = createViewStore<CartState>('cart', zeroCart);\nconst useCartRevisionStore = createViewStore('cart_revision', { value: 0 });\n\nfunction structured<T>(value: unknown): T | undefined {\n return (value as { structuredContent?: T } | undefined)?.structuredContent;\n}\n\nfunction currency(value: number): string {\n return `$${value.toFixed(2)}`;\n}\n\nfunction modifierLabel(value: string): string {\n return value\n .split('_')\n .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n .join(' ');\n}\n\nexport default function OrderingFlow() {\n const ready = useWidgetReady();\n const { displayMode, supports, theme } = useLayout();\n const toolInfo = useToolInfo('open_ordering');\n const isPending = !ready || Object.keys(toolInfo).length === 0;\n const entry = isOrderingEntryResult(toolInfo.structuredContent)\n ? toolInfo.structuredContent\n : undefined;\n const flow = useAppFlow<ViewName>({\n key: 'ordering_flow',\n initialView: 'stores',\n views: ['stores', 'menu', 'item', 'cart', 'review', 'handoff'],\n });\n const view = flow.activeView;\n const setView = flow.navigate;\n const handoff = useHandoff();\n const sendFollowUpMessage = useSendFollowUpMessage();\n const updateModelContext = useUpdateModelContext();\n const publishLifecycle = useWidgetLifecycle('ordering-flow');\n const searchStores = useCallTool('search_stores');\n const loadMenu = useCallTool('load_menu');\n const loadItem = useCallTool('load_item');\n const readCart = useCallTool('read_cart');\n const syncCart = useCallTool('sync_cart');\n const prepareCheckout = useCallTool('prepare_checkout');\n\n const [query, setQuery] = useViewState('query', '');\n const [customer, setCustomer] = useViewState('customer', entry?.customer ?? 'Guest');\n const [selectedStoreId, setSelectedStoreId] = useViewState<string | undefined>(\n 'selected_store',\n entry?.stores?.[0]?.id,\n );\n const [selectedItemId, setSelectedItemId] = useViewState<string | undefined>(\n 'selected_item',\n entry?.featuredItems?.[0]?.id,\n );\n const cartStore = useCartStore();\n const revisionStore = useCartRevisionStore((state) => state.value);\n const cart =\n cartStore.state.customer === 'Guest' && entry?.customer\n ? { ...cartStore.state, customer: entry.customer }\n : cartStore.state;\n const revision = revisionStore.selected;\n const setCart = cartStore.setState;\n const setRevision = (value: number) => revisionStore.setState({ value });\n const [quantity, setQuantity] = useState(1);\n const [selectedModifiers, setSelectedModifiers] = useState<readonly string[]>([]);\n const [lifecycle, setLifecycle] = useState<'active' | 'submitted'>('active');\n\n const storeData = structured<{ readonly stores?: readonly Store[] }>(searchStores.data);\n const menuData = structured<{\n readonly stores?: readonly Store[];\n readonly items?: readonly MenuItem[];\n }>(loadMenu.data);\n const itemData = structured<{ readonly items?: readonly MenuItem[] }>(loadItem.data);\n const checkoutData = structured<{ readonly checkoutUrl?: string; readonly cart?: CartState }>(\n prepareCheckout.data,\n );\n const stores = storeData?.stores ?? entry?.stores ?? [];\n const queryText = query.trim().toLowerCase();\n const displayedStores = stores.filter(\n (store) =>\n queryText.length === 0 ||\n store.name.toLowerCase().includes(queryText) ||\n store.cuisine.toLowerCase().includes(queryText),\n );\n const allItems = menuData?.items ?? itemData?.items ?? entry?.featuredItems ?? [];\n const items = allItems.filter((item) => item.storeId === selectedStoreId);\n const currentStore =\n stores.find((store) => store.id === selectedStoreId) ?? menuData?.stores?.[0] ?? stores[0];\n const currentItem = allItems.find((item) => item.id === selectedItemId) ?? items[0];\n const lineItems = useMemo(\n () =>\n cart.lines.map((line) => ({\n ...line,\n item: [...(entry?.featuredItems ?? []), ...allItems].find(\n (item) => item.id === line.itemId,\n ),\n })),\n [cart.lines, entry?.featuredItems, allItems],\n );\n const llmSummary = `${view} view for ${customer}; ${cart.lines.length} cart lines; subtotal ${currency(\n cart.subtotal,\n )}`;\n const canUpdateModelContext = supports?.modelContext === true;\n\n useEffect(() => {\n if (!entry || !canUpdateModelContext) return;\n // Each call replaces model context, so publish one cohesive snapshot of current surface state.\n void updateModelContext({\n content: [{ type: 'text', text: `Food ordering: ${llmSummary}; ${lifecycle}.` }],\n structuredContent: {\n widget: { name: 'ordering-flow', lifecycle },\n ordering: {\n view,\n customer,\n cartLines: cart.lines.length,\n subtotal: cart.subtotal,\n selectedStoreId: selectedStoreId ?? null,\n selectedItemId: selectedItemId ?? null,\n },\n },\n });\n }, [\n canUpdateModelContext,\n cart.lines.length,\n cart.subtotal,\n customer,\n entry,\n lifecycle,\n llmSummary,\n selectedItemId,\n selectedStoreId,\n updateModelContext,\n view,\n ]);\n\n async function chooseStore(store: Store) {\n setSelectedStoreId(store.id);\n setView('menu');\n const result = await loadMenu.callTool({ storeId: store.id });\n const loaded = structured<{ readonly items?: readonly MenuItem[] }>(result);\n setSelectedItemId(loaded?.items?.find((item) => item.storeId === store.id)?.id);\n }\n\n async function chooseItem(item: MenuItem) {\n setSelectedItemId(item.id);\n setSelectedModifiers([]);\n setQuantity(1);\n setView('item');\n await loadItem.callTool({ itemId: item.id });\n }\n\n async function persistCart(nextCart: CartState, nextView: ViewName) {\n const result = await syncCart.callTool({\n selectedStoreId: nextCart.selectedStoreId,\n lines: nextCart.lines,\n customer: nextCart.customer,\n notes: nextCart.notes,\n subtotal: nextCart.subtotal,\n expectedRevision: revision,\n });\n const synced = structured<{ readonly cart?: CartState; readonly revision?: number }>(result);\n setCart(synced?.cart ?? nextCart);\n setRevision(synced?.revision ?? revision + 1);\n setView(nextView);\n }\n\n async function addCurrentItem() {\n if (!currentItem) return;\n const nextLines = [\n ...cart.lines,\n {\n itemId: currentItem.id,\n quantity,\n modifiers: selectedModifiers,\n },\n ];\n await persistCart(\n {\n ...cart,\n selectedStoreId: currentStore?.id ?? selectedStoreId,\n lines: nextLines,\n customer,\n subtotal: nextLines.reduce(\n (sum, line) =>\n sum +\n ([...(entry?.featuredItems ?? []), ...allItems].find((item) => item.id === line.itemId)\n ?.price ?? 0) *\n line.quantity,\n 0,\n ),\n status: 'draft',\n },\n 'cart',\n );\n }\n\n async function prepareHandoff() {\n const result = await prepareCheckout.callTool({\n selectedStoreId: cart.selectedStoreId,\n lines: cart.lines,\n customer,\n notes: cart.notes,\n subtotal: cart.subtotal,\n expectedRevision: revision,\n });\n const prepared = structured<{\n readonly cart?: CartState;\n readonly revision?: number;\n readonly checkoutUrl?: string;\n }>(result);\n const preparedCart = prepared?.cart ?? cart;\n setCart(preparedCart);\n setRevision(prepared?.revision ?? revision + 1);\n setLifecycle('submitted');\n setView('handoff');\n if (canUpdateModelContext) {\n await publishLifecycle('submitted', {\n view: 'handoff',\n customer,\n cartLines: preparedCart.lines.length,\n subtotal: preparedCart.subtotal,\n selectedStoreId: preparedCart.selectedStoreId ?? null,\n selectedItemId: selectedItemId ?? null,\n });\n }\n if (supports?.followUpMessage) {\n await sendFollowUpMessage({\n prompt: 'Checkout is prepared. Confirm the cart summary and explain the final handoff.',\n });\n }\n }\n\n if (isPending) {\n return (\n <Feedback status=\"loading\" title=\"Loading\">\n Loading food ordering…\n </Feedback>\n );\n }\n if (toolInfo.isError) {\n return (\n <Feedback status=\"error\" title=\"Ordering unavailable\">\n Could not load food ordering.\n </Feedback>\n );\n }\n if (!entry) {\n return (\n <Feedback status=\"error\" title=\"Invalid ordering result\">\n Food ordering result was incomplete.\n </Feedback>\n );\n }\n\n return (\n <AppShell\n className={`nw-shell${theme === 'dark' ? ' dark' : ''}`}\n data-llm={llmSummary}\n title=\"Food Ordering\"\n subtitle={entry?.status ?? 'Find a store, build a cart, and hand off checkout.'}\n icon={<BowlIcon />}\n badge={displayMode === 'fullscreen' ? 'Fullscreen' : view}\n footer={\n <>\n <span className=\"nw-meta\">\n <BagIcon />\n {cart.lines.length} items\n </span>\n <span className=\"nw-meta\">Revision {revision}</span>\n </>\n }\n >\n <ShellNav\n activeView={view}\n aria-label=\"Ordering steps\"\n items={(['stores', 'menu', 'cart', 'review', 'handoff'] as const).map((step) => ({\n view: step,\n label: step,\n }))}\n onNavigate={setView}\n />\n <div className=\"nw-body\">\n <ViewStack flow={flow}>\n <View name=\"stores\">\n <Form onSubmit={() => void searchStores.callTool({ query, openOnly: false })}>\n <div className=\"nw-field-grid\">\n <Field className=\"nw-field\" label=\"Customer\">\n <input\n className=\"nw-input\"\n value={customer}\n onChange={(event) => setCustomer(event.currentTarget.value)}\n />\n </Field>\n <Field className=\"nw-field\" label=\"Search\">\n <input\n className=\"nw-input\"\n value={query}\n placeholder=\"Noodles\"\n onChange={(event) => setQuery(event.currentTarget.value)}\n />\n </Field>\n </div>\n <ActionBar className=\"nw-actions\">\n <SubmitButton\n type=\"submit\"\n className=\"nw-button nw-button-primary\"\n disabled={!ready}\n pending={searchStores.isPending}\n pendingLabel=\"Searching...\"\n >\n <SearchIcon />\n Search stores\n </SubmitButton>\n <SubmitButton\n type=\"button\"\n className=\"nw-button\"\n pending={readCart.isPending}\n pendingLabel=\"Loading...\"\n onClick={async () => {\n const result = await readCart.callTool({});\n const stored = structured<{\n readonly value?: CartState;\n readonly revision?: number;\n }>(result);\n if (stored?.value?.lines) setCart(stored.value);\n setRevision(stored?.revision ?? revision);\n }}\n >\n <RefreshIcon />\n Load cart\n </SubmitButton>\n </ActionBar>\n </Form>\n <AsyncBoundary\n state={searchStores}\n isEmpty={displayedStores.length === 0}\n empty=\"No matching stores\"\n >\n <StoreList stores={displayedStores} onChoose={chooseStore} />\n </AsyncBoundary>\n </View>\n\n <View name=\"menu\">\n <SectionHeader title={currentStore?.name ?? 'Menu'} detail={currentStore?.address} />\n <AsyncBoundary\n state={loadMenu}\n isEmpty={items.length === 0}\n empty=\"No menu items loaded\"\n >\n <ItemList items={items} onChoose={chooseItem} />\n </AsyncBoundary>\n </View>\n\n <View name=\"item\">\n {currentItem ? (\n <>\n <SectionHeader title={currentItem.name} detail={currentItem.description} />\n <div className=\"nw-field-grid\">\n <Field className=\"nw-field\" label=\"Quantity\">\n <QuantityStepper value={quantity} min={1} onChange={setQuantity} />\n </Field>\n <div className=\"nw-summary nw-summary-compact\">\n <div className=\"nw-summary-row\">\n <dt>Price</dt>\n <dd>{currency(currentItem.price)}</dd>\n </div>\n </div>\n </div>\n <ChoiceGroup\n className=\"nw-modifier-grid\"\n values={currentItem.modifiers}\n selected={selectedModifiers}\n onChange={setSelectedModifiers}\n labelFor={modifierLabel}\n />\n <ActionBar className=\"nw-actions\">\n <SubmitButton\n type=\"button\"\n className=\"nw-button nw-button-primary\"\n pending={syncCart.isPending}\n pendingLabel=\"Adding...\"\n onClick={addCurrentItem}\n >\n <BagIcon />\n Add to cart\n </SubmitButton>\n <button className=\"nw-button\" type=\"button\" onClick={() => setView('menu')}>\n Back to menu\n </button>\n </ActionBar>\n </>\n ) : null}\n </View>\n\n <View name=\"cart\">\n <SectionHeader\n title=\"Cart\"\n detail={`${cart.lines.length} line${cart.lines.length === 1 ? '' : 's'}`}\n />\n <CartSummary lineItems={lineItems} subtotal={cart.subtotal} />\n <Field className=\"nw-field\" label=\"Notes\">\n <input\n className=\"nw-input\"\n value={cart.notes ?? ''}\n placeholder=\"Utensils, pickup name, or allergies\"\n onChange={(event) => setCart({ ...cart, notes: event.currentTarget.value })}\n />\n </Field>\n <ActionBar className=\"nw-actions\">\n <SubmitButton\n type=\"button\"\n className=\"nw-button\"\n pending={syncCart.isPending}\n pendingLabel=\"Saving...\"\n onClick={() => persistCart({ ...cart, customer, status: 'draft' }, 'review')}\n >\n <SaveIcon />\n Save cart\n </SubmitButton>\n <SubmitButton\n type=\"button\"\n className=\"nw-button nw-button-primary\"\n disabled={cart.lines.length === 0}\n pending={prepareCheckout.isPending}\n pendingLabel=\"Preparing...\"\n onClick={prepareHandoff}\n >\n <ExternalIcon />\n Prepare checkout\n </SubmitButton>\n </ActionBar>\n </View>\n\n <View name=\"review\">\n <SectionHeader\n title=\"Review order\"\n detail={`${cart.lines.length} line${cart.lines.length === 1 ? '' : 's'}`}\n />\n <CartSummary lineItems={lineItems} subtotal={cart.subtotal} />\n <ActionBar className=\"nw-actions\">\n <button className=\"nw-button\" type=\"button\" onClick={() => setView('cart')}>\n Edit cart\n </button>\n <SubmitButton\n type=\"button\"\n className=\"nw-button nw-button-primary\"\n disabled={cart.lines.length === 0}\n pending={prepareCheckout.isPending}\n pendingLabel=\"Preparing...\"\n onClick={prepareHandoff}\n >\n <ExternalIcon />\n Prepare checkout\n </SubmitButton>\n </ActionBar>\n </View>\n\n <View name=\"handoff\">\n <SectionHeader\n title=\"Checkout handoff\"\n detail=\"Payment stays with the ordering site.\"\n />\n <CartSummary lineItems={lineItems} subtotal={cart.subtotal} />\n <p className=\"nw-note\">\n The MCP App prepared a synthetic checkout URL and will open only the allowlisted\n example domain.\n </p>\n <ActionBar className=\"nw-actions\">\n <HandoffButton\n handoff={handoff}\n target={checkoutData?.checkoutUrl ?? cart.checkoutUrl ?? ''}\n className=\"nw-button nw-button-primary\"\n type=\"button\"\n pendingLabel=\"Opening...\"\n >\n <ExternalIcon />\n Continue checkout\n </HandoffButton>\n <button\n className=\"nw-button\"\n type=\"button\"\n onClick={() =>\n sendFollowUpMessage({\n prompt: `Summarize my food order for ${customer} before checkout.`,\n })\n }\n >\n <SparkIcon />\n Ask assistant\n </button>\n </ActionBar>\n </View>\n </ViewStack>\n </div>\n </AppShell>\n );\n}\n\nfunction SectionHeader({ title, detail }: { readonly title: string; readonly detail?: string }) {\n return (\n <div className=\"nw-section-head\">\n <p className=\"nw-section-title\">{title}</p>\n {detail ? <p className=\"nw-section-detail\">{detail}</p> : null}\n </div>\n );\n}\n\nfunction StoreList({\n stores,\n onChoose,\n}: {\n readonly stores: readonly Store[];\n readonly onChoose: (store: Store) => void;\n}) {\n return (\n <DataList className=\"nw-menu-list\">\n {stores.map((store) => (\n <DataCard\n as=\"button\"\n className=\"nw-menu-item\"\n key={store.id}\n type=\"button\"\n onClick={() => onChoose(store)}\n >\n <span>\n <span className=\"nw-menu-name\">{store.name}</span>\n <span className=\"nw-menu-desc\">\n {store.cuisine} · {store.address}\n </span>\n </span>\n <StatusBadge className=\"nw-status\" tone={store.open ? 'success' : 'neutral'}>\n {store.open ? `${store.etaMinutes}m` : 'Closed'}\n </StatusBadge>\n </DataCard>\n ))}\n </DataList>\n );\n}\n\nfunction ItemList({\n items,\n onChoose,\n}: {\n readonly items: readonly MenuItem[];\n readonly onChoose: (item: MenuItem) => void;\n}) {\n return (\n <DataList className=\"nw-menu-list\">\n {items.map((item) => (\n <DataCard\n as=\"button\"\n className=\"nw-menu-item\"\n key={item.id}\n type=\"button\"\n onClick={() => onChoose(item)}\n >\n <span>\n <span className=\"nw-menu-name\">{item.name}</span>\n <span className=\"nw-menu-desc\">{item.description}</span>\n </span>\n <span className=\"nw-price\">{currency(item.price)}</span>\n </DataCard>\n ))}\n </DataList>\n );\n}\n\nfunction CartSummary({\n lineItems,\n subtotal,\n}: {\n readonly lineItems: readonly (CartLine & { readonly item?: MenuItem })[];\n readonly subtotal: number;\n}) {\n return (\n <dl className=\"nw-summary\">\n {lineItems.length === 0 ? (\n <div className=\"nw-summary-row\">\n <dt>No items yet</dt>\n <dd>{currency(0)}</dd>\n </div>\n ) : (\n lineItems.map((line, index) => (\n <div className=\"nw-summary-row\" key={`${line.itemId}-${index}`}>\n <dt>\n {line.quantity} x {line.item?.name ?? line.itemId}\n {line.modifiers.length > 0 ? (\n <span className=\"nw-line-note\">{line.modifiers.map(modifierLabel).join(', ')}</span>\n ) : null}\n </dt>\n <dd>{currency((line.item?.price ?? 0) * line.quantity)}</dd>\n </div>\n ))\n )}\n <div className=\"nw-summary-row nw-total\">\n <dt>Subtotal</dt>\n <dd>{currency(subtotal)}</dd>\n </div>\n </dl>\n );\n}\n\nfunction BowlIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M4 11h16a7 7 0 0 1-7 7h-2a7 7 0 0 1-7-7Z\" />\n <path d=\"M7 21h10\" />\n <path d=\"M8 6c0-1 1-1 1-2\" />\n <path d=\"M12 6c0-1 1-1 1-2\" />\n <path d=\"M16 6c0-1 1-1 1-2\" />\n </svg>\n );\n}\n\nfunction SearchIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <circle cx=\"11\" cy=\"11\" r=\"7\" />\n <path d=\"m20 20-4-4\" />\n </svg>\n );\n}\n\nfunction RefreshIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M20 12a8 8 0 1 1-2.3-5.6\" />\n <path d=\"M20 4v5h-5\" />\n </svg>\n );\n}\n\nfunction BagIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M6 8h12l-1 12H7L6 8Z\" />\n <path d=\"M9 8a3 3 0 0 1 6 0\" />\n </svg>\n );\n}\n\nfunction SaveIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M5 4h12l2 2v14H5V4Z\" />\n <path d=\"M8 4v6h8\" />\n <path d=\"M8 20v-5h8v5\" />\n </svg>\n );\n}\n\nfunction ExternalIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M14 4h6v6\" />\n <path d=\"m20 4-9 9\" />\n <path d=\"M20 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5\" />\n </svg>\n );\n}\n\nfunction SparkIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"m12 3 1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8L12 3Z\" />\n </svg>\n );\n}\n" },
|
|
68
69
|
{ relPath: "examples/food-ordering/src/views/ordering-result.ts", content: "export type Store = {\n readonly id: string;\n readonly name: string;\n readonly cuisine: string;\n readonly address: string;\n readonly open: boolean;\n readonly etaMinutes: number;\n readonly rating: number;\n};\n\nexport type MenuItem = {\n readonly id: string;\n readonly storeId: string;\n readonly category: string;\n readonly name: string;\n readonly price: number;\n readonly description: string;\n readonly modifiers: readonly string[];\n};\n\nexport type OrderingEntryResult = {\n readonly customer: string;\n readonly stores: readonly Store[];\n readonly featuredItems: readonly MenuItem[];\n readonly status: string;\n};\n\nfunction nonEmptyString(value: unknown): value is string {\n return typeof value === 'string' && value.trim().length > 0;\n}\n\nfunction isStore(value: unknown): value is Store {\n if (value === null || typeof value !== 'object') return false;\n const store = value as Partial<Store>;\n return (\n nonEmptyString(store.id) &&\n nonEmptyString(store.name) &&\n nonEmptyString(store.cuisine) &&\n nonEmptyString(store.address) &&\n typeof store.open === 'boolean' &&\n typeof store.etaMinutes === 'number' &&\n Number.isFinite(store.etaMinutes) &&\n typeof store.rating === 'number' &&\n Number.isFinite(store.rating)\n );\n}\n\nfunction isMenuItem(value: unknown): value is MenuItem {\n if (value === null || typeof value !== 'object') return false;\n const item = value as Partial<MenuItem>;\n return (\n nonEmptyString(item.id) &&\n nonEmptyString(item.storeId) &&\n nonEmptyString(item.category) &&\n nonEmptyString(item.name) &&\n typeof item.price === 'number' &&\n Number.isFinite(item.price) &&\n nonEmptyString(item.description) &&\n Array.isArray(item.modifiers) &&\n item.modifiers.every(nonEmptyString)\n );\n}\n\nexport function isOrderingEntryResult(value: unknown): value is OrderingEntryResult {\n if (value === null || typeof value !== 'object') return false;\n const entry = value as Partial<OrderingEntryResult>;\n if (\n !nonEmptyString(entry.customer) ||\n !nonEmptyString(entry.status) ||\n !Array.isArray(entry.stores) ||\n !entry.stores.every(isStore) ||\n !Array.isArray(entry.featuredItems) ||\n !entry.featuredItems.every(isMenuItem)\n ) {\n return false;\n }\n const storeIds = new Set(entry.stores.map((store) => store.id));\n const itemIds = new Set(entry.featuredItems.map((item) => item.id));\n return (\n storeIds.size === entry.stores.length &&\n itemIds.size === entry.featuredItems.length &&\n entry.featuredItems.every((item) => storeIds.has(item.storeId))\n );\n}\n" },
|
|
@@ -26,6 +26,18 @@ const MANAGED_FILE_BY_TARGET = {
|
|
|
26
26
|
codex: 'AGENTS.md',
|
|
27
27
|
'claude-code': 'CLAUDE.md',
|
|
28
28
|
};
|
|
29
|
+
const SKILL_REFERENCE_BY_PATH = new Map(SKILL_REFERENCES.map((reference) => [reference.relPath, reference]));
|
|
30
|
+
function behaviorSkillReferenceFiles(skill) {
|
|
31
|
+
return [skill.primaryReference, ...skill.supportingReferences].map((path) => {
|
|
32
|
+
const reference = SKILL_REFERENCE_BY_PATH.get(path);
|
|
33
|
+
if (reference === undefined)
|
|
34
|
+
throw new Error(`missing behavior skill reference: ${path}`);
|
|
35
|
+
return {
|
|
36
|
+
relPath: reference.relPath,
|
|
37
|
+
render: () => reference.render(),
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
}
|
|
29
41
|
export const SKILL_REGISTRY = defineSkillRegistry([
|
|
30
42
|
{
|
|
31
43
|
name: 'noodle-seed',
|
|
@@ -51,7 +63,7 @@ export const SKILL_REGISTRY = defineSkillRegistry([
|
|
|
51
63
|
version: AGENT_KIT_VERSION,
|
|
52
64
|
supportedHosts: TARGETS,
|
|
53
65
|
renderBody: (target) => renderBehaviorSkillBody(skill, target),
|
|
54
|
-
files:
|
|
66
|
+
files: behaviorSkillReferenceFiles(skill),
|
|
55
67
|
})),
|
|
56
68
|
{ ...EXECUTING_NOODLE_PLANS_SKILL, version: AGENT_KIT_VERSION },
|
|
57
69
|
]);
|