@noodleseed/one 0.186.0 → 0.187.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/node_modules/@noodle-borg/admission-limits/dist/in-memory-counter-store.d.ts +12 -4
  2. package/node_modules/@noodle-borg/admission-limits/dist/in-memory-counter-store.js +12 -4
  3. package/node_modules/@noodle-borg/admission-limits/dist/portable.d.ts +1 -0
  4. package/node_modules/@noodle-borg/admission-limits/dist/portable.js +1 -0
  5. package/node_modules/@noodle-borg/admission-limits/dist/public-mcp-admission.d.ts +18 -0
  6. package/node_modules/@noodle-borg/admission-limits/dist/public-mcp-admission.js +16 -0
  7. package/node_modules/@noodle-borg/agent-kit/dist/curated/error-fixes.js +1 -0
  8. package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +2 -2
  9. package/node_modules/@noodle-borg/agent-kit/dist/generated/surface.js +1 -0
  10. package/node_modules/@noodle-borg/agent-kit/dist/skill-authoring-auth-ref.js +1 -0
  11. package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
  12. package/node_modules/@noodle-borg/authoring/dist/server.d.ts +6 -0
  13. package/node_modules/@noodle-borg/authoring/dist/server.js +1 -0
  14. package/node_modules/@noodle-borg/compiler/dist/artifact/catalog-emission.js +3 -0
  15. package/node_modules/@noodle-borg/compiler/dist/artifact/types.d.ts +5 -0
  16. package/node_modules/@noodle-borg/compiler/dist/artifact/version.d.ts +2 -1
  17. package/node_modules/@noodle-borg/compiler/dist/artifact/version.js +2 -1
  18. package/node_modules/@noodle-borg/compiler/dist/compile.js +2 -1
  19. package/node_modules/@noodle-borg/compiler/dist/errors.d.ts +1 -1
  20. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.d.ts +2 -0
  21. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.js +3 -0
  22. package/node_modules/@noodle-borg/compiler/dist/manifest/website-projection.d.ts +2 -0
  23. package/node_modules/@noodle-borg/compiler/dist/manifest/website-projection.js +12 -0
  24. package/node_modules/@noodle-borg/module/dist/contract.d.ts +1 -1
  25. package/node_modules/@noodle-borg/service/dist/admission.js +47 -10
  26. package/node_modules/@noodle-borg/service/dist/business-information/managed-solution-executable.js +2 -0
  27. package/node_modules/@noodle-borg/service/dist/serve-local.js +2 -0
  28. package/node_modules/@noodle-borg/service/dist/service.js +8 -3
  29. package/node_modules/@noodle-borg/service/package.json +1 -1
  30. package/node_modules/@noodle-borg/transport-http/dist/handler.d.ts +5 -0
  31. package/node_modules/@noodle-borg/transport-http/dist/handler.js +2 -0
  32. package/node_modules/@noodle-borg/transport-http/dist/responses.js +7 -5
  33. package/node_modules/@noodle-borg/transport-http/dist/serve-request.js +3 -1
  34. package/node_modules/@noodle-borg/transport-http/dist/tool-authorization.js +25 -3
  35. package/package.json +2 -2
@@ -2,13 +2,21 @@ import { type AtomicCounterAttemptOutcome, type AtomicDailyCounterStore, type Co
2
2
  /**
3
3
  * Process-local counters for local development and tests.
4
4
  *
5
- * `durable` is false, and that is load-bearing rather than informational: the public mint route refuses
6
- * to serve a surface backed by this store, because a ceiling that resets on every restart and is not
7
- * shared across instances would look like protection while providing none.
5
+ * `durable` is false by default, and that is load-bearing rather than informational: public surfaces
6
+ * (the website mint route, anonymous MCP tool calls) refuse to serve on this store, because a ceiling
7
+ * that resets on every restart and is not shared across instances would look like protection while
8
+ * providing none.
9
+ *
10
+ * `singleProcess: true` is the caller's claim that exactly one process serves and that losing today's
11
+ * counts on restart is acceptable, which makes the counters truthfully durable for that host. Only the
12
+ * local author loop (`noodle dev`) and tests may make that claim.
8
13
  */
9
14
  export declare class InMemoryDailyCounterStore implements DailyCounterStore, AtomicDailyCounterStore {
10
15
  #private;
11
- readonly durable = false;
16
+ readonly durable: boolean;
17
+ constructor(options?: {
18
+ readonly singleProcess?: true;
19
+ });
12
20
  consume(request: CounterRequest, now: Date): Promise<CounterOutcome>;
13
21
  consumeAll(requests: readonly CounterRequest[], now: Date): Promise<boolean>;
14
22
  consumeAllOnce(requests: readonly CounterRequest[], attempt: {
@@ -2,14 +2,22 @@ import { counterRow, dayKey, nextReset, retentionCutoff, } from './counter-store
2
2
  /**
3
3
  * Process-local counters for local development and tests.
4
4
  *
5
- * `durable` is false, and that is load-bearing rather than informational: the public mint route refuses
6
- * to serve a surface backed by this store, because a ceiling that resets on every restart and is not
7
- * shared across instances would look like protection while providing none.
5
+ * `durable` is false by default, and that is load-bearing rather than informational: public surfaces
6
+ * (the website mint route, anonymous MCP tool calls) refuse to serve on this store, because a ceiling
7
+ * that resets on every restart and is not shared across instances would look like protection while
8
+ * providing none.
9
+ *
10
+ * `singleProcess: true` is the caller's claim that exactly one process serves and that losing today's
11
+ * counts on restart is acceptable, which makes the counters truthfully durable for that host. Only the
12
+ * local author loop (`noodle dev`) and tests may make that claim.
8
13
  */
9
14
  export class InMemoryDailyCounterStore {
10
- durable = false;
15
+ durable;
11
16
  #used = new Map();
12
17
  #receipts = new Map();
18
+ constructor(options = {}) {
19
+ this.durable = options.singleProcess === true;
20
+ }
13
21
  async consume(request, now) {
14
22
  const row = counterRow(request, now);
15
23
  const key = `${row.day}:${row.key}`;
@@ -4,6 +4,7 @@ export * from './counter-store.js';
4
4
  export * from './envelope.js';
5
5
  export * from './in-memory-counter-store.js';
6
6
  export * from './public-admission-assertion.js';
7
+ export * from './public-mcp-admission.js';
7
8
  export * from './public-record-admission.js';
8
9
  export * from './request-attribution.js';
9
10
  export * from './visitor-bucket.js';
@@ -4,6 +4,7 @@ export * from './counter-store.js';
4
4
  export * from './envelope.js';
5
5
  export * from './in-memory-counter-store.js';
6
6
  export * from './public-admission-assertion.js';
7
+ export * from './public-mcp-admission.js';
7
8
  export * from './public-record-admission.js';
8
9
  export * from './request-attribution.js';
9
10
  export * from './visitor-bucket.js';
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Anonymous callers of a public or mixed MCP app (ADR 0201 §7 applied to MCP).
3
+ *
4
+ * The daily ceiling is the solvency bound: it caps what anonymous traffic can spend of the business's
5
+ * monthly allowance without depending on client-address attribution, which host traffic (ChatGPT,
6
+ * Claude) makes meaningless. 10,000 a day is about a third of the Free plan's monthly pool per day, so
7
+ * signed-in callers always keep the rest.
8
+ */
9
+ export declare const PUBLIC_MCP_ADMISSION_DEFAULTS: Readonly<{
10
+ anonymousToolCallsPerDay: 10000;
11
+ }>;
12
+ /** One budget per app environment, so a redeploy mid-day does not hand anonymous traffic a fresh one. */
13
+ export declare function anonymousMcpCounterKey(tenant: {
14
+ readonly org: string;
15
+ readonly app: string;
16
+ readonly env: string;
17
+ }): string;
18
+ //# sourceMappingURL=public-mcp-admission.d.ts.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Anonymous callers of a public or mixed MCP app (ADR 0201 §7 applied to MCP).
3
+ *
4
+ * The daily ceiling is the solvency bound: it caps what anonymous traffic can spend of the business's
5
+ * monthly allowance without depending on client-address attribution, which host traffic (ChatGPT,
6
+ * Claude) makes meaningless. 10,000 a day is about a third of the Free plan's monthly pool per day, so
7
+ * signed-in callers always keep the rest.
8
+ */
9
+ export const PUBLIC_MCP_ADMISSION_DEFAULTS = Object.freeze({
10
+ anonymousToolCallsPerDay: 10_000,
11
+ });
12
+ /** One budget per app environment, so a redeploy mid-day does not hand anonymous traffic a fresh one. */
13
+ export function anonymousMcpCounterKey(tenant) {
14
+ return `mcp-anonymous:${tenant.org}/${tenant.app}/${tenant.env}`;
15
+ }
16
+ //# sourceMappingURL=public-mcp-admission.js.map
@@ -70,6 +70,7 @@ export const ERROR_FIXES = {
70
70
  assistant_capability_unknown: 'Name a tool, resource, or prompt this server declares in `embeddedAssistant({ capabilities })`, or remove the entry; capabilities reference declared components, not arbitrary names.',
71
71
  assistant_public_user_reference: 'Remove the `${user...}` reference from this tool or drop it from the public assistant `capabilities`; a public website visitor is anonymous, so there is no signed-in user to read.',
72
72
  assistant_public_effect_unconfirmed: 'Add `annotations.readOnly()` if this projected tool only reads, or `{ confirm: true }` if it causes an external effect; a public assistant never reaches an unconfirmed side effect.',
73
+ tool_anonymous_requires_identity: "Remove `anonymous: 'allowed'` from this tool, or stop reading `${user...}` and drop `authorization`; an anonymous MCP caller has no identity for the tool to use.",
73
74
  interaction_action_invalid: 'Point `interaction.action` at a declared tool that sets `annotations.openAction({ confirm: true })` (or another `{ confirm: true }` action) and is not read-only; a collect interaction prepares exactly one confirmed action.',
74
75
  interaction_field_unknown: 'Use an input property of the action tool as the field `key`; the collect block names fields by the action’s Zod input keys, never by labels.',
75
76
  interaction_field_control_mismatch: 'Match each control to the action input type: `consent` needs `z.literal(true)`, `select` needs `z.enum([...])` of strings, and `text`/`textarea`/`email`/`phone`/`url` need a string schema.',
@@ -23,10 +23,10 @@ export const BUNDLED_EXAMPLE_FILES = [
23
23
  { relPath: "examples/acme-bistro/noodle.json", content: "{\n \"entrypoint\": \"src/server.ts\",\n \"name\": \"acme-bistro\",\n \"template\": \"widget\"\n}\n" },
24
24
  { relPath: "examples/acme-bistro/package.json", content: "{\n \"name\": \"acme-bistro\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {\n \"test\": \"vitest run\",\n \"validate\": \"noodle validate\",\n \"dev\": \"noodle dev\",\n \"deploy\": \"noodle deploy\"\n },\n \"devDependencies\": {\n \"@vitejs/plugin-react\": \"latest\",\n \"@noodleseed/one\": \"latest\",\n \"react\": \"latest\",\n \"react-dom\": \"latest\",\n \"vite\": \"latest\",\n \"vitest\": \"latest\"\n }\n}\n" },
25
25
  { relPath: "examples/acme-bistro/src/helpers.ts", content: "import type { ServerDefinition } from '@noodleseed/one';\nimport { generateHelpers } from '@noodleseed/one/react';\n\nexport type AppType = ServerDefinition;\n\nexport const { useBranding, useCallTool, useLayout, useOpenExternal, useToolInfo, useViewState } =\n generateHelpers<AppType>();\n" },
26
- { relPath: "examples/acme-bistro/src/server.ts", content: "import {\n annotations,\n managedCollection,\n noodlePlatform,\n server,\n tool,\n variable,\n z,\n} from '@noodleseed/one';\n\n// Fictional ordering app. Payment uses a checkout link; native guest requests require installation.\n\nconst menu = [\n { id: 'stone_pizza', name: 'Stone-baked Margherita', price: 14, kind: 'Mains' },\n { id: 'roast_bowl', name: 'Harvest Roast Bowl', price: 13, kind: 'Mains' },\n { id: 'house_salad', name: 'House Garden Salad', price: 11, kind: 'Starters' },\n { id: 'lemon_tart', name: 'Lemon Tart', price: 8, kind: 'Desserts' },\n { id: 'sparkling', name: 'Sparkling Water', price: 4, kind: 'Drinks' },\n] as const;\n\nconst itemId = z.enum(['stone_pizza', 'roast_bowl', 'house_salad', 'lemon_tart', 'sparkling']);\n\n// Operators configure the notice without redeploying.\nconst guestExperience = variable('GUEST_EXPERIENCE', {\n schema: z.object({ notice: z.string().max(500) }),\n default: { notice: 'Ask us about dietary requirements before placing your order.' },\n portal: { label: 'Guest experience', group: 'Guest experience' },\n requiredFor: ['show_menu'],\n});\n\nconst menuItemOutput = z.object({\n id: z.string(),\n name: z.string(),\n price: z.number(),\n kind: z.string(),\n});\n\nconst guestRequestRecord = z.object({\n locationReference: z.string().min(1).max(120),\n requestType: z.enum(['reservation_help', 'accessibility', 'dietary_question', 'other']),\n summary: z.string().min(1).max(1000),\n guestReference: z.string().max(120).optional(),\n progress: z.enum(['received', 'reviewing', 'handled']).default('received'),\n});\n\nconst guestRequests = managedCollection('guest_requests', {\n title: 'Guest requests',\n description: 'Guest service requests that restaurant staff can review and resolve.',\n schemaVersion: 1,\n // No source is declared, so Noodle is authoritative. Outside-owned records would name an exact\n // connector scan contract here; changes in that outside system would remain ordinary tools.\n record: guestRequestRecord,\n management: { notes: true },\n publicFields: ['locationReference', 'requestType', 'summary'],\n editableFields: ['locationReference', 'requestType', 'summary', 'guestReference', 'progress'],\n fields: { progress: { label: 'Progress' }, summary: { label: 'Guest request' } },\n summaryFields: ['requestType', 'summary', 'progress'],\n filterFields: ['progress'],\n sortFields: ['progress'],\n});\n\n// Tool annotations for host planners: the menu read is read-only; cart edits are local writes; checkout\n// opens an external (payment) link.\nconst readOnly = annotations.readOnly();\nconst localWrite = annotations.localAction({ destructive: false, confirm: false });\nconst openLink = annotations.openAction();\n\nexport default server(\n 'acme_bistro',\n {\n title: 'Acme Bistro',\n version: '1.0.0',\n branding: {\n name: 'Acme Bistro',\n accent: '#B91C1C',\n surface: '#FEF3F2',\n surfaceDark: '#1A1211',\n radius: 'lg',\n density: 'comfortable',\n },\n // Payment is the only off-app step; the compiler derives ChatGPT redirect_domains from this so the\n // signed checkout link opens without a safe-link warning. The card never reaches this app.\n handoff: {\n allowedDomains: ['https://pay.acme.example', 'https://acme.example'],\n },\n // Reusable intent only. Operators review native record preservation separately from short-lived\n // assistant history; tools must not select expiry or claim a saved request confirms a reservation.\n collections: [guestRequests],\n use: { records: noodlePlatform.records.v1 },\n variables: [guestExperience],\n },\n [\n tool('submit_guest_request', {\n title: 'Submit a guest request',\n description: 'Record a guest request for staff review; this does not confirm a reservation.',\n annotations: annotations.localAction({ destructive: false, confirm: true }),\n input: guestRequestRecord.pick({ locationReference: true, requestType: true, summary: true }),\n output: z.object({ recordId: z.string() }),\n fulfil: ({ input, connectors }) => {\n const receipt = connectors.records.submitRecord({\n collection: 'guest_requests',\n payload: {\n locationReference: input.locationReference,\n requestType: input.requestType,\n summary: input.summary,\n },\n });\n return { recordId: receipt.recordId };\n },\n }),\n tool('show_menu', {\n title: 'Show the menu',\n description: 'Show the Acme Bistro menu and render the ordering widget.',\n annotations: readOnly,\n input: z.object({ customer: z.string().default('Guest') }),\n output: z.object({\n status: z.string(),\n customer: z.string(),\n serviceNotice: z.string().max(500),\n // Bounded list: the menu is a fixed catalog, so the ceiling is declared on the shape rather\n // than taken as a pagination input. `noodle check` reports `tool_design_output_bounds`.\n items: z.array(menuItemOutput).max(50),\n }),\n fulfil: ({ input }) => ({\n status: `Acme Bistro menu is ready for ${input.customer}. Build the order here; pay at checkout.`,\n customer: input.customer,\n serviceNotice: guestExperience.field('notice'),\n items: menu,\n }),\n viewTitle: 'Order at Acme Bistro',\n viewDescription: 'Browse the menu, build an order in chat, and hand off to pay.',\n invoking: 'Loading the menu…',\n invoked: 'Menu ready',\n domain: 'https://order.acme.example',\n view: {\n component: 'menu-cart',\n entry: './views/menu-cart.tsx',\n },\n csp: {\n connectDomains: ['https://acme.example'],\n resourceDomains: ['https://acme.example'],\n frameDomains: ['https://acme.example'],\n },\n }),\n // Widget-only cart edits — the model fills the item from natural language (\"add two margheritas\").\n tool('add_to_cart', {\n visibility: ['app'],\n description: 'Add a menu item to the Acme Bistro order from the widget.',\n annotations: localWrite,\n input: z.object({\n customer: z.string().default('Guest'),\n item: itemId.default('stone_pizza'),\n quantity: z.number().int().min(1).default(1),\n notes: z.string().default(''),\n }),\n output: z.object({\n status: z.string(),\n item: z.string(),\n quantity: z.number(),\n notes: z.string(),\n }),\n fulfil: ({ input }) => ({\n status: `Added ${input.quantity} × ${input.item} for ${input.customer}.`,\n item: input.item,\n quantity: input.quantity,\n notes: input.notes,\n }),\n }),\n tool('remove_from_cart', {\n visibility: ['app'],\n description: 'Remove a menu item from the Acme Bistro order.',\n annotations: localWrite,\n input: z.object({\n customer: z.string().default('Guest'),\n item: itemId.default('stone_pizza'),\n }),\n output: z.object({\n status: z.string(),\n item: z.string(),\n }),\n fulfil: ({ input }) => ({\n status: `Removed ${input.item} for ${input.customer}.`,\n item: input.item,\n }),\n }),\n // The only handoff: payment. The widget computes the total (live React) and passes a url-safe cart\n // token + total; the card is entered on Acme's PCI-scoped checkout, never in chat.\n tool('create_checkout', {\n title: 'Create checkout',\n description:\n 'Create the Acme Bistro payment checkout link for the current order. Pass a url-safe cart token ' +\n 'and the numeric total computed in the widget. Payment happens off-app; the card never reaches this app.',\n annotations: openLink,\n input: z.object({\n customer: z.string().default('Guest'),\n cartToken: z.string().default('cart'),\n total: z.number().min(0).default(0),\n }),\n output: z.object({\n status: z.string(),\n summary: z.string(),\n checkoutUrl: z.string(),\n }),\n // Do not place a literal `$` immediately before a token (`$${input.total}`) — it collides with the\n // `${…}` substitution syntax and leaves the token unresolved. Keep the amount token standalone.\n fulfil: ({ input }) => ({\n status: `Ready to pay for ${input.customer}'s order.`,\n summary: `${input.customer}'s Acme Bistro order · ${input.total} USD`,\n checkoutUrl: `https://pay.acme.example/checkout?cart=${input.cartToken}&total=${input.total}&src=chatgpt`,\n }),\n }),\n ],\n);\n" },
26
+ { relPath: "examples/acme-bistro/src/server.ts", content: "import {\n annotations,\n managedCollection,\n noodlePlatform,\n server,\n tool,\n variable,\n z,\n} from '@noodleseed/one';\n\n// Fictional ordering app. Payment uses a checkout link; native guest requests require installation.\n\nconst menu = [\n { id: 'stone_pizza', name: 'Stone-baked Margherita', price: 14, kind: 'Mains' },\n { id: 'roast_bowl', name: 'Harvest Roast Bowl', price: 13, kind: 'Mains' },\n { id: 'house_salad', name: 'House Garden Salad', price: 11, kind: 'Starters' },\n { id: 'lemon_tart', name: 'Lemon Tart', price: 8, kind: 'Desserts' },\n { id: 'sparkling', name: 'Sparkling Water', price: 4, kind: 'Drinks' },\n] as const;\n\nconst itemId = z.enum(['stone_pizza', 'roast_bowl', 'house_salad', 'lemon_tart', 'sparkling']);\n\n// Operators configure the notice without redeploying.\nconst guestExperience = variable('GUEST_EXPERIENCE', {\n schema: z.object({ notice: z.string().max(500) }),\n default: { notice: 'Ask us about dietary requirements before placing your order.' },\n portal: { label: 'Guest experience', group: 'Guest experience' },\n requiredFor: ['show_menu'],\n});\n\nconst menuItemOutput = z.object({\n id: z.string(),\n name: z.string(),\n price: z.number(),\n kind: z.string(),\n});\n\nconst guestRequestRecord = z.object({\n locationReference: z.string().min(1).max(120),\n requestType: z.enum(['reservation_help', 'accessibility', 'dietary_question', 'other']),\n summary: z.string().min(1).max(1000),\n guestReference: z.string().max(120).optional(),\n progress: z.enum(['received', 'reviewing', 'handled']).default('received'),\n});\n\nconst guestRequests = managedCollection('guest_requests', {\n title: 'Guest requests',\n description: 'Guest service requests that restaurant staff can review and resolve.',\n schemaVersion: 1,\n // No source is declared, so Noodle is authoritative. Outside-owned records would name an exact\n // connector scan contract here; changes in that outside system would remain ordinary tools.\n record: guestRequestRecord,\n management: { notes: true },\n publicFields: ['locationReference', 'requestType', 'summary'],\n editableFields: ['locationReference', 'requestType', 'summary', 'guestReference', 'progress'],\n fields: { progress: { label: 'Progress' }, summary: { label: 'Guest request' } },\n summaryFields: ['requestType', 'summary', 'progress'],\n filterFields: ['progress'],\n sortFields: ['progress'],\n});\n\n// Tool annotations for host planners: the menu read is read-only; cart edits are local writes; checkout\n// opens an external (payment) link.\nconst readOnly = annotations.readOnly();\nconst localWrite = annotations.localAction({ destructive: false, confirm: false });\nconst openLink = annotations.openAction();\n\nexport default server(\n 'acme_bistro',\n {\n title: 'Acme Bistro',\n version: '1.0.0',\n branding: {\n name: 'Acme Bistro',\n accent: '#B91C1C',\n surface: '#FEF3F2',\n surfaceDark: '#1A1211',\n radius: 'lg',\n density: 'comfortable',\n },\n // Payment is the only off-app step; the compiler derives ChatGPT redirect_domains from this so the\n // signed checkout link opens without a safe-link warning. The card never reaches this app.\n handoff: {\n allowedDomains: ['https://pay.acme.example', 'https://acme.example'],\n },\n // Reusable intent only. Operators review native record preservation separately from short-lived\n // assistant history; tools must not select expiry or claim a saved request confirms a reservation.\n collections: [guestRequests],\n use: { records: noodlePlatform.records.v1 },\n variables: [guestExperience],\n },\n [\n tool('submit_guest_request', {\n title: 'Submit a guest request',\n description: 'Record a guest request for staff review; this does not confirm a reservation.',\n annotations: annotations.localAction({ destructive: false, confirm: true }),\n // A low-risk request, so anonymous ChatGPT and Claude guests may send it without signing in.\n anonymous: 'allowed',\n input: guestRequestRecord.pick({ locationReference: true, requestType: true, summary: true }),\n output: z.object({ recordId: z.string() }),\n fulfil: ({ input, connectors }) => {\n const receipt = connectors.records.submitRecord({\n collection: 'guest_requests',\n payload: {\n locationReference: input.locationReference,\n requestType: input.requestType,\n summary: input.summary,\n },\n });\n return { recordId: receipt.recordId };\n },\n }),\n tool('show_menu', {\n title: 'Show the menu',\n description: 'Show the Acme Bistro menu and render the ordering widget.',\n annotations: readOnly,\n input: z.object({ customer: z.string().default('Guest') }),\n output: z.object({\n status: z.string(),\n customer: z.string(),\n serviceNotice: z.string().max(500),\n // Bounded list: the menu is a fixed catalog, so the ceiling is declared on the shape rather\n // than taken as a pagination input. `noodle check` reports `tool_design_output_bounds`.\n items: z.array(menuItemOutput).max(50),\n }),\n fulfil: ({ input }) => ({\n status: `Acme Bistro menu is ready for ${input.customer}. Build the order here; pay at checkout.`,\n customer: input.customer,\n serviceNotice: guestExperience.field('notice'),\n items: menu,\n }),\n viewTitle: 'Order at Acme Bistro',\n viewDescription: 'Browse the menu, build an order in chat, and hand off to pay.',\n invoking: 'Loading the menu…',\n invoked: 'Menu ready',\n domain: 'https://order.acme.example',\n view: {\n component: 'menu-cart',\n entry: './views/menu-cart.tsx',\n },\n csp: {\n connectDomains: ['https://acme.example'],\n resourceDomains: ['https://acme.example'],\n frameDomains: ['https://acme.example'],\n },\n }),\n // Widget-only cart edits — the model fills the item from natural language (\"add two margheritas\").\n tool('add_to_cart', {\n visibility: ['app'],\n description: 'Add a menu item to the Acme Bistro order from the widget.',\n annotations: localWrite,\n input: z.object({\n customer: z.string().default('Guest'),\n item: itemId.default('stone_pizza'),\n quantity: z.number().int().min(1).default(1),\n notes: z.string().default(''),\n }),\n output: z.object({\n status: z.string(),\n item: z.string(),\n quantity: z.number(),\n notes: z.string(),\n }),\n fulfil: ({ input }) => ({\n status: `Added ${input.quantity} × ${input.item} for ${input.customer}.`,\n item: input.item,\n quantity: input.quantity,\n notes: input.notes,\n }),\n }),\n tool('remove_from_cart', {\n visibility: ['app'],\n description: 'Remove a menu item from the Acme Bistro order.',\n annotations: localWrite,\n input: z.object({\n customer: z.string().default('Guest'),\n item: itemId.default('stone_pizza'),\n }),\n output: z.object({\n status: z.string(),\n item: z.string(),\n }),\n fulfil: ({ input }) => ({\n status: `Removed ${input.item} for ${input.customer}.`,\n item: input.item,\n }),\n }),\n // The only handoff: payment. The widget computes the total (live React) and passes a url-safe cart\n // token + total; the card is entered on Acme's PCI-scoped checkout, never in chat.\n tool('create_checkout', {\n title: 'Create checkout',\n description:\n 'Create the Acme Bistro payment checkout link for the current order. Pass a url-safe cart token ' +\n 'and the numeric total computed in the widget. Payment happens off-app; the card never reaches this app.',\n annotations: openLink,\n input: z.object({\n customer: z.string().default('Guest'),\n cartToken: z.string().default('cart'),\n total: z.number().min(0).default(0),\n }),\n output: z.object({\n status: z.string(),\n summary: z.string(),\n checkoutUrl: z.string(),\n }),\n // Do not place a literal `$` immediately before a token (`$${input.total}`) — it collides with the\n // `${…}` substitution syntax and leaves the token unresolved. Keep the amount token standalone.\n fulfil: ({ input }) => ({\n status: `Ready to pay for ${input.customer}'s order.`,\n summary: `${input.customer}'s Acme Bistro order · ${input.total} USD`,\n checkoutUrl: `https://pay.acme.example/checkout?cart=${input.cartToken}&total=${input.total}&src=chatgpt`,\n }),\n }),\n ],\n);\n" },
27
27
  { relPath: "examples/acme-bistro/src/views/menu-cart.tsx", content: "import { type CSSProperties, useMemo, useState } from 'react';\nimport {\n useBranding,\n useCallTool,\n useLayout,\n useOpenExternal,\n useToolInfo,\n useViewState,\n} from '../helpers.js';\nimport './widget-style.css';\n\ntype MenuItem = {\n readonly id: string;\n readonly name: string;\n readonly price: number;\n readonly kind: string;\n};\n\nfunction asMenu(value: unknown) {\n return value as\n | { readonly status?: string; readonly customer?: string; readonly items?: readonly MenuItem[] }\n | undefined;\n}\n\nexport default function MenuCart() {\n const { displayMode, theme } = useLayout();\n // Widget CSS is ours, so nothing applies `server.branding` for us. Map the one value this widget\n // cares about onto its own custom property; widget-style.css keeps a default for local dev.\n const branding = useBranding();\n const brandStyle = branding.accent\n ? ({ '--nw-accent': branding.accent } as CSSProperties)\n : undefined;\n const openExternal = useOpenExternal();\n const menuResult = asMenu(useToolInfo('show_menu').structuredContent);\n const addToCart = useCallTool('add_to_cart');\n const removeFromCart = useCallTool('remove_from_cart');\n const checkout = useCallTool('create_checkout');\n\n const items = menuResult?.items ?? [];\n const [customer] = useViewState('customer', menuResult?.customer ?? 'Guest');\n // Cart is session-local (id → quantity); the total is summed here in live React, not in a recorded fulfil.\n const [cart, setCart] = useState<Record<string, number>>({});\n const [status, setStatus] = useState(\n menuResult?.status ?? 'Build your order, then check out to pay.',\n );\n\n const total = useMemo(\n () => items.reduce((sum, item) => sum + item.price * (cart[item.id] ?? 0), 0),\n [items, cart],\n );\n const lineCount = Object.values(cart).reduce((n, q) => n + q, 0);\n\n async function add(item: MenuItem) {\n setCart((current) => ({ ...current, [item.id]: (current[item.id] ?? 0) + 1 }));\n const result = await addToCart.callTool({ customer, item: item.id, quantity: 1 });\n const structured = result.structuredContent as { readonly status?: string } | undefined;\n setStatus(structured?.status ?? `Added ${item.name}.`);\n }\n\n async function remove(item: MenuItem) {\n setCart((current) => {\n const next = { ...current };\n const q = (next[item.id] ?? 0) - 1;\n if (q <= 0) delete next[item.id];\n else next[item.id] = q;\n return next;\n });\n await removeFromCart.callTool({ customer, item: item.id });\n }\n\n async function payNow() {\n if (lineCount === 0) return;\n // The only handoff: payment. A url-safe cart token + the numeric total go to Acme's PCI checkout.\n const cartToken = items\n .filter((item) => cart[item.id])\n .map((item) => `${item.id}x${cart[item.id]}`)\n .join('-');\n const result = await checkout.callTool({ customer, cartToken, total });\n const structured = result.structuredContent as { readonly checkoutUrl?: string } | undefined;\n if (structured?.checkoutUrl) openExternal(structured.checkoutUrl);\n }\n\n return (\n <main\n className={`nw-shell${theme === 'dark' ? ' dark' : ''}`}\n style={brandStyle}\n data-llm={`Acme Bistro order for ${customer}: ${lineCount} item(s), total $${total}`}\n >\n <section className=\"nw-card\">\n <header className=\"nw-header\">\n <span className=\"nw-icon\" aria-hidden=\"true\">\n <PlateIcon />\n </span>\n <div className=\"nw-title-block\">\n <h1 className=\"nw-title\">Acme Bistro</h1>\n <p className=\"nw-subtitle\">{status}</p>\n </div>\n <span className=\"nw-chip\">\n {displayMode === 'fullscreen' ? 'Fullscreen' : `${lineCount} in cart`}\n </span>\n </header>\n\n <ul className=\"nw-menu\">\n {items.map((item) => (\n <li className=\"nw-row\" key={item.id}>\n <span className=\"nw-row-main\">\n <span className=\"nw-name\">{item.name}</span>\n <span className=\"nw-kind\">{item.kind}</span>\n </span>\n <span className=\"nw-price\">${item.price}</span>\n <span className=\"nw-qty\">\n <button\n aria-label={`Remove one ${item.name}`}\n className=\"nw-step\"\n type=\"button\"\n disabled={!cart[item.id]}\n onClick={() => remove(item)}\n >\n −\n </button>\n <span className=\"nw-count\">{cart[item.id] ?? 0}</span>\n <button\n aria-label={`Add one ${item.name}`}\n className=\"nw-step\"\n type=\"button\"\n onClick={() => add(item)}\n >\n +\n </button>\n </span>\n </li>\n ))}\n </ul>\n\n <footer className=\"nw-footer\">\n <span className=\"nw-total\">\n Total <strong>${total}</strong>\n </span>\n <button\n className=\"nw-button nw-button-primary\"\n type=\"button\"\n disabled={lineCount === 0 || checkout.isPending}\n onClick={payNow}\n >\n <CardIcon />\n {checkout.isPending ? 'Opening checkout…' : 'Check out & pay'}\n </button>\n </footer>\n <p className=\"nw-note\">\n Payment happens on acme.example — your card is never entered in chat.\n </p>\n </section>\n </main>\n );\n}\n\nfunction PlateIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <circle cx=\"12\" cy=\"12\" r=\"9\" />\n <circle cx=\"12\" cy=\"12\" r=\"4\" />\n </svg>\n );\n}\n\nfunction CardIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <rect x=\"3\" y=\"5\" width=\"18\" height=\"14\" rx=\"2\" />\n <path d=\"M3 10h18\" />\n </svg>\n );\n}\n" },
28
28
  { relPath: "examples/acme-bistro/src/views/widget-style.css", content: ":root {\n color-scheme: light dark;\n font-family:\n Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n --nw-bg: #ffffff;\n --nw-surface: #fdf4f3;\n --nw-text: #1f1413;\n --nw-muted: #7a5f5c;\n --nw-border: #efd9d6;\n /* Local-dev default. At runtime menu-cart.tsx overrides this from server.branding.accent. */\n --nw-accent: #b91c1c;\n --nw-accent-strong: #991b1b;\n --nw-accent-soft: #fdeae8;\n --nw-radius: 10px;\n --nw-shadow: 0 18px 50px rgb(60 20 20 / 12%);\n}\n\n.dark,\n[data-theme=\"dark\"] {\n --nw-bg: #1a1211;\n --nw-surface: #241816;\n --nw-text: #f8ecea;\n --nw-muted: #c3a29e;\n --nw-border: #43302d;\n --nw-accent: #f87171;\n --nw-accent-strong: #ef4444;\n --nw-accent-soft: #3a1f1d;\n --nw-shadow: 0 18px 50px rgb(0 0 0 / 32%);\n}\n\n* {\n box-sizing: border-box;\n}\n\nbody {\n margin: 0;\n background: var(--nw-bg);\n color: var(--nw-text);\n}\n\nbutton {\n font: inherit;\n}\n\n.nw-shell {\n min-height: 100vh;\n padding: 14px;\n background: var(--nw-bg);\n color: var(--nw-text);\n}\n\n.nw-card {\n max-width: 560px;\n margin: 0 auto;\n background: var(--nw-surface);\n border: 1px solid var(--nw-border);\n border-radius: var(--nw-radius);\n box-shadow: var(--nw-shadow);\n overflow: hidden;\n}\n\n.nw-header {\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 16px;\n border-bottom: 1px solid var(--nw-border);\n}\n\n.nw-icon svg {\n width: 24px;\n height: 24px;\n fill: none;\n stroke: var(--nw-accent);\n stroke-width: 1.7;\n stroke-linecap: round;\n stroke-linejoin: round;\n}\n\n.nw-title-block {\n flex: 1;\n min-width: 0;\n}\n\n.nw-title {\n margin: 0;\n font-size: 17px;\n font-weight: 700;\n}\n\n.nw-subtitle {\n margin: 2px 0 0;\n font-size: 13px;\n color: var(--nw-muted);\n}\n\n.nw-chip {\n padding: 4px 10px;\n border-radius: 999px;\n background: var(--nw-accent-soft);\n color: var(--nw-accent-strong);\n font-size: 12px;\n font-weight: 600;\n}\n\n.nw-menu {\n list-style: none;\n margin: 0;\n padding: 8px 16px;\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n\n.nw-row {\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 10px 0;\n border-bottom: 1px dashed var(--nw-border);\n}\n\n.nw-row-main {\n flex: 1;\n min-width: 0;\n display: flex;\n flex-direction: column;\n}\n\n.nw-name {\n font-weight: 600;\n}\n\n.nw-kind {\n font-size: 12px;\n color: var(--nw-muted);\n}\n\n.nw-price {\n color: var(--nw-accent-strong);\n font-weight: 600;\n font-size: 13px;\n}\n\n.nw-qty {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n}\n\n.nw-step {\n width: 26px;\n height: 26px;\n border: 1px solid var(--nw-border);\n border-radius: 8px;\n background: var(--nw-bg);\n color: var(--nw-text);\n cursor: pointer;\n}\n\n.nw-step:disabled {\n opacity: 0.4;\n cursor: default;\n}\n\n.nw-count {\n min-width: 16px;\n text-align: center;\n font-variant-numeric: tabular-nums;\n}\n\n.nw-footer {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n padding: 12px 16px 4px;\n}\n\n.nw-total {\n font-size: 14px;\n color: var(--nw-muted);\n}\n\n.nw-total strong {\n color: var(--nw-text);\n font-size: 16px;\n}\n\n.nw-button {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 9px 14px;\n border: 1px solid var(--nw-border);\n border-radius: 10px;\n background: var(--nw-bg);\n color: var(--nw-text);\n cursor: pointer;\n}\n\n.nw-button svg {\n width: 16px;\n height: 16px;\n fill: none;\n stroke: currentColor;\n stroke-width: 1.7;\n stroke-linecap: round;\n stroke-linejoin: round;\n}\n\n.nw-button-primary {\n background: var(--nw-accent);\n border-color: var(--nw-accent);\n color: #ffffff;\n font-weight: 600;\n}\n\n.nw-button-primary:disabled {\n opacity: 0.6;\n cursor: default;\n}\n\n.nw-note {\n margin: 0;\n padding: 8px 16px 16px;\n font-size: 12px;\n color: var(--nw-muted);\n}\n" },
29
- { relPath: "examples/acme-bistro/test/server.test.ts", content: "import { describe, expect, it } from 'vitest';\nimport app from '../src/server.js';\n\ndescribe('acme-bistro example', () => {\n it('exports a Noodle server definition', () => {\n expect(typeof app.toManifest).toBe('function');\n });\n\n it('declares the payment-only handoff domain', async () => {\n // End-to-end: the order completes in chat; only payment hands off to Acme's checkout.\n const text = JSON.stringify(await app.toManifest());\n expect(text).toContain('https://pay.acme.example');\n });\n\n it('exposes the menu widget, cart helpers, and checkout tool', async () => {\n const text = JSON.stringify(await app.toManifest());\n expect(text).toContain('show_menu');\n expect(text).toContain('add_to_cart');\n expect(text).toContain('create_checkout');\n });\n\n it('declares guest records and explicitly authors the native submission tool', async () => {\n const manifest = await app.toManifest();\n expect(manifest.server.collections).toEqual([\n expect.objectContaining({ name: 'guest_requests', schemaVersion: 1 }),\n ]);\n expect(\n manifest.tools.find((tool) => tool.name === 'submit_guest_request')?.fulfilment.steps,\n ).toMatchObject([{ use: 'records.submit_record' }]);\n });\n});\n" },
29
+ { relPath: "examples/acme-bistro/test/server.test.ts", content: "import { describe, expect, it } from 'vitest';\nimport app from '../src/server.js';\n\ndescribe('acme-bistro example', () => {\n it('exports a Noodle server definition', () => {\n expect(typeof app.toManifest).toBe('function');\n });\n\n it('declares the payment-only handoff domain', async () => {\n // End-to-end: the order completes in chat; only payment hands off to Acme's checkout.\n const text = JSON.stringify(await app.toManifest());\n expect(text).toContain('https://pay.acme.example');\n });\n\n it('exposes the menu widget, cart helpers, and checkout tool', async () => {\n const text = JSON.stringify(await app.toManifest());\n expect(text).toContain('show_menu');\n expect(text).toContain('add_to_cart');\n expect(text).toContain('create_checkout');\n });\n\n it('declares guest records and explicitly authors the native submission tool', async () => {\n const manifest = await app.toManifest();\n expect(manifest.server.collections).toEqual([\n expect.objectContaining({ name: 'guest_requests', schemaVersion: 1 }),\n ]);\n expect(\n manifest.tools.find((tool) => tool.name === 'submit_guest_request')?.fulfilment.steps,\n ).toMatchObject([{ use: 'records.submit_record' }]);\n });\n\n it('opens only the guest request, of its write tools, to anonymous MCP callers', async () => {\n const manifest = await app.toManifest();\n const anonymousWrites = manifest.tools\n .filter((tool) => tool.anonymous === 'allowed')\n .map((tool) => tool.name);\n expect(anonymousWrites).toEqual(['submit_guest_request']);\n });\n});\n" },
30
30
  { relPath: "examples/acme-bistro/vitest.config.ts", content: "import { defineConfig } from 'vitest/config';\n\n// Local config so `npm test` (vitest run) discovers this example's own tests instead of inheriting a\n// parent monorepo config's include globs.\nexport default defineConfig({\n test: { include: ['test/**/*.test.ts'] },\n});\n" },
31
31
  { relPath: "examples/acme-discovery/README.md", content: "# Acme Getaways — top-of-funnel discovery → handoff\n\nA Noodle MCP App for **Acme Getaways**, a fictional travel brand. It is the flagship for the\n**top-of-funnel discovery → handoff** pattern: discovery and configuration happen inside ChatGPT; the\nbooking/transaction happens off-app on Acme's own site, reached through a signed, attributable handoff\ndeep link. It pairs a `tool` discovery carousel with a model-visible `create_handoff` tool and\nserver-level `handoff.allowedDomains`.\n\nCapability slots: top-of-funnel funnel discipline, discovery carousel widget, `create_handoff` deep-link\nhandoff with attribution, `handoff.allowedDomains`, the **public website assistant surface** with its\n**WebMCP browser-agent bridge** on a real demo page (`site/index.html`), and a worked\n**design-first** artifact (`design/UX-Document.md` and `design/wireframe.html`). It shows the \"design the experience, then build\nit\" flow the `noodle-seed` skill's `references/experience-design.md` teaches.\n\n## The same tools on Acme's own website\n\nThe funnel does not only start in ChatGPT. The `assistant` block projects these same tools onto\nAcme's marketing site for a visitor with **no account and no session backend** — as a **mixed**\nsurface, so the visitor can also sign in mid-conversation:\n\n```ts\naccess: [\n publicWebsite({\n origins: ['https://getaways.acme.example'],\n capabilities: [destinations, discoverGetaways, createHandoff, shortlistGetaway, offerLeadCapture, captureLead, myTrips],\n signIn: true, // my_trips reads ${user}; reaching it raises the sign-in card\n instructions:\n 'Be a friendly, consultative travel guide, never pushy. Help visitors narrow a getaway before suggesting the next useful step.',\n }),\n authenticatedWebsite({\n origins: ['https://account.acme.example'],\n capabilities: [destinations, discoverGetaways, createHandoff, myTrips],\n instructions: 'The traveler is signed in. Help them plan from their saved trips.',\n }),\n],\n```\n\nThere is no second tool set and no second app — one `server.ts`, projected onto its front doors.\nThe surface `instructions` add only the website-specific voice and goal; shared product truth stays in\n`server.instructions`. This public guidance is injected into that surface's assistant turns, never MCP\n`initialize` or another assistant surface.\n`capabilities` is the entire externally reachable surface per front door, so it stays short enough to\nreview at a glance and closed by default: a tool added to this server later is unreachable from the\nwebsite until someone lists it.\n\n## Sign in mid-conversation, land in the account\n\n`signIn: true` makes the marketing surface **mixed**: `my_trips` stays visible so the assistant can\noffer it, and an anonymous visitor who reaches for it sees a branded card — *Sign in* plus, because\n`labels.signUpAction` is authored, *Create free account*. Both raise `assistant-sign-in-requested`\nwith a single-use `signInTicket`; the detail's `intent` tells the page whether to route its login or\nits registration. Acme's page signs the visitor in exactly as it already does, its backend spends the\nticket with `createAssistantSession({ ..., signInTicket })`, and the **same conversation continues**\non whichever origin the backend designates:\n\n- landing back on `getaways.acme.example` keeps the mixed surface's projection with identity attached;\n- landing on `account.acme.example` rebinds the conversation to the authenticated surface — its\n capabilities and its voice — and the widget repaints the visible transcript and auto-answers the\n intercepted `my_trips` ask under the new identity.\n\nThe ticket spend after account creation is identical to the one after sign-in; the service never\noperates a login of its own.\n\n## Browser agents on Acme's page\n\n`site/index.html` is the marketing page itself: static markup, four listings, and the one line a\ncustomer pastes.\n\n```html\n<script src=\"https://cloud.noodleseed.dev/v1/assistant/embed.js\" data-embed-id=\"pub_…\"></script>\n```\n\nBecause the marketing surface sets `webmcp: { enabled: true }`, that same line does a second job in a\nbrowser that supports WebMCP: the embed registers the session's projected tools with\n`document.modelContext`, so a browser agent — Gemini-in-Chrome, Claude-in-Chrome — can call\n`discover_getaways` or `create_handoff` without a human typing in the panel.\n\nWhat it does **not** do is widen anything. A bridged call carries exactly the embed session's\nauthority: the surface's six-capability allowlist, the same policy and budgets, the same audit trail,\nand the same confirmation card on `capture_lead` — the visitor still approves the lead in the panel,\nbecause a browser agent's consent is not the visitor's. The switch governs *discovery*, not\npermission: it decides whether an agent learns the tools are there. The signed-in account surface\nbelow leaves it off, which is the point of setting it per surface.\n\nBrowsers without `document.modelContext` are unaffected; the page and the panel behave exactly as\nthey did before.\n\nTo run it:\n\n```sh\nnoodle dev # the server, in one terminal\nnpx serve site # the page, in another (any static server works)\n```\n\n`noodle dev` serves the MCP endpoint, not HTML, so the page needs its own server. For a real\nend-to-end run, `noodle deploy` this example, paste the minted `pub_…` id into `site/index.html`, and\nadd the origin you serve the page from to the `publicWebsite` `origins` list — the session exchange\nrefuses any origin that is not listed. WebMCP itself ships behind an origin trial in Chrome 149+, so\na browser without the trial enabled shows the assistant panel and no bridge.\n\n## The consultative sales gateway\n\nWhen a visitor's plans firm up but they would rather not sign up, the assistant may — with explicit\nconfirmation — take their details and deliver them to Acme's own sink. The recipe is a composition of\nexisting primitives, not a platform feature:\n\n- `capture_lead` is an ordinary tool with `annotations.action({ confirm: true })`: the confirmation\n card, showing every field, is the visitor's consent moment.\n- `offer_lead_capture` is its read-only opener, declaring one `collect` `interaction` (ADR 0240):\n the fields to collect, the work email marked `private`, the trip note seeded from the opener's\n output and optional, `review: 'all'`, and the success sentence. A form on the website and natural\n conversation on a messaging channel both save through the same confirmed `capture_lead`;\n `noodle validate` checks every field against the action's input schema.\n- Delivery is a declarative HTTP connector whose endpoint is `variable('LEAD_SINK_URL')` and whose\n credential is `secret('LEAD_SINK_TOKEN')` — the operator supplies values with\n `noodle variables set` / `noodle secrets set`; the example stays credential-free and one authored\n class serves any business.\n- The request mapping sets `source: 'website-assistant'` itself, so Acme's sink can trust the\n attribution; the model never supplies it.\n- The lead rests only in Acme's own system. The platform stores no lead, and a vendor sink is just\n different data: Resend/Postmark are `auth: { kind: 'apiKey', … }`, a HubSpot private app is\n `auth: { kind: 'bearer', … }` — never a named vendor package.\n\n## Design spec and wireframe (write these before the code)\n\nThe house-style UX Document (funnel boundary, personas, prioritized flows, tool and widget spec,\nhandoff domains) is `design/UX-Document.md`; the single-file wireframe with its embedded Apps SDK\naudit is `design/wireframe.html`.\n\n## Local author loop\n\n```sh\nnoodle validate\nnoodle test\nnoodle dev\n```\n\nIn another terminal:\n\n```sh\nnoodle tools list\nnoodle tools call discover_getaways --args '{\"vibe\":\"beach\",\"month\":\"June\",\"travelers\":2}'\nnoodle tools call create_handoff --args '{\"destination\":\"coral_bay\",\"destinationName\":\"Coral Bay\",\"month\":\"June\",\"travelers\":2}'\nnoodle check --target chatgpt\n```\n\n## Deploy\n\n```sh\nnoodle link --org demo --app acme-discovery\nnoodle deploy --access owner-only\nnoodle open\n```\n\nThis example has no connector or model secrets and does not include tokens, caller-key mechanisms, or\n`.env.noodle` values. Hosted `noodleManaged()` inference is available by default to billing-attributed\ndeployments and remains subject to sponsored daily limits; local validation and tool calls do not use the\nhosted model, and `openAICompatible(...)` is the BYO alternative. All destinations, prices, and URLs are\nfictional.\n" },
32
32
  { relPath: "examples/acme-discovery/design/UX-Document.md", content: "# Acme Getaways ChatGPT App — User Flow & Experience Document\n\n**Prepared by:** Noodle Seed\n**Scope:** Top-of-funnel destination discovery & trip-shaping inside ChatGPT → signed, attributable handoff to finish booking on Acme's own site\n**Status:** Design specification (v1)\n**Funnel boundary:** Everything up to \"this is the getaway I want and roughly when I'm going.\" Discovery, shortlisting, and shaping the trip (vibe · month · travelers) happen inside ChatGPT; **booking, dates, and payment happen off-app at `book.acme.example`** via a signed deep link that carries the trip. No per-user OAuth in this app.\n\n> The funnel-boundary line above is the contract. Every scope debate resolves against it: **shape the trip in chat, transact on Acme.**\n\n---\n\n## 0. The One-Paragraph Thesis\n\nA traveler deciding *where* to go doesn't start on a booking site — they start with a fuzzy feeling: \"somewhere warm and walkable in June, just the two of us, not too expensive.\" That deliberation increasingly happens in ChatGPT, where they can think out loud and be talked through options. But base ChatGPT can only guess at places and prices; it doesn't know Acme's actual catalog, what a trip *starts from*, or which months are right. The Acme Getaways app brings Acme's **own curated destinations** — Coral Bay, Monte Alto, Old Quarter, Harbor City, each with a real starting price, best-months window, and an honest one-line reason it fits — into that same conversation, rendered as a discovery carousel the traveler can scan, shortlist, and shape. We own the **discovery and configuration** loop; Acme owns the **booking, dates, inventory, and payment** that happen on `book.acme.example`. The handoff *is* the product: the moment the traveler is excited about a specific place, one tap carries the shaped trip to Acme's site with a `src=chatgpt` attribution tag — so Acme measures, and pays for, exactly the demand ChatGPT sent. Acme never has to build or maintain a chat surface; Noodle Seed never has to touch payments.\n\n---\n\n## 1. Acme Getaways Product Overview (Knowledge Base)\n\n### 1.1 What is Acme Getaways?\n\n**Acme Getaways** is a fictional curated-travel brand that sells a small, hand-picked catalog of getaway destinations rather than an infinite metasearch index. Its edge is *curation*: every destination is chosen, described, and priced by Acme, and every trip is booked and fulfilled on Acme's own platform at `acme.example` (booking flow at `book.acme.example`). Because the catalog is small and Acme-owned, it is the ideal thing to ground a ChatGPT app on — the app can be authoritative about every place it shows, because Acme is the source of truth for all of them.\n\n### 1.2 The Catalog (what the app helps discover)\n\nThe curated catalog is **static, Acme-owned data the app returns verbatim** — the app never invents a destination, a price, or a best-months window.\n\n| Destination | Region | Vibe | From (per person) | Best months | Why (Acme's own line) |\n|-------------|--------|------|-------------------|-------------|------------------------|\n| **Coral Bay** | Adriatic coast | beach | **$890** | May–Sep | Calm swimming coves and a walkable old town — easy for a relaxed first trip. |\n| **Monte Alto** | Northern Alps | mountains | **$1,120** | Dec–Mar | Ski-in village with beginner slopes and long groomed runs. |\n| **Old Quarter** | Central Europe | culture | **$640** | Apr–Oct | Dense museum district and food halls, all reachable on foot. |\n| **Harbor City** | Pacific rim | city | **$980** | Sep–Nov | Waterfront nightlife and day-trip islands a short ferry away. |\n\n**\"From\" prices are per-person starting figures**, not quotes — the real, dated price is computed on Acme's site once the traveler picks dates and party size. The app is careful to say \"from $X,\" never \"$X.\"\n\n### 1.3 The Trip Shape (the three inputs the app configures)\n\nDiscovery is parameterized by three model-fillable inputs the traveler expresses in natural language: **vibe** (`beach` · `mountains` · `culture` · `city`), **month** (a calendar month, defaulting to June), and **travelers** (party size, default 2). These are exactly the values that survive the handoff, so the trip a traveler shapes in chat is the trip Acme's site opens to. Nothing else is configured in chat — no dates, no rooms, no payment; those belong to Acme.\n\n### 1.4 The Booking Platform (what lives after the handoff)\n\n`book.acme.example` is Acme's real booking flow: live inventory, exact dates, party pricing, and checkout. It is **off-app by design** — it needs the traveler's account, real availability, and a payment method, none of which belong in a chat surface. The ChatGPT app's job ends the instant the traveler is ready to book; it hands the shaped trip across a signed deep link and never sees a card number.\n\n### 1.5 Business Model / Why Acme Wants This\n\nAcme earns on completed bookings. Its bottleneck is **top-of-funnel demand** — reaching travelers at the \"where should we go?\" moment, before they've defaulted to a metasearch site. That moment now happens in ChatGPT. This app moves Acme **upstream** into the deliberation itself, and — critically — makes the demand **attributable**: every handoff carries `src=chatgpt`, so Acme can measure ChatGPT-sourced sessions, handoffs, and downstream bookings, and Noodle Seed can be paid for the funnel it fills. Acme gets qualified, pre-shaped travelers landing on its booking flow; it does not have to build, staff, or moderate a conversational surface.\n\n---\n\n## 2. Competitive Landscape — Travel Discovery on ChatGPT\n\n### 2.1 What exists today\n\nTravel discovery inside ChatGPT today is **ungrounded**: the model will happily suggest destinations, but it can't tell you what *Acme* actually sells, what a trip starts from, or which months are right for a specific place — and it can't hand you off to book. Travelers bounce out to metasearch tabs, lose the context they built up in chat, and re-explain everything. Generic travel apps that do exist optimize for the *booking* transaction, not the *deliberation* — they assume you already know where you're going.\n\n### 2.2 Acme's unique position in ChatGPT\n\nThe wedge is **\"a fuzzy feeling in, a specific shortlisted getaway out — grounded in a real catalog, ready to book in one tap.\"** Differentiators:\n\n- **Grounded catalog.** Every place, price, best-month, and reason comes from Acme's own data, shown in the carousel — never the model's guess.\n- **Shaped, not just suggested.** The trip carries a vibe, a month, and a party size, so the handoff opens Acme's site to *this* trip, not a blank search.\n- **Honest top-of-funnel.** The app is explicit that booking and payment happen on Acme, never in chat — no fake in-chat checkout, no invented availability.\n- **Attributable by construction.** The `src=chatgpt` tag on the handoff makes the funnel measurable from day one.\n\n---\n\n## 3. Target User Personas (traveler-centric)\n\n**Persona A — \"The Weekend Deliberator.\"** Knows the vibe and the rough month, not the place. \"Somewhere warm and walkable, early June, two of us.\" Wants 3–4 credible options with a reason each, fast. High intent, low patience. Handoff target: **book the shortlisted place on Acme.**\n\n**Persona B — \"The Budget-First Traveler.\"** Starts from a number, not a place. \"What's the cheapest getaway that isn't miserable?\" Scans on the \"from\" price, reads the reason, shortlists. Handoff target: **Acme, once the price/place feels right.**\n\n**Persona C — \"The Season Chaser.\"** Has a fixed window and wants the place that's *right then*. \"Where's good in December?\" Cares most about the best-months fit. Handoff target: **Acme, for the in-season pick.**\n\n**Persona D — \"The Vibe Switcher.\"** Came in for the beach, talks themselves into culture or a city break mid-conversation. Re-runs discovery with a new vibe; the carousel re-renders. Handoff target: **Acme, once the vibe settles.**\n\n**Persona E — \"The Group Coordinator.\"** (Edge.) Shaping a trip for 4–6 people; party size matters for how the trip reads and what carries across. Same loop; the `travelers` value is the one they care about surviving the handoff.\n\n> **The single adaptive behavior:** the app infers vibe · month · travelers from natural language, re-runs discovery when any of them changes, and only ever offers **one deliberate exit — book on Acme.** There is no second track; the handoff is singular.\n\n---\n\n## 4. Conversational User Flow\n\n### 4.1 Entry Points\n\n1. **Vibe + month:** \"Where should we go for a beach trip in June?\"\n2. **Budget-first:** \"Cheapest getaway you'd actually recommend for two?\"\n3. **Season-first:** \"Somewhere good in December?\"\n4. **Named vibe switch:** \"Actually, more of a city break — options?\"\n5. **Party-shaped:** \"A culture trip for four in October.\"\n\n### 4.2 Flow Architecture\n\n```\n ┌─────────────────────────────────────────┐\n │ ENTRY / INTENT │\n │ vibe · month · travelers (from language) │\n └──────────────────────┬────────────────────┘\n │\n discover_getaways ★\n │\n ┌─────────────▼──────────────┐\n │ DiscoveryCarousel │\n │ 4 grounded options: name, │\n │ from-price, best-months, │\n │ region, Acme's \"why\" line │\n └─────────────┬──────────────┘\n │\n ┌──────────────────────────┼──────────────────────────┐\n │ │ │\n shortlist_getaway re-run discovery (browse / narrate)\n (tap a card → mark) (change vibe/month/pax) model explains fit\n │ │ │\n └──────────────────────────┼───────────────────────────┘\n │\n ┌─────────────▼──────────────┐\n │ \"Continue on Acme\" CTA │\n │ create_handoff ★ │\n └─────────────┬──────────────┘\n │\n ┌──────────────────────────▼───────────────────────────┐\n │ HANDOFF (the funnel boundary) │\n │ book.acme.example/plan?dest=…&month=…&pax=…&src=chatgpt│\n │ (dates · inventory · payment happen OFF-APP) │\n └────────────────────────────────────────────────────────┘\n```\n\nThe defining property: **the discovery loop is fast, grounded, and account-free; the handoff is a single deliberate exit to Acme.** Nothing is sold, dated, or paid for inside ChatGPT.\n\n### 4.3 Detailed Conversational Scenarios\n\n#### Scenario 1 — \"Beach trip in June\" (vibe + month → carousel)\n\n> **Traveler:** Where should we go for a beach trip in June? Just the two of us.\n\nApp calls `discover_getaways(vibe=\"beach\", month=\"June\", travelers=2)`.\n\n> **ChatGPT:** Here are Acme's getaways shaped for a beach trip in June, for two —\n> *[renders `DiscoveryCarousel`]*\n> **Coral Bay** stands out for a relaxed June trip: calm swimming coves and a walkable old town, from **$890**, and June sits right in its May–Sep window. Tap **Shortlist** on any you like, and I'll hold it. When you're ready, **Continue on Acme** to pick dates and book.\n\n**Subtlety:** the model *narrates which fit* (Coral Bay for beach), but the carousel shows Acme's full catalog — the tool returns all four; it never filters on the input (that's the honest, grounded behavior, §6). Prices are shown \"from,\" never as quotes.\n\n#### Scenario 2 — \"Cheapest you'd recommend\" (budget-first)\n\n> **Traveler:** Honestly, what's the cheapest one you'd actually recommend for two?\n\nApp calls `discover_getaways(vibe=\"culture\", month=\"June\", travelers=2)` (model maps \"cheapest, recommend\" to browsing the catalog).\n\n> **ChatGPT:** *[renders `DiscoveryCarousel`]*\n> On price, **Old Quarter** starts lowest at **from $640** — a dense museum district and food halls, all walkable, best Apr–Oct so June works well. It's the value pick without feeling like a compromise. Shortlist it, or want me to lean warmer/beachier?\n\n**Subtlety:** the recommendation is tied to Acme's real starting prices ($640 Old Quarter < $890 Coral Bay < $980 Harbor City < $1,120 Monte Alto), stated honestly as \"from,\" and the model offers to re-shape the vibe rather than pretend to filter.\n\n#### Scenario 3 — \"Somewhere good in December\" (season-first)\n\n> **Traveler:** We've got a week in December — where's actually good then?\n\nApp calls `discover_getaways(vibe=\"mountains\", month=\"December\", travelers=2)`.\n\n> **ChatGPT:** *[renders `DiscoveryCarousel`]*\n> December points straight at **Monte Alto** — it's the one destination whose best-months window is **Dec–Mar** (ski-in village, beginner slopes, long groomed runs), from **$1,120**. The others read better in warmer months. Want me to shortlist Monte Alto and get you to Acme to check December dates?\n\n**Subtlety:** the app grounds the seasonal call in the catalog's actual `bestMonths` values; it doesn't invent weather claims. Real availability for December is Acme's to confirm — the app says so.\n\n#### Scenario 4 — \"Shortlist that one\" (widget-only write)\n\n> **Traveler:** Shortlist Coral Bay.\n\nInside the widget, tapping **Shortlist** calls `shortlist_getaway(destination=\"Coral Bay\")`.\n\n> **ChatGPT:** *[carousel updates the card to \"Shortlisted\"; status line reads \"Shortlisted Coral Bay.\"]*\n> Held. Coral Bay's your pick — shortlist another to compare, or **Continue on Acme** when you're ready to book it for June, for two.\n\n**Subtlety:** shortlisting is a **local, non-destructive write** (widget state), not a booking or an account action. It's the \"I like this one\" gesture that sets up the handoff; nothing leaves the app.\n\n#### Scenario 5 — \"Actually, a city break\" (vibe switch → re-run)\n\n> **Traveler:** Hmm, actually more of a city break. What've you got?\n\nApp re-calls `discover_getaways(vibe=\"city\", month=\"June\", travelers=2)`; the carousel re-renders.\n\n> **ChatGPT:** *[renders `DiscoveryCarousel`]*\n> Switching to a city vibe — **Harbor City** is the fit: waterfront nightlife and day-trip islands a short ferry away, from **$980**, best Sep–Nov (so June's shoulder-season, still lively). Shortlist it, or keep exploring?\n\n**Subtlety:** the trip **re-shapes on a natural-language change** — the model updates the `vibe` input and re-invokes discovery. Month and party size persist so the traveler isn't re-asked.\n\n#### Scenario 6 — \"Continue on Acme\" (HANDOFF — the funnel boundary)\n\n> **Traveler:** Let's do Coral Bay. Book it.\n\nInside the widget, **Continue on Acme** calls `create_handoff(destination=\"coral_bay\", destinationName=\"Coral Bay\", month=\"June\", travelers=2)`, then opens the returned URL.\n\n> **ChatGPT:** *[opens `https://book.acme.example/plan?dest=coral_bay&month=June&pax=2&src=chatgpt`]*\n> Opening Acme with Coral Bay, June, two travelers pre-filled — pick your exact dates and finish booking there. Booking and payment happen on Acme, never in chat.\n\n**This is the funnel boundary.** The deep link carries the shaped trip (`dest` · `month` · `pax`) plus the `src=chatgpt` attribution. Acme's site takes it from there: real dates, live inventory, payment. The app's job — turning a fuzzy feeling into a specific, shaped, ready-to-book getaway — is done.\n\n---\n\n## 5. UI Widget Specifications (OpenAI Apps SDK Compliant)\n\n> The app has **one** widget, `DiscoveryCarousel`, authored as a React `view` on the `discover_getaways` `tool`. It follows the OpenAI Apps SDK UI Guidelines (system fonts, monochrome outlined icons, WCAG AA, ≤2 actions per card, no nested scroll). Styling uses Noodle Seed's server-level `branding` tokens and CSS cascade layers — not app-specific global CSS. Acme's accent teal is restricted to the primary CTA, the logo mark, and the \"Shortlisted\" state only.\n\n### 5.1 Design System Compliance\n\nBranding is declared once, server-side, and the runtime injects it into the widget as semantic tokens:\n\n```ts\nbranding: {\n name: 'Acme Getaways',\n accent: '#0EA5A4', // teal — CTAs / logo / \"Shortlisted\" ONLY\n surface: '#F0FDFA', // light surface tint\n surfaceDark: '#0B1B1B', // dark-mode surface\n radius: 'lg',\n density: 'comfortable',\n}\n```\n\n| Category | Source | Notes |\n|----------|--------|-------|\n| Text / background / border | Host system tokens (light + dark) | Neutral ChatGPT surface; the widget reads `theme` from `useLayout()` |\n| Accent | `branding.accent` (`#0EA5A4`) | **Primary CTA + logo mark + \"Shortlisted\" badge ONLY** |\n| Surface | `branding.surface` / `surfaceDark` | Light/dark card tint; no full-bleed brand gradient |\n| Radius / density | `branding.radius: lg` / `density: comfortable` | System scale, comfortable spacing |\n| Icons | Monochrome outlined (compass, external-link) | Inline SVG, single stroke, no fills |\n\n**Rules enforced:** system fonts only; monochrome outlined icons; WCAG AA contrast in light *and* dark; no nested scroll (the carousel is a single horizontal track, no scroll-within-scroll); **≤2 actions per card** (each card has one **Shortlist** toggle; the shell has one **Continue on Acme** primary); prices always shown as **\"from $X\"**, never as a quote; the standing line **\"Booking and payment happen on acme.example — never inside chat.\"** is always visible. Verify all of this with `noodle check --target chatgpt` before submission.\n\n### 5.2 Display Mode Strategy\n\n| User Intent | Display Mode | Compliance Note |\n|-------------|-------------|-----------------|\n| Discover / shortlist getaways | **Inline Carousel** (`DiscoveryCarousel`) | Horizontal track of ≤4 cards; 1 action/card; auto-fit inline |\n| Scan the full catalog at once | **Fullscreen** (same component, `displayMode=\"fullscreen\"`) | The component reads `displayMode` and shows a \"Fullscreen\" chip; same data, roomier layout |\n\n> **No Picture-in-Picture, no in-chat checkout.** There is no live session to pin and no transaction in-app, so PiP is unnecessary and a payment/checkout widget is deliberately **not** built — booking is off-app by design. Fullscreen is the same carousel component, not a second widget.\n\n### 5.3 Widget Specification\n\n#### `DiscoveryCarousel` — Inline Carousel ★ core (the only widget)\n**Purpose:** Turn a shaped trip (vibe · month · travelers) into a scannable set of grounded Acme destinations the traveler can shortlist and then carry to Acme to book.\n\n| Spec | Value |\n|------|-------|\n| Header | Compass logo mark, \"Acme Getaways\" title, status subtitle (e.g. \"Shortlisted Coral Bay.\"), a mode chip (\"Discover\" / \"Fullscreen\") |\n| Per card | Destination **name**, **from $price**, **region · best {months}**, Acme's **\"why\"** line (grounded copy), one **Shortlist** toggle (→ \"Shortlisted\" when active) |\n| Shell action | One primary **Continue on Acme · {selected}** CTA (opens the handoff), disabled while pending (\"Opening Acme…\") |\n| Actions per card | **1** (Shortlist) — well within the ≤2 inline limit |\n| Standing note | \"Booking and payment happen on acme.example — never inside chat.\" (always shown) |\n| Model context | A `data-llm` summary line (\"Acme Getaways discovery: N options for {month}, {travelers} traveler(s); shortlisted {name}\") so the model can narrate accurately |\n| Edge states | Empty catalog → status prompts \"Pick a getaway to continue.\"; pending handoff → CTA shows \"Opening Acme…\"; dark mode → `surfaceDark` tint |\n\n---\n\n## 6. Tool Definitions (App Backend)\n\nThree tools, all thin and deterministic over Acme's **own** catalog data. No tool requires a user credential, and **no tool invents a place, price, or best-month** — the catalog is returned verbatim.\n\n### Tool 1: `discover_getaways` ★ (tool with widget)\n- **In:** `vibe (\"beach\"|\"mountains\"|\"culture\"|\"city\", default \"beach\")`, `month (calendar month, default \"June\")`, `travelers (int ≥1, default 2)`\n- **Out:** `{ status, vibe, month, travelers, options[] }` where each option is `{ id, name, region, vibe, priceFrom, bestMonths, why }`\n- **Behavior:** returns the **full curated catalog** verbatim and renders `DiscoveryCarousel`; the model narrates which options fit the stated vibe. It deliberately does **not** filter on the input — filtering a returned catalog on an input value is connector/flow work, not a tool's job, and returning everything keeps the app honest and lets the traveler switch vibe without a dead end. `read-only` annotation. Host status copy: \"Finding getaways…\" → \"Getaways ready\".\n\n### Tool 2: `shortlist_getaway` (tool for widget)\n- **In:** `destination (string)`, `note (string, default \"\")`\n- **Out:** `{ status, destination, note }`\n- **Behavior:** records the traveler's shortlisted destination from inside the carousel. Widget-only (called by the view, not opened by the model). `local-action`, non-destructive — it's a \"hold this one\" gesture in widget state, not a booking or an account write.\n\n### Tool 3: `create_handoff` ★ (open-link tool)\n- **In:** `destination (url-safe id slug, e.g. \"coral_bay\")`, `destinationName (display name)`, `month (calendar month)`, `travelers (int ≥1)`\n- **Out:** `{ status, destination, summary, handoffUrl }`\n- **Behavior:** builds the signed Acme booking deep link carrying the shaped trip:\n `https://book.acme.example/plan?dest=${destination}&month=${month}&pax=${travelers}&src=chatgpt`\n Every value is already URL-safe (id slug · month enum · integer), so it is substituted directly — the tool never URL-encodes, transforms, or filters an input. `open-action` annotation; the domain is declared in `handoff.allowedDomains` so ChatGPT opens it without a safe-link warning. See §9.\n\n> **No live inventory, no pricing math, no AI in the tool path.** Discovery returns static curated data; the handoff is string substitution. The model does the language and the routing; the tools supply grounded truth and the signed link.\n\n---\n\n## 7. Conversation Design Principles\n\n### 7.1 Tone of Voice\nWarm, concise, and honest — a well-traveled friend who knows Acme's catalog cold. It makes a real recommendation (\"Old Quarter is the value pick\"), names the trade-off, and is candid about what it *can't* do (confirm December dates, quote an exact price) because that's Acme's job. No hype, no invented superlatives.\n\n### 7.2 Guardrails (non-negotiable)\n- **Never invent a destination, price, or best-month.** Only the four catalog entries exist; all values are Acme's, shown verbatim.\n- **Always say \"from $X,\" never \"$X.\"** Starting prices are not quotes; the dated price is computed on Acme.\n- **Never imply an in-chat booking, date, or payment.** The standing note is always visible; the CTA always says \"Continue on Acme.\"\n- **Ground seasonal claims in `bestMonths` only** — no fabricated weather or crowd claims.\n- **Attribution is honest, not hidden.** The `src=chatgpt` tag measures the funnel; it carries no PII.\n\n### 7.3 Memory Strategy\nLightweight, per-conversation: the current trip shape (vibe · month · travelers, so suggestions stay consistent), the shortlisted destination, and rejected vibes. No PII, no account linkage. The `chosen` selection lives in widget view-state so it survives re-renders within the session.\n\n### 7.4 Multi-Turn Intelligence\nDiscovery is iterative. The app holds the trip shape across turns; **re-runs `discover_getaways` when vibe, month, or party size changes**; persists the values the traveler didn't change; and proactively offers the next step (shortlist → continue on Acme). The model routes and phrases; the tools supply the catalog and the link.\n\n---\n\n## 8. End-to-End User Journey Map\n\n**Phase 1 — Frame the feeling (first 10–20s).** Traveler states a fuzzy intent; `discover_getaways` returns four grounded options. *Emotional beat: \"these are real places, with real reasons.\"*\n\n**Phase 2 — Compare & shortlist (20–60s).** Traveler scans on price / best-months / reason, switches vibe if the mood changes, taps **Shortlist**. *Emotional beat: \"this one — I like this one.\"*\n\n**Phase 3 — Decide (10–20s).** The shortlisted getaway, shaped with month and party size, is the thing to act on. *Emotional beat: confidence in the pick.*\n\n**Phase 4 — Handoff (1 tap).** **Continue on Acme** opens the booking flow pre-filled. *Emotional beat: \"and now I just pick dates.\"*\n\n**Phase 5 — Off-app.** Dates, inventory, and payment on `book.acme.example`. **Outside our scope by design.**\n\n**Phase 6 — Return (next trip).** A new conversation re-frames a new feeling; the loop repeats for the next getaway.\n\n---\n\n## 9. Handoff Architecture (Deep Dive) ★\n\nThe handoff *is* the product boundary — and here it is a **single, signed, attributable deep link**, not a two-track decision. Simplicity is the point.\n\n### 9.1 What must be true of the handoff\n\n1. **Zero credentials cross the boundary.** ChatGPT never holds an Acme login or a payment method. The traveler authenticates and pays on Acme.\n2. **The shaped trip is preserved.** `dest` (id slug), `month`, and `pax` (party size) travel in the URL so nothing is re-typed on Acme.\n3. **Attribution is attached.** `src=chatgpt` lets Acme credit the session, the handoff, and the downstream booking to the ChatGPT funnel — the commercial heart of the deal, and it carries no PII.\n4. **The domain is allow-listed.** `book.acme.example` (and `acme.example`) are declared in `handoff.allowedDomains`; the compiler derives ChatGPT's redirect domains from that list, so the link opens without a safe-link interstitial.\n5. **Values are already URL-safe.** The id slug, month enum, and integer party size need no encoding — the tool substitutes them directly (encoding an input would break substitution).\n\n### 9.2 The URL pattern\n\n```\nhttps://book.acme.example/plan?dest=coral_bay&month=June&pax=2&src=chatgpt\n```\n\n`dest` = catalog id slug · `month` = calendar month · `pax` = party size · `src=chatgpt` = attribution. One pattern, every destination.\n\n### 9.3 Recommendation & open questions for Acme\n\nShip against Acme's **existing** `book.acme.example/plan` entry point so nothing blocks launch. In parallel, confirm with Acme:\n\n- The **attribution parameter** name/format (we assume `src=chatgpt`) and whether a finer campaign/session tag is wanted.\n- Whether `book.acme.example/plan` should **pre-select dates** from `month` or just default the month filter (today the app passes the month; Acme owns exact dates).\n- Whether a **deep-linked destination page** (`/plan/coral_bay`) is preferred over a query param — a config change, not a UX rework.\n\n> **Design stance:** `create_handoff` emits a `url` + `summary`. Upgrading the query param to a path, or adding a session tag, is a config change — the unknowns don't block the build.\n\n### 9.4 Edge cases at the boundary\n\n- **Destination sold out / month unavailable:** Acme's site owns this; the app never asserts availability, only \"from\" pricing and best-months.\n- **Mobile:** the deep link opens Acme's site/app; the shaped trip carries regardless.\n- **No shortlist yet:** the CTA continues with the currently-selected (first) card, so the handoff always has a destination.\n\n---\n\n## 10. Demo Scope Recommendation\n\n### 10.1 MVP demo features (priority order)\n1. `discover_getaways` + `DiscoveryCarousel` — fuzzy feeling → four grounded options (the \"these are real\" moment).\n2. `shortlist_getaway` — tap to hold a getaway (the \"this one\" moment).\n3. Re-run discovery on a vibe switch — beach → city, carousel re-renders (the \"it adapts\" moment).\n4. `create_handoff` — Continue on Acme, signed link with `src=chatgpt` (the funnel boundary).\n\n### 10.2 Demo script (≈90 seconds)\n1. \"Beach trip in June, two of us.\" → `DiscoveryCarousel`: Coral Bay leads, from $890, May–Sep fit. *(discovery)*\n2. \"What's the cheapest you'd recommend?\" → model points to Old Quarter, from $640. *(grounded recommendation)*\n3. \"Actually, a city break.\" → carousel re-renders to feature Harbor City. *(vibe switch)*\n4. Tap **Shortlist** on Harbor City → status \"Shortlisted Harbor City.\" *(the pick)*\n5. **Continue on Acme** → opens `book.acme.example/plan?dest=harbor_city&month=June&pax=2&src=chatgpt`. *(the handoff — the whole point)*\n\nThe demo's arc: *a vague mood → four real, priced, reasoned getaways → one shortlisted → one tap to book on Acme.*\n\n---\n\n## 11. Technical Architecture (High Level)\n\n```\nChatGPT ──tool calls──► Noodle Seed runtime (server 'acme_discovery')\n │ app-owned curated catalog (static data)\n ├──► discover_getaways (tool → DiscoveryCarousel)\n ├──► shortlist_getaway (tool, local write)\n ├──► create_handoff (open-link → signed Acme deep link)\n └──► React view bundle (DiscoveryCarousel, branding tokens, CSP)\n │\n (traveler taps Continue) ──► book.acme.example/plan?…&src=chatgpt\n (dates · inventory · payment on Acme)\n```\n\n- **Stateless hot path.** Discovery returns static curated data; the handoff is string substitution. No AI in the tool path.\n- **Grounding discipline.** Every place/price/best-month is Acme's own data, returned verbatim; the model narrates, it never invents.\n- **CSP.** The widget's `connectDomains` / `resourceDomains` / `frameDomains` are scoped to `acme.example`; the handoff domains are declared in `handoff.allowedDomains`.\n- **Attribution.** `src=chatgpt` injected at `create_handoff`, logged (PII-free) for funnel analytics.\n\n---\n\n## 12. Success Metrics\n\n| Metric | What it tells us |\n|--------|------------------|\n| **Discovery rate** (session → carousel rendered) | Top-of-funnel reach |\n| **Shortlists per session** | Engagement with the core gesture |\n| **Vibe re-runs per session** | Depth of deliberation (the loop working) |\n| **Handoff rate** (session → Continue on Acme) | In-app funnel conversion |\n| **`src=chatgpt` bookings on Acme** | The revenue number — bookings attributed to the ChatGPT funnel |\n| **Handoff→booking rate** (Acme-side) | Quality of the shaped demand we send |\n\nThe cleanest experiment: measure **ChatGPT-attributed handoffs → completed Acme bookings** — the conversion that justifies the app and prices the funnel.\n\n---\n\n## 13. Future Enhancements (Post-Launch)\n\n- **Deep-linked destination pages** (`/plan/coral_bay`) if Acme prefers a path over a query param (§9.3).\n- **Month → date pre-selection** on Acme once the booking flow accepts a target window.\n- **Richer shortlist** — hold multiple getaways and compare them side by side before the handoff.\n- **Live catalog feed** — swap the static catalog for an Acme feed so new destinations and \"from\" prices update without a redeploy.\n- **Seasonal nudges** — surface the in-season destination first when the stated month maps cleanly to one `bestMonths` window.\n- **Fullscreen catalog browse** — a roomier grid of the full catalog (same component, `displayMode=\"fullscreen\"`) for \"show me everything.\"\n\n---\n\n## Appendix A — Funnel Boundary Cheat-Sheet\n\n| Stage | Where it happens | Auth needed? |\n|-------|------------------|--------------|\n| Frame the feeling (vibe · month · travelers) | ChatGPT (model) | No |\n| Discover getaways | App → `discover_getaways` | No |\n| Shortlist a getaway | App → `shortlist_getaway` (widget state) | No |\n| Re-shape the trip (change vibe/month/pax) | App → re-run `discover_getaways` | No |\n| **Handoff** | App → `create_handoff` (signed deep link) | No |\n| Pick dates / check availability | **Acme** (`book.acme.example`) | **Yes (at Acme)** |\n| Pay & confirm booking | **Acme** | **Yes (at Acme)** |\n\nEverything above the bold line is ours and runs without a single user credential. Everything below is Acme's. The **Continue on Acme** CTA is the line — and it points one way: *book it on Acme.*\n\n---\n\n## Appendix B — Source Notes (for the build team)\n\nAcme Getaways is a **fictional** brand; the catalog, prices, best-months, and regions in this document are the app's own curated data and are internally consistent with `src/server.ts` and the `DiscoveryCarousel` view. \"From\" prices are per-person starting figures, not quotes — the `create_handoff` deep link exists precisely so exact dates and pricing are always resolved on Acme's own booking flow, never asserted in chat. Tool names (`discover_getaways`, `shortlist_getaway`, `create_handoff`), the widget name (`DiscoveryCarousel`), and the handoff domain (`book.acme.example`) match the implementation exactly; verify Apps SDK compliance with `noodle check --target chatgpt` at build time.\n" },
@@ -176,6 +176,7 @@ export const COMPILE_ERROR_CODES = [
176
176
  'assistant_capability_unknown',
177
177
  'assistant_public_user_reference',
178
178
  'assistant_public_effect_unconfirmed',
179
+ 'tool_anonymous_requires_identity',
179
180
  'interaction_action_invalid',
180
181
  'interaction_field_unknown',
181
182
  'interaction_field_control_mismatch',
@@ -122,6 +122,7 @@ export function renderCustomerAuthAuthoringSections() {
122
122
  '```',
123
123
  '',
124
124
  "Use `authorization.discovery: 'public'` on a nonempty scope/role rule to expose its MCP descriptor before sign-in. Omission and explicit 'authorized' preserve filtered discovery and compile identically. Visibility grants no execution or product-skill eligibility; scopes remain ALL and roles ANY. Noodle derives securitySchemes; never author them.",
125
+ "On `public` and `mixed` MCP, anonymous callers may run only tools marked `annotations.readOnly()` or declared `anonymous: 'allowed'`; any other tool answers them with a 401 sign-in challenge in `mixed` and 403 in `public`. Local `noodle dev` treats you as the caller and does not apply this rule, so check the hosted app before release. Add `anonymous: 'allowed'` only to a low-risk handoff such as a contact or callback request, never to a tool that reads `user` or declares `authorization` (compile error `tool_anonymous_requires_identity`). The website assistant keeps its own `publicWebsite({ capabilities })` allow-list.",
125
126
  'For mixed customer preview, keep server customerAuth and adopt on the same org/app/env, endpoint, issuer and audience; keep the separate public Help endpoint during the pilot, and never create overlapping active issuer/audience ownership. Before any mixed deploy or access change, leave only Help unrestricted, add authorization to every customer tool, and validate plus preview with `noodle dev --access mixed`. For an existing customer-only app, first run `noodle deployments list --org <org> --app <app> --env <env> --json` and inspect an inactive `customers` record for the exact server version. If none exists, record the active ID, redeploy the unchanged secured source to the same version with `--access customers`, then list and inspect again to prove that ID is now inactive; recording it alone preserves nothing. Next deploy the policy-prepared source with `--access customers`, verify the original rollback record remains inactive, and only then run `noodle access set mixed ... --version <version>`. On pilot failure use `noodle rollback <deployment-id> --org <org> --app <app> --env <env> --reason <text>`. This app-history rollback differs from the compatible hosted service-release floor. Invalid supplied credentials fail, with no platform-human fallback. Broker exchange remains required. Local Devtools retries after successful sign-in and leaves Help available after cancellation without executing the protected tool. After deployment, test anonymous discovery, sign-in, cancellation, authenticated retry and expiry in the actual host before customer cutover; local/SDK checks do not prove ChatGPT or another host.',
126
127
  '',
127
128
  'Role values are trusted only from the explicitly configured claim path (or the platform-private bridge role claim). Direct OIDC scopes default to standard `scope`, `scp`, or `scopes` claims unless `claims.scopes` is configured. Embedded-assistant backends pass verified `user.roles` and `user.scopes` separately during `createAssistantSession(...)`; page context never grants either. Claim values must be a string or string array; malformed or oversized values fail closed. Tools retain authored order in `tools/list`; protected descriptors are omitted unless explicitly public, and an unauthorized direct call is still denied before argument validation or connector execution. Scope denials use MCP OAuth step-up metadata without disclosing role names.',
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noodle-borg/agent-kit",
3
- "version": "0.110.0",
3
+ "version": "0.111.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "engines": {
@@ -137,6 +137,12 @@ export interface ToolOptions {
137
137
  readonly description: string;
138
138
  /** Verified caller claims required to invoke this tool; discovery is restricted by default. */
139
139
  readonly authorization?: ToolAuthorizationOptions;
140
+ /**
141
+ * Let anonymous callers of a public or mixed MCP app run this tool although it can change something.
142
+ * By default they must sign in for any tool not marked `readOnly()`. Use it for low-risk handoffs such
143
+ * as a contact or callback request; never on a tool that reads `user` or declares `authorization`.
144
+ */
145
+ readonly anonymous?: 'allowed';
140
146
  /** Designate this ordinary zero-input MCP tool as the application context provider. */
141
147
  readonly contextProvider?: true;
142
148
  readonly input: JsonSchema | z.ZodType;
@@ -145,6 +145,7 @@ class ServerBuilder {
145
145
  },
146
146
  }
147
147
  : {}),
148
+ ...(tool.options.anonymous === 'allowed' ? { anonymous: 'allowed' } : {}),
148
149
  ...(tool.options.contextProvider ? { contextProvider: true } : {}),
149
150
  inputSchema: toJsonSchema(tool.options.input, 'input'),
150
151
  ...(tool.options.output ? { outputSchema: toJsonSchema(tool.options.output) } : {}),
@@ -51,6 +51,9 @@ export function emitCatalogArtifactSurfaces({ manifest, catalog, structuralAmbie
51
51
  },
52
52
  }
53
53
  : {}),
54
+ ...('anonymous' in s.tool && s.tool.anonymous === 'allowed'
55
+ ? { anonymous: 'allowed' }
56
+ : {}),
54
57
  inputSchema,
55
58
  ...(s.outputSchema ? { outputSchema: withDialect(s.outputSchema) } : {}),
56
59
  ...(s.tool.annotations ? { annotations: s.tool.annotations } : {}),
@@ -136,6 +136,11 @@ export interface ArtifactTool {
136
136
  readonly title?: string;
137
137
  readonly description: string;
138
138
  readonly authorization?: ArtifactToolAuthorization;
139
+ /**
140
+ * Anonymous callers of a public or mixed MCP app may run this tool although it is not read-only.
141
+ * Without it, such a tool asks anonymous MCP callers to sign in first.
142
+ */
143
+ readonly anonymous?: 'allowed';
139
144
  readonly inputSchema: JsonSchema;
140
145
  readonly outputSchema?: JsonSchema;
141
146
  readonly annotations?: Record<string, unknown>;
@@ -65,7 +65,8 @@
65
65
  /** `0.21.0`: protected tools may opt into public descriptor discovery without execution permission. */
66
66
  /** `0.22.0`: governed public-page extraction declarations and ordinary generated tools. */
67
67
  /** `0.23.0`: bounded `collect` interaction metadata keyed by opener tool (ADR 0240); never MCP-visible. */
68
- export declare const ARTIFACT_SCHEMA_VERSION = "0.23.0";
68
+ /** `0.24.0`: tools may carry `anonymous: 'allowed'`, opening a non-read-only tool to anonymous MCP callers. */
69
+ export declare const ARTIFACT_SCHEMA_VERSION = "0.24.0";
69
70
  /** mimeType for an MCP Apps UI resource (SEP-1865). Widgets are served under this profile. */
70
71
  export declare const MCP_APP_MIME_TYPE = "text/html;profile=mcp-app";
71
72
  //# sourceMappingURL=version.d.ts.map
@@ -65,7 +65,8 @@
65
65
  /** `0.21.0`: protected tools may opt into public descriptor discovery without execution permission. */
66
66
  /** `0.22.0`: governed public-page extraction declarations and ordinary generated tools. */
67
67
  /** `0.23.0`: bounded `collect` interaction metadata keyed by opener tool (ADR 0240); never MCP-visible. */
68
- export const ARTIFACT_SCHEMA_VERSION = '0.23.0';
68
+ /** `0.24.0`: tools may carry `anonymous: 'allowed'`, opening a non-read-only tool to anonymous MCP callers. */
69
+ export const ARTIFACT_SCHEMA_VERSION = '0.24.0';
69
70
  /** mimeType for an MCP Apps UI resource (SEP-1865). Widgets are served under this profile. */
70
71
  export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app';
71
72
  //# sourceMappingURL=version.js.map
@@ -19,7 +19,7 @@ import { compileToolInteractions } from './manifest/interaction-validation.js';
19
19
  import { parseManifestDocument } from './manifest/parse-document.js';
20
20
  import { manifestSchema } from './manifest/schema.js';
21
21
  import { resolveSchemaUses } from './manifest/schema-refs.js';
22
- import { validateWebsiteProjection } from './manifest/website-projection.js';
22
+ import { validateAnonymousTools, validateWebsiteProjection, } from './manifest/website-projection.js';
23
23
  import { compileState } from './state-handles.js';
24
24
  import { suggestionFields } from './suggest.js';
25
25
  import { expandWebCapabilities } from './web-capabilities.js';
@@ -418,6 +418,7 @@ export function compileManifest(raw, options = {}) {
418
418
  errors,
419
419
  });
420
420
  const toolInteractions = compileToolInteractions(manifest.tools, artifactTools, errors);
421
+ errors.push(...validateAnonymousTools(artifactTools));
421
422
  errors.push(...validateWebsiteProjection(manifest.server.assistant, {
422
423
  tools: artifactTools,
423
424
  resources: artifactResources,
@@ -1,7 +1,7 @@
1
1
  import type { AppPackageArtifactV1 } from '@noodle-borg/app-package';
2
2
  import type { RuntimeArtifact } from './artifact/types.js';
3
3
  import type { PackagedAsset } from './assets.js';
4
- export type CompileErrorCode = 'invalid_context_provider' | 'yaml_parse_error' | 'invalid_shape' | 'invalid_name' | 'duplicate_name' | 'reserved_name' | 'unsupported_manifest_version' | 'reserved_for_future_version' | 'invalid_operation_ref' | 'external_ref' | 'invalid_schema_ref' | 'unknown_schema_ref' | 'schema_ref_conflict' | 'invalid_expression' | 'expr_unknown_root' | 'expr_root_unavailable' | 'expr_operator_not_allowed' | 'expr_if_not_boolean' | 'unknown_step_ref' | 'forward_step_ref' | 'self_step_ref' | 'duplicate_step_id' | 'invalid_fulfilment' | 'invalid_elicitation_schema' | 'invalid_elicitation_flow' | 'invalid_confirmation_flow' | 'arg_type_mismatch' | 'ambient_context_action' | 'duplicate_resource' | 'duplicate_prompt' | 'duplicate_resource_uri' | 'unsupported_uri_template' | 'duplicate_widget' | 'unknown_widget_tool' | 'unknown_widget_action_tool' | 'duplicate_widget_tool' | 'invalid_widget_binding' | 'invalid_widget_state_handle' | 'widget_html_too_large' | 'widget_html_total_too_large' | 'invalid_asset' | 'invalid_capability_requirement' | 'state_secret_field' | 'invalid_knowledge' | 'knowledge_unhashed' | 'invalid_managed_collection' | 'invalid_variable_declaration' | 'invalid_managed_collection_source' | 'unknown_connector_alias' | 'connector_not_in_catalog' | 'unknown_operation' | 'connector_binding_required' | 'ambiguous_nested_connector_binding' | 'invalid_connector_call_graph' | 'unsupported_credential_profile' | 'credential_scope_mismatch' | 'credential_audience_mismatch' | 'customer_endpoint_auth_required' | 'customer_endpoint_mapping_required' | 'customer_endpoint_unknown_mapping' | 'customer_endpoint_bridge_unsupported' | 'assistant_messaging_unsupported' | 'assistant_capability_unknown' | 'assistant_public_user_reference' | 'assistant_public_effect_unconfirmed' | 'interaction_action_invalid' | 'interaction_field_unknown' | 'interaction_field_control_mismatch' | 'interaction_field_missing' | 'interaction_initial_value_unknown' | 'interaction_consent_invalid' | 'interaction_expiry_invalid' | 'channel_dependency_missing' | 'channel_requirement_unsupported' | 'customer_endpoint_action_unsupported' | 'customer_endpoint_surface_unsupported' | 'customer_endpoint_credential_source_unsupported' | 'customer_endpoint_policy_conflict' | 'customer_endpoint_routing_inconsistent' | 'unused_connector_alias' | 'arg_mismatch' | 'agent_guide_invalid' | 'agent_guide_duplicate_workflow' | 'agent_guide_duplicate_example' | 'agent_guide_example_workflow_missing' | 'agent_guide_capability_missing' | 'agent_guide_capability_kind' | 'app_package_sensitive_content';
4
+ export type CompileErrorCode = 'invalid_context_provider' | 'yaml_parse_error' | 'invalid_shape' | 'invalid_name' | 'duplicate_name' | 'reserved_name' | 'unsupported_manifest_version' | 'reserved_for_future_version' | 'invalid_operation_ref' | 'external_ref' | 'invalid_schema_ref' | 'unknown_schema_ref' | 'schema_ref_conflict' | 'invalid_expression' | 'expr_unknown_root' | 'expr_root_unavailable' | 'expr_operator_not_allowed' | 'expr_if_not_boolean' | 'unknown_step_ref' | 'forward_step_ref' | 'self_step_ref' | 'duplicate_step_id' | 'invalid_fulfilment' | 'invalid_elicitation_schema' | 'invalid_elicitation_flow' | 'invalid_confirmation_flow' | 'arg_type_mismatch' | 'ambient_context_action' | 'duplicate_resource' | 'duplicate_prompt' | 'duplicate_resource_uri' | 'unsupported_uri_template' | 'duplicate_widget' | 'unknown_widget_tool' | 'unknown_widget_action_tool' | 'duplicate_widget_tool' | 'invalid_widget_binding' | 'invalid_widget_state_handle' | 'widget_html_too_large' | 'widget_html_total_too_large' | 'invalid_asset' | 'invalid_capability_requirement' | 'state_secret_field' | 'invalid_knowledge' | 'knowledge_unhashed' | 'invalid_managed_collection' | 'invalid_variable_declaration' | 'invalid_managed_collection_source' | 'unknown_connector_alias' | 'connector_not_in_catalog' | 'unknown_operation' | 'connector_binding_required' | 'ambiguous_nested_connector_binding' | 'invalid_connector_call_graph' | 'unsupported_credential_profile' | 'credential_scope_mismatch' | 'credential_audience_mismatch' | 'customer_endpoint_auth_required' | 'customer_endpoint_mapping_required' | 'customer_endpoint_unknown_mapping' | 'customer_endpoint_bridge_unsupported' | 'assistant_messaging_unsupported' | 'assistant_capability_unknown' | 'assistant_public_user_reference' | 'assistant_public_effect_unconfirmed' | 'tool_anonymous_requires_identity' | 'interaction_action_invalid' | 'interaction_field_unknown' | 'interaction_field_control_mismatch' | 'interaction_field_missing' | 'interaction_initial_value_unknown' | 'interaction_consent_invalid' | 'interaction_expiry_invalid' | 'channel_dependency_missing' | 'channel_requirement_unsupported' | 'customer_endpoint_action_unsupported' | 'customer_endpoint_surface_unsupported' | 'customer_endpoint_credential_source_unsupported' | 'customer_endpoint_policy_conflict' | 'customer_endpoint_routing_inconsistent' | 'unused_connector_alias' | 'arg_mismatch' | 'agent_guide_invalid' | 'agent_guide_duplicate_workflow' | 'agent_guide_duplicate_example' | 'agent_guide_example_workflow_missing' | 'agent_guide_capability_missing' | 'agent_guide_capability_kind' | 'app_package_sensitive_content';
5
5
  export interface CompileError {
6
6
  readonly code: CompileErrorCode;
7
7
  /** Dotted path to the offending location (e.g. `tools.1.name`); empty string for the document root. */
@@ -1278,6 +1278,7 @@ export declare const manifestV2Schema: z.ZodObject<{
1278
1278
  seconds: z.ZodNumber;
1279
1279
  }, z.core.$strict>>;
1280
1280
  }, z.core.$strict>>;
1281
+ anonymous: z.ZodOptional<z.ZodLiteral<"allowed">>;
1281
1282
  }, z.core.$strip>>;
1282
1283
  connectors: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
1283
1284
  id: z.ZodString;
@@ -2591,6 +2592,7 @@ export declare const manifestSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
2591
2592
  seconds: z.ZodNumber;
2592
2593
  }, z.core.$strict>>;
2593
2594
  }, z.core.$strict>>;
2595
+ anonymous: z.ZodOptional<z.ZodLiteral<"allowed">>;
2594
2596
  }, z.core.$strip>>;
2595
2597
  connectors: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
2596
2598
  id: z.ZodString;
@@ -122,6 +122,9 @@ const toolV2Schema = toolSchema.extend({
122
122
  // Additive optional field: a v1.x minor under ADR 0150. Bounded `collect` interaction metadata
123
123
  // (ADR 0240) an opener tool declares for one confirmed action; references are checked in compile().
124
124
  interaction: toolInteractionSchema.optional(),
125
+ // Additive optional field: anonymous callers of a public or mixed MCP app may run this tool although
126
+ // it is not read-only. Tools that read `${user}` or require claims are rejected in compile().
127
+ anonymous: z.literal('allowed').optional(),
125
128
  });
126
129
  /**
127
130
  * An MCP resource. Like a tool, it carries a `fulfilment` (it can return a constant or call connectors);
@@ -45,6 +45,8 @@ export interface WebsiteProjectionSurfaces {
45
45
  }
46
46
  /** A tool touches identity when it reads `${user}` or requires verified claims (ADR 0185). */
47
47
  export declare function anonymousBehavior(tool: ArtifactTool): 'public-safe' | 'requires-identity';
48
+ /** `anonymous: 'allowed'` cannot open a tool that needs to know who the caller is. */
49
+ export declare function validateAnonymousTools(tools: readonly ArtifactTool[]): readonly CompileError[];
48
50
  export declare function validateWebsiteProjection(assistant: AssistantProjectionInput | undefined, surfaces: WebsiteProjectionSurfaces): readonly CompileError[];
49
51
  /** Resolved refs carry the connector id; a shape-only ref is looked up through its declared alias. */
50
52
  export declare function isNativeRecordsOperation(operation: OperationRef, connectors: WebsiteProjectionSurfaces['connectors']): boolean;
@@ -6,6 +6,18 @@ export function anonymousBehavior(tool) {
6
6
  return 'requires-identity';
7
7
  return readsUser(tool.fulfilment) ? 'requires-identity' : 'public-safe';
8
8
  }
9
+ /** `anonymous: 'allowed'` cannot open a tool that needs to know who the caller is. */
10
+ export function validateAnonymousTools(tools) {
11
+ return tools.flatMap((tool, index) => tool.anonymous === 'allowed' && anonymousBehavior(tool) === 'requires-identity'
12
+ ? [
13
+ {
14
+ code: 'tool_anonymous_requires_identity',
15
+ path: `tools.${index}.anonymous`,
16
+ message: `tool "${tool.name}" reads the signed-in user or requires verified claims, so anonymous callers cannot run it; remove anonymous: 'allowed' or stop reading \${user}`,
17
+ },
18
+ ]
19
+ : []);
20
+ }
9
21
  export function validateWebsiteProjection(assistant, surfaces) {
10
22
  if (assistant?.surfaces === undefined)
11
23
  return [];
@@ -308,7 +308,7 @@ export type AdmissionDecision = {
308
308
  } | {
309
309
  readonly allow: false;
310
310
  readonly reason: string;
311
- readonly status?: 403 | 429;
311
+ readonly status?: 403 | 429 | 503;
312
312
  readonly code?: -32001 | -32002 | -32003;
313
313
  readonly policyId?: string;
314
314
  readonly policyVersion?: number;
@@ -1,27 +1,64 @@
1
- import { PUBLIC_RECORD_ADMISSION_DEFAULTS } from '@noodle-borg/admission-limits/portable';
1
+ import { anonymousMcpCounterKey, PUBLIC_MCP_ADMISSION_DEFAULTS, PUBLIC_RECORD_ADMISSION_DEFAULTS, } from '@noodle-borg/admission-limits/portable';
2
2
  const ANONYMOUS_CONSUMER_LIMIT = PUBLIC_RECORD_ADMISSION_DEFAULTS.networkPerHour;
3
3
  const ANONYMOUS_CONSUMER_WINDOW_MS = 60 * 60 * 1000;
4
- export function createAdmissionGate(anonymousLimiter, audit, extraGate) {
4
+ export function createAdmissionGate(options) {
5
+ const { counters, audit, extraGate } = options;
6
+ const now = options.now ?? (() => new Date());
7
+ const anonymousLimiter = options.anonymousLimiter ?? new AnonymousConsumerLimiter(now);
8
+ const deny = async (context, decision) => {
9
+ await emitAdmissionDeny(audit, context, decision.reason, decision.status);
10
+ return decision;
11
+ };
5
12
  return async (context) => {
6
13
  if (extraGate !== undefined) {
7
14
  const extra = await extraGate(context);
8
- if (!extra.allow) {
9
- await emitAdmissionDeny(audit, context, extra.reason, extra.status);
10
- return extra;
11
- }
15
+ if (!extra.allow)
16
+ return deny(context, extra);
12
17
  }
13
18
  if ((context.accessMode === 'public' || context.accessMode === 'mixed') &&
14
19
  context.subject === undefined &&
15
20
  context.method === 'tools/call') {
21
+ // A ceiling that resets on restart and is unshared across instances is no ceiling (ADR 0201 §7),
22
+ // so anonymous tool calls are refused outright rather than served without one.
23
+ if (!counters.durable)
24
+ return deny(context, { allow: false, reason: 'admission_store_not_durable', status: 503 });
16
25
  const limited = anonymousLimiter.consume(`${context.routeId}:${context.remoteAddress ?? 'unknown'}`);
17
- if (!limited.ok) {
18
- await emitAdmissionDeny(audit, context, limited.code, 429);
19
- return { allow: false, reason: limited.code, status: 429 };
20
- }
26
+ if (!limited.ok)
27
+ return deny(context, { allow: false, reason: limited.code, status: 429 });
28
+ const daily = await consumeAnonymousDaily(counters, context, now());
29
+ if (daily !== undefined)
30
+ return deny(context, daily);
21
31
  }
22
32
  return { allow: true };
23
33
  };
24
34
  }
35
+ async function consumeAnonymousDaily(counters, context, now) {
36
+ const tenant = context.org !== undefined && context.app !== undefined && context.env !== undefined
37
+ ? { org: context.org, app: context.app, env: context.env }
38
+ : undefined;
39
+ let outcome;
40
+ try {
41
+ outcome = await counters.consume({
42
+ key: tenant === undefined
43
+ ? `mcp-anonymous:${context.routeId}`
44
+ : anonymousMcpCounterKey(tenant),
45
+ limit: PUBLIC_MCP_ADMISSION_DEFAULTS.anonymousToolCallsPerDay,
46
+ window: 'day',
47
+ }, now);
48
+ }
49
+ catch {
50
+ return { allow: false, reason: 'admission_unavailable', status: 503 };
51
+ }
52
+ if (outcome.allowed)
53
+ return undefined;
54
+ return {
55
+ allow: false,
56
+ reason: 'anonymous_daily_quota_exceeded',
57
+ status: 429,
58
+ retryAfterSeconds: Math.max(1, Math.ceil((outcome.resetAt.getTime() - now.getTime()) / 1000)),
59
+ resetAt: outcome.resetAt.toISOString(),
60
+ };
61
+ }
25
62
  async function emitAdmissionDeny(audit, context, reasonCode, status = 403) {
26
63
  if (context.org === undefined)
27
64
  return;
@@ -66,6 +66,8 @@ export function managedSolutionManifest(definition, hostedPageOrigin) {
66
66
  description: blueprint.description,
67
67
  inputSchema,
68
68
  outputSchema: RECORD_OPERATION_SIGNATURES.submit_record?.output,
69
+ // Lead capture stays open to anonymous MCP callers; native-record admission bounds it.
70
+ anonymous: 'allowed',
69
71
  annotations: {
70
72
  confirm: true,
71
73
  readOnlyHint: false,
@@ -66,6 +66,8 @@ export async function serveLocalService(options = {}) {
66
66
  });
67
67
  const handler = createServiceHandler(registry, {
68
68
  capabilities,
69
+ // Loopback only (checked above): the one anonymous caller is the author running `noodle dev`.
70
+ mcpAnonymousCallers: 'author',
69
71
  deployGate: {
70
72
  authorize: () => ({
71
73
  ok: true,
@@ -5,7 +5,7 @@ import { dispatchKnowledgeRequest } from '@noodle-borg/knowledge-operations/port
5
5
  import { capabilityOperatorRoute } from '@noodle-borg/managed-capabilities';
6
6
  import { OPENAI_APPS_CHALLENGE_PATH } from '@noodle-borg/module';
7
7
  import { applySecurityHeaders, createMcpRouter, enforceHttps, noopLogger, sendJson, } from '@noodle-borg/transport-http';
8
- import { AnonymousConsumerLimiter, createAdmissionGate } from './admission.js';
8
+ import { createAdmissionGate } from './admission.js';
9
9
  import { createApplicationServingRuntime } from './application-runtime-target.js';
10
10
  import { createArchivePreflight } from './archive-preflight.js';
11
11
  import { ArchiveSweeper, resolveArchiveRetentionDays } from './archive-sweeper.js';
@@ -74,13 +74,17 @@ export function createServiceHandler(registry, options = {}) {
74
74
  const [intentSettings, intentEvents, requestEvents, intentPreviewOrgs] = createObservabilityStores(options);
75
75
  const assistantStore = options.assistantStore ?? new InMemoryAssistantStore();
76
76
  const assistantAppearance = options.assistantAppearance ?? new InMemoryAssistantAppearanceSettingsStore();
77
- const anonymousLimiter = new AnonymousConsumerLimiter();
78
77
  const publicCounters = options.admissionCounters ?? new InMemoryDailyCounterStore();
79
78
  const { businessInformationStore, businessInformationSourceStore, sourceCoordinator } = createBusinessInformationRuntime(registry, options, publicCounters);
80
79
  const moduleProviders = createServiceModuleProviders({
81
80
  options,
82
81
  auditMirror: new StdoutAuditSink(logger),
83
- admissionGate: createAdmissionGate(anonymousLimiter, { emit: (event) => activeAudit.emit(event) }, options.admissionGate),
82
+ admissionGate: createAdmissionGate({
83
+ counters: publicCounters,
84
+ audit: { emit: (event) => activeAudit.emit(event) },
85
+ ...(options.admissionGate === undefined ? {} : { extraGate: options.admissionGate }),
86
+ ...(options.clock === undefined ? {} : { now: options.clock }),
87
+ }),
84
88
  });
85
89
  const moduleHost = moduleProviders.host;
86
90
  businessInformationStore?.configurePrincipalAuthority(moduleHost.platformHumanIdentity);
@@ -103,6 +107,7 @@ export function createServiceHandler(registry, options = {}) {
103
107
  logger,
104
108
  tls,
105
109
  protocolMode: options.mcpProtocolMode ?? 'dual',
110
+ ...(options.mcpAnonymousCallers ? { anonymousCallers: options.mcpAnonymousCallers } : {}),
106
111
  ...(options.mcpRequestState === undefined ? {} : { requestState: options.mcpRequestState }),
107
112
  ...(options.mcpConfirmationNonceLedger === undefined
108
113
  ? {}
@@ -41,7 +41,7 @@
41
41
  "@noodle-borg/managed-capabilities": "0.0.0",
42
42
  "@modelcontextprotocol/sdk": "^1.29.0",
43
43
  "@noodle-borg/admission-limits": "0.0.0",
44
- "@noodle-borg/agent-kit": "0.110.0",
44
+ "@noodle-borg/agent-kit": "0.111.0",
45
45
  "@noodle-borg/app-package": "0.0.0",
46
46
  "@noodle-borg/assistant-gateway": "0.0.0",
47
47
  "@noodle-borg/auth": "0.0.0",
@@ -46,6 +46,11 @@ export interface HttpHandlerOptions {
46
46
  readonly authorizeDataPlaneIdentity?: DataPlaneIdentityAuthorizer;
47
47
  /** Request admission hook after authentication and JSON parsing, before SDK execution. */
48
48
  readonly admissionGate?: AdmissionGate;
49
+ /**
50
+ * `author`: every anonymous caller is the app's own author, as on the loopback `noodle dev` service, so
51
+ * tools that change something run without sign-in. Hosted services never set it.
52
+ */
53
+ readonly anonymousCallers?: 'author';
49
54
  /** Enqueue one scalar-only analytics event after the response is written. */
50
55
  readonly captureRequestEvent?: (event: RequestEventInput) => void;
51
56
  /** Separate, best-effort operator intent stream. Requires `intentCaptureMode: starter-v1`. */
@@ -37,6 +37,7 @@ export function createMcpHttpHandler(target, options = {}) {
37
37
  authentication: resolveTargetAuthentication({}, options.verifyOwnerToken),
38
38
  authorizeDataPlaneIdentity: options.authorizeDataPlaneIdentity,
39
39
  admissionGate: options.admissionGate,
40
+ anonymousCallers: options.anonymousCallers,
40
41
  captureRequestEvent: options.captureRequestEvent,
41
42
  captureIntentEvent: options.captureIntentEvent,
42
43
  intentCaptureMode: options.intentCaptureMode ?? 'off',
@@ -105,6 +106,7 @@ export function createMcpRouter(lookup, options = {}) {
105
106
  authentication: resolveTargetAuthentication(resolvedTarget, options.verifyOwnerToken),
106
107
  authorizeDataPlaneIdentity: options.authorizeDataPlaneIdentity,
107
108
  admissionGate: options.admissionGate,
109
+ anonymousCallers: options.anonymousCallers,
108
110
  captureRequestEvent: options.captureRequestEvent,
109
111
  captureIntentEvent: options.captureIntentEvent,
110
112
  intentCaptureMode: resolvedTarget.intentCaptureMode ?? 'off',
@@ -27,11 +27,13 @@ export function admissionRpcError(context, decision) {
27
27
  (decision.status === 429
28
28
  ? LEGACY_MCP_ERROR.ADMISSION_QUOTA_EXCEEDED
29
29
  : LEGACY_MCP_ERROR.ADMISSION_DENIED);
30
- const message = code === LEGACY_MCP_ERROR.ADMISSION_QUOTA_EXCEEDED
31
- ? 'Quota exceeded'
32
- : code === LEGACY_MCP_ERROR.ADMISSION_RATE_LIMITED
33
- ? 'Rate limited'
34
- : 'Access denied by policy';
30
+ const message = decision.status === 503
31
+ ? 'Admission unavailable, try again shortly'
32
+ : code === LEGACY_MCP_ERROR.ADMISSION_QUOTA_EXCEEDED
33
+ ? 'Quota exceeded'
34
+ : code === LEGACY_MCP_ERROR.ADMISSION_RATE_LIMITED
35
+ ? 'Rate limited'
36
+ : 'Access denied by policy';
35
37
  const data = { reason: decision.reason };
36
38
  if (decision.policyId !== undefined)
37
39
  data.policyId = decision.policyId;
@@ -159,7 +159,9 @@ export async function serveRequest(req, res, target, auth, routeId, maxBody, all
159
159
  return denied(`admission_denied.${boundedReason(decision.reason)}`, parsed);
160
160
  }
161
161
  }
162
- const authorization = preflightToolAuthorization(parsed, target, protocolContext.caller);
162
+ const authorization = preflightToolAuthorization(parsed, target, protocolContext.caller,
163
+ // On the loopback author loop the anonymous caller is the author, so no public-write rule applies.
164
+ auth.anonymousCallers === 'author' ? undefined : auth.accessMode);
163
165
  for (const observation of authorization.observations) {
164
166
  logger.info('mcp.tool_authorization', {
165
167
  toolName: observation.toolName,
@@ -1,7 +1,8 @@
1
1
  import { evaluateToolAuthorization, TOOL_AUTHORIZATION_DENIED, toolAuthorizationRuleClass, toolAuthorizationRuleFingerprint, } from '@noodle-borg/protocol';
2
2
  import { protectedResourceMetadataUrl, } from './identity-authorization.js';
3
3
  import { rpcMethod, rpcTargetName, safeRpcId } from './request-capture.js';
4
- export function preflightToolAuthorization(parsed, target, caller) {
4
+ const ANONYMOUS_WRITE_MESSAGE = "this tool can change something, so it needs a signed-in caller; anonymous callers may only run tools marked readOnly() or declared with anonymous: 'allowed'";
5
+ export function preflightToolAuthorization(parsed, target, caller, accessMode) {
5
6
  const items = Array.isArray(parsed) ? parsed : [parsed];
6
7
  const observations = [];
7
8
  let denial;
@@ -15,7 +16,10 @@ export function preflightToolAuthorization(parsed, target, caller) {
15
16
  const tool = target.artifact.tools.find((candidate) => candidate.name === toolName);
16
17
  if (tool === undefined)
17
18
  continue;
18
- const decision = evaluateToolAuthorization(tool.authorization, caller);
19
+ const anonymousWrite = anonymousWriteRefused(tool, caller, accessMode);
20
+ const decision = anonymousWrite
21
+ ? { allow: false, reason: 'authentication_required' }
22
+ : evaluateToolAuthorization(tool.authorization, caller);
19
23
  observations.push({
20
24
  toolName,
21
25
  decision: decision.allow ? 'allow' : 'deny',
@@ -27,6 +31,7 @@ export function preflightToolAuthorization(parsed, target, caller) {
27
31
  denial = {
28
32
  requestId: safeRpcId(item).requestId ?? null,
29
33
  decision,
34
+ ...(anonymousWrite ? { anonymousWrite: true } : {}),
30
35
  };
31
36
  }
32
37
  }
@@ -35,11 +40,28 @@ export function preflightToolAuthorization(parsed, target, caller) {
35
40
  ...(denial === undefined ? {} : { denial }),
36
41
  };
37
42
  }
43
+ /**
44
+ * On public MCP, a tool that may change something needs a signed-in caller unless its author opened it
45
+ * to anonymous callers. Hosts and scripts can skip a confirmation, so confirmation is not the guard.
46
+ * Read-only is declared, never assumed: a tool without `readOnlyHint: true` counts as a write.
47
+ * MCP only — the website, WhatsApp and Instagram surfaces apply their own per-surface allow-lists.
48
+ */
49
+ function anonymousWriteRefused(tool, caller, accessMode) {
50
+ return (caller === undefined &&
51
+ (accessMode === 'public' || accessMode === 'mixed') &&
52
+ tool.annotations?.readOnlyHint !== true &&
53
+ tool.anonymous !== 'allowed');
54
+ }
38
55
  export function sendToolAuthorizationDenial(req, res, auth, denial) {
56
+ // A public app has no sign-in to offer, so a challenge would send hosts after one that does not exist.
57
+ if (denial.anonymousWrite && auth.accessMode === 'public') {
58
+ sendError(res, 403, denial.requestId, ANONYMOUS_WRITE_MESSAGE, denial.decision.reason);
59
+ return;
60
+ }
39
61
  const metadata = protectedResourceMetadataUrl(req, auth);
40
62
  if (denial.decision.reason === 'authentication_required') {
41
63
  res.setHeader('WWW-Authenticate', `Bearer realm="noodle", resource_metadata="${metadata}"`);
42
- sendError(res, 401, denial.requestId, 'unauthorized', denial.decision.reason);
64
+ sendError(res, 401, denial.requestId, denial.anonymousWrite ? ANONYMOUS_WRITE_MESSAGE : 'unauthorized', denial.decision.reason);
43
65
  return;
44
66
  }
45
67
  if (denial.decision.reason === 'insufficient_scope') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noodleseed/one",
3
- "version": "0.186.0",
3
+ "version": "0.187.0",
4
4
  "private": false,
5
5
  "description": "Noodle CLI by Noodle Seed — author, run, and deploy declarative MCP servers. Embedding the assistant in your own web app is @noodleseed/assistant.",
6
6
  "license": "Apache-2.0",
@@ -236,7 +236,7 @@
236
236
  "@modelcontextprotocol/client": "2.0.0",
237
237
  "@modelcontextprotocol/server": "2.0.0",
238
238
  "@noodle-borg/admission-limits": "0.0.0",
239
- "@noodle-borg/agent-kit": "0.110.0",
239
+ "@noodle-borg/agent-kit": "0.111.0",
240
240
  "@noodle-borg/app-audit": "0.0.0",
241
241
  "@noodle-borg/app-package": "0.0.0",
242
242
  "@noodle-borg/assistant-gateway": "0.0.0",