@noodleseed/one 0.154.0 → 0.155.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 (22) hide show
  1. package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +1 -1
  2. package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-ref.js +2 -0
  3. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-suggestions.js +5 -3
  4. package/node_modules/@noodle-borg/assistant-gateway/dist/continuity-bounds.d.ts +36 -0
  5. package/node_modules/@noodle-borg/assistant-gateway/dist/continuity-bounds.js +52 -0
  6. package/node_modules/@noodle-borg/assistant-gateway/dist/continuity-store.d.ts +115 -0
  7. package/node_modules/@noodle-borg/assistant-gateway/dist/continuity-store.js +51 -0
  8. package/node_modules/@noodle-borg/assistant-gateway/dist/in-memory-continuity-store.d.ts +26 -0
  9. package/node_modules/@noodle-borg/assistant-gateway/dist/in-memory-continuity-store.js +80 -0
  10. package/node_modules/@noodle-borg/assistant-gateway/dist/model-request.d.ts +1 -0
  11. package/node_modules/@noodle-borg/assistant-gateway/dist/model-request.js +2 -0
  12. package/node_modules/@noodle-borg/assistant-gateway/dist/model-response-values.js +11 -0
  13. package/node_modules/@noodle-borg/assistant-gateway/dist/model-responses.js +1 -10
  14. package/node_modules/@noodle-borg/assistant-gateway/dist/model-stream.js +1 -10
  15. package/node_modules/@noodle-borg/assistant-gateway/dist/portable.d.ts +3 -0
  16. package/node_modules/@noodle-borg/assistant-gateway/dist/portable.js +3 -0
  17. package/node_modules/@noodle-borg/authoring/dist/assistant.d.ts +35 -0
  18. package/node_modules/@noodle-borg/authoring/dist/assistant.js +4 -0
  19. package/node_modules/@noodle-borg/compiler/dist/manifest/branding-schema.js +86 -0
  20. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.d.ts +44 -25
  21. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.js +24 -76
  22. package/package.json +1 -1
@@ -36,7 +36,7 @@ export const BUNDLED_EXAMPLE_FILES = [
36
36
  { relPath: "examples/acme-discovery/src/helpers.ts", content: "import type { ServerDefinition } from '@noodleseed/one';\nimport { generateHelpers } from '@noodleseed/one/react';\n\nexport type AppType = ServerDefinition;\n\nexport const { useCallTool, useLayout, useOpenExternal, useToolInfo, useViewState } =\n generateHelpers<AppType>();\n" },
37
37
  { relPath: "examples/acme-discovery/src/knowledge/faq.txt", content: "ACME GETAWAYS FAQ\n\nQ: Does the assistant book and take payment?\nA: No. The assistant shapes the trip and hands off to Acmes own checkout with a signed link.\n\nQ: How current are prices?\nA: Starting prices come from Acmes live site; the assistant cites the page it used.\n\nQ: Can I compare destinations?\nA: Yes, ask for a shortlist by vibe, month, or budget.\n" },
38
38
  { relPath: "examples/acme-discovery/src/knowledge/product.md", content: "# Acme Getaways product guide\n\nAcme Getaways curates four destination types: beach, mountains, culture, and city escapes. Every listing shows a real starting price and the best months to travel. Bookings, payments, and date selection happen on Acmes own site through a signed handoff link; the assistant never takes payment details.\n\n## Cancellation and support\n\nAll trips can be cancelled free within 48 hours of the handoff. Support runs seven days a week through the chat on book.acme.example.\n" },
39
- { relPath: "examples/acme-discovery/src/server.ts", content: "import {\n annotations,\n authenticatedWebsite,\n connector,\n embeddedAssistant,\n file,\n knowledge,\n noodleManaged,\n publicWebsite,\n secret,\n server,\n site,\n tool,\n variable,\n z,\n} from '@noodleseed/one';\n\n// Acme Getaways is a fictional travel brand. This app is deliberately top-of-funnel: discovery and\n// configuration happen inside ChatGPT; the booking/transaction happens off-app on Acme's own site,\n// reached through a signed, attributable handoff deep link. Destinations are the partner's own\n// catalog (grounding) — the app never invents a place, price, or best-month.\n//\n// Authoring note: a tool `fulfil` is *recorded*, not run as live JS. Inputs flow through as\n// `${input.x}` substitutions when placed directly into an output string; do not transform them\n// (no URL-encoding, arithmetic, or filtering on an input value — those break substitution). The\n// curated catalog below is static data the runtime returns verbatim.\n\nconst catalog = [\n {\n id: 'coral_bay',\n name: 'Coral Bay',\n region: 'Adriatic coast',\n vibe: 'beach',\n priceFrom: 890,\n bestMonths: 'May–Sep',\n why: 'Calm swimming coves and a walkable old town — easy for a relaxed first trip.',\n },\n {\n id: 'monte_alto',\n name: 'Monte Alto',\n region: 'Northern Alps',\n vibe: 'mountains',\n priceFrom: 1120,\n bestMonths: 'Dec–Mar',\n why: 'Ski-in village with beginner slopes and long groomed runs.',\n },\n {\n id: 'old_quarter',\n name: 'Old Quarter',\n region: 'Central Europe',\n vibe: 'culture',\n priceFrom: 640,\n bestMonths: 'Apr–Oct',\n why: 'Dense museum district and food halls, all reachable on foot.',\n },\n {\n id: 'harbor_city',\n name: 'Harbor City',\n region: 'Pacific rim',\n vibe: 'city',\n priceFrom: 980,\n bestMonths: 'Sep–Nov',\n why: 'Waterfront nightlife and day-trip islands a short ferry away.',\n },\n] as const;\n\n// A closed set of URL-safe month values. A `fulfil` cannot url-encode an input (recording would break\n// substitution), so the deep link carries `month` only if it is already safe — the model maps natural\n// phrasing (\"early June\") onto one of these when it fills the tool.\nconst monthEnum = z\n .enum([\n 'January',\n 'February',\n 'March',\n 'April',\n 'May',\n 'June',\n 'July',\n 'August',\n 'September',\n 'October',\n 'November',\n 'December',\n ])\n .default('June');\n\n// The catalog ids are already url-safe slugs. Constrain the handoff `destination` to this closed set so\n// only a real, url-safe id can reach the deep link.\nconst destinationId = z.enum(['coral_bay', 'monte_alto', 'old_quarter', 'harbor_city']);\n\nconst discoverInput = z.object({\n vibe: z.enum(['beach', 'mountains', 'culture', 'city']).default('beach'),\n month: monthEnum,\n travelers: z.number().int().min(1).default(2),\n});\n\n// Tool annotations for host planners: reads are read-only, the handoff opens an external link, and\n// shortlisting is a local non-destructive write.\nconst readOnly = annotations.readOnly();\nconst openLink = annotations.openAction();\nconst localWrite = annotations.localAction({ destructive: false, confirm: false });\n\nconst destinationOutput = z.object({\n id: z.string(),\n name: z.string(),\n region: z.string(),\n vibe: z.string(),\n priceFrom: z.number(),\n bestMonths: z.string(),\n why: z.string(),\n});\n\nconst discoverGetaways = tool('discover_getaways', {\n title: 'Discover getaways',\n description:\n 'Suggest Acme Getaways destinations for a vibe and month and render a discovery carousel.',\n annotations: readOnly,\n input: discoverInput,\n output: z.object({\n status: z.string(),\n vibe: z.string(),\n month: z.string(),\n travelers: z.number(),\n // Bounded list: the curated catalog is fixed and small, and the declared ceiling tells the\n // model and host the payload cannot grow. `noodle check` reports `tool_design_output_bounds`.\n options: z.array(destinationOutput).max(20),\n }),\n // The carousel presents Acme's curated catalog; the model narrates which fit the stated vibe.\n // (A tool cannot filter on an input value — that is connector/flow work — so all are returned.)\n fulfil: ({ input }) => ({\n status: `Acme Getaways for a ${input.vibe} trip in ${input.month}, ${input.travelers} traveler(s).`,\n vibe: input.vibe,\n month: input.month,\n travelers: input.travelers,\n options: catalog,\n }),\n viewTitle: 'Discover getaways',\n // ChatGPT host status copy (openai/toolInvocation/*) — required for widget-opening tools.\n invoking: 'Finding getaways…',\n invoked: 'Getaways ready',\n domain: 'https://getaways.acme.example',\n view: {\n component: 'discovery-carousel',\n entry: './views/discovery-carousel.tsx',\n },\n viewDescription:\n 'A top-of-funnel discovery carousel: pick a destination, then hand off to Acme to book.',\n csp: {\n connectDomains: ['https://acme.example'],\n resourceDomains: ['https://acme.example'],\n frameDomains: ['https://acme.example'],\n },\n});\n\nconst createHandoff = tool('create_handoff', {\n title: 'Create booking handoff',\n description:\n 'Create the Acme booking deep link for a chosen destination, carrying the configured trip. ' +\n 'Pass the destination id (url-safe slug, e.g. \"coral_bay\") and its display name.',\n annotations: openLink,\n input: z.object({\n destination: destinationId,\n destinationName: z.string().min(1),\n month: monthEnum,\n travelers: z.number().int().min(1).default(2),\n }),\n output: z.object({\n status: z.string(),\n destination: z.string(),\n summary: z.string(),\n handoffUrl: z.string(),\n }),\n // Inline the inputs directly so they substitute at runtime; every value is already url-safe\n // (id slug, month enum, integer), and `src=chatgpt` is the attribution the partner measures\n // ChatGPT-sourced conversions on.\n fulfil: ({ input }) => ({\n status: `Ready to continue on Acme for ${input.destinationName}.`,\n destination: input.destination,\n summary: `${input.destinationName} · ${input.month} · ${input.travelers} traveler(s)`,\n handoffUrl: `https://book.acme.example/plan?dest=${input.destination}&month=${input.month}&pax=${input.travelers}&src=chatgpt`,\n }),\n});\n\nconst shortlistGetaway = tool('shortlist_getaway', {\n visibility: ['app'],\n description: 'Record the traveler’s shortlisted destination from the discovery widget.',\n annotations: localWrite,\n input: z.object({\n destination: z.string(),\n note: z.string().default(''),\n }),\n output: z.object({\n status: z.string(),\n destination: z.string(),\n note: z.string(),\n }),\n fulfil: ({ input }) => ({\n status: `Shortlisted ${input.destination}.`,\n destination: input.destination,\n note: input.note,\n }),\n});\n\n// The consultative sales gateway (ADR 0214): when a visitor would rather not sign up, the assistant\n// may — with explicit confirmation — take their details and deliver them to Acme's own sink. The\n// recipe is a composition of existing primitives, not a platform feature: an ordinary confirm-gated\n// action plus a declarative HTTP connector whose endpoint and credential are operator-managed\n// (`noodle variables set LEAD_SINK_URL …`, `noodle secrets set LEAD_SINK_TOKEN …`). The payload\n// rests only in Acme's own system; the platform stores no lead. A vendor sink is the same shape as\n// data: Resend/Postmark take `auth: { kind: 'apiKey', … }`, a HubSpot private app takes\n// `auth: { kind: 'bearer', … }` — never a named vendor package.\nconst leadSink = connector('lead_sink')\n .version('1.0.0')\n .http({\n baseUrl: variable('LEAD_SINK_URL'),\n allowedOrigins: ['https://acme.example'],\n auth: { kind: 'bearer', secret: secret('LEAD_SINK_TOKEN') },\n operations: {\n submit_lead: {\n type: 'action',\n method: 'POST',\n path: '/api/assistant-lead',\n input: z.object({\n name: z.string().trim().min(2).max(120),\n workEmail: z.email().max(240),\n company: z.string().trim().min(2).max(200),\n note: z.string().max(500),\n }),\n output: z.object({ ok: z.boolean() }),\n request: {\n name: '${args.name}',\n workEmail: '${args.workEmail}',\n company: '${args.company}',\n note: '${args.note}',\n // Fixed attribution, set here rather than model-supplied: Acme's sink can trust it.\n source: 'website-assistant',\n },\n response: { ok: '${response.ok}' },\n },\n },\n });\n\nconst captureLead = tool('capture_lead', {\n title: 'Send my details to Acme',\n description:\n 'Send the visitor’s contact details and trip interest to Acme Getaways so the team may follow ' +\n 'up. Call only after the visitor explicitly agrees to be contacted; the confirmation card is ' +\n 'their consent moment. After a confirmed success, say only that the details were sent — never ' +\n 'promise response timing.',\n annotations: annotations.action({ confirm: true }),\n input: z.object({\n name: z.string().trim().min(2).max(120).meta({ title: 'Your name' }),\n workEmail: z.email().max(240).meta({ title: 'Work email' }),\n company: z.string().trim().min(2).max(200).meta({ title: 'Company' }),\n note: z.string().max(500).default('').meta({ title: 'What are you planning?' }),\n }),\n output: z.object({ ok: z.boolean() }),\n fulfil: ({ input, connectors }) => {\n const result = connectors.leads.submitLead({\n name: input.name,\n workEmail: input.workEmail,\n company: input.company,\n note: input.note,\n });\n return { ok: result.ok };\n },\n});\n\n// The mixed surface's sign-in trigger (ADR 0201): reading `${user.id}` classifies this tool\n// requires-identity, so an anonymous visitor who reaches for it sees the sign-in card instead of an\n// error — and after signing in on Acme's account origin, the conversation continues under the\n// authenticated surface below.\nconst myTrips = tool('my_trips', {\n title: 'My saved trips',\n description: 'Read the signed-in traveler’s saved trips and their booking status.',\n annotations: readOnly,\n input: z.object({}),\n output: z.object({\n traveler: z.string(),\n status: z.string(),\n }),\n fulfil: ({ user }) => ({\n traveler: user.id as string,\n status: 'No trips booked yet — shortlist a getaway to start one.',\n }),\n});\n\n// Grounding beyond the catalog: two controlled files answer policy/pricing/support questions with\n// citations, and Acme's live public site is crawled on deploy and re-crawled on the declared\n// refresh cadence — no sync job, no handwritten search tool. One declaration, one generated\n// `search_destinations` capability. The managed crawler and index are the defaults; a component\n// can instead bring its own via `crawler: firecrawl({ apiKey: secret('FIRECRAWL_API_KEY') })`\n// and `index: algolia({ appId: variable('ALGOLIA_APP_ID'), apiKey: secret('ALGOLIA_API_KEY') })`\n// — the code names the config, `noodle secrets|variables set` supplies the values.\nconst destinations = knowledge('destinations', {\n title: 'Acme Getaways destinations',\n description: 'Public destination, pricing, cancellation, and support information.',\n documents: [\n file('./knowledge/product.md', {\n title: 'Product guide',\n sourceUrl: 'https://getaways.acme.example/product',\n }),\n file('./knowledge/faq.txt', { title: 'FAQ' }),\n ],\n sites: [\n site({\n origin: 'https://getaways.acme.example',\n include: ['/destinations/**', '/pricing', '/support'],\n refresh: '12h',\n }),\n ],\n});\n\nexport default server(\n 'acme_discovery',\n {\n title: 'Acme Getaways',\n version: '1.0.0',\n branding: {\n name: 'Acme Getaways',\n accent: '#0EA5A4',\n surface: '#F0FDFA',\n surfaceDark: '#0B1B1B',\n radius: 'lg',\n density: 'comfortable',\n },\n // The only external destinations the app links out to — the compiler derives ChatGPT's\n // redirect_domains from this so the handoff opens without a safe-link warning.\n handoff: {\n allowedDomains: ['https://book.acme.example', 'https://acme.example'],\n },\n use: { leads: leadSink },\n // The same tools also serve Acme's own websites, with no second tool set. The marketing site is\n // a **mixed** surface (`signIn: true`): a visitor with no account gets discovery, the booking\n // handoff, and the confirm-gated lead capture — and reaching for `my_trips` raises the sign-in\n // card instead of an error, with `signUpAction` offering account creation through Acme's own\n // registration. After the login redirect lands on the account origin, the same conversation\n // continues under the authenticated surface's capabilities and instructions (ADR 0201).\n // `capabilities` is the whole externally reachable surface per front door — short enough to\n // review in one glance, and closed by default when a tool is added to the server later.\n assistant: embeddedAssistant({\n model: noodleManaged(),\n access: [\n publicWebsite({\n origins: ['https://getaways.acme.example'],\n // A browser agent on Acme's marketing page (Gemini-in-Chrome, Claude-in-Chrome) discovers\n // exactly the capabilities listed below and reaches them over the same authorization,\n // confirmation, budget, and audit path the panel's own calls take: `capture_lead` still\n // stops for its confirmation card. `site/index.html` is the page this runs on.\n webmcp: { enabled: true },\n capabilities: [\n destinations,\n discoverGetaways,\n createHandoff,\n shortlistGetaway,\n captureLead,\n myTrips,\n ],\n signIn: true,\n instructions:\n 'Be a friendly, consultative travel guide, never pushy. Help visitors narrow a getaway before suggesting the next useful step. Ground recommendations in Acme knowledge, and clearly separate discovery from booking. When a visitor’s plans firm up, invite them to sign in or create an account; if they would rather not, offer — once — to send their details to the Acme team instead.',\n }),\n authenticatedWebsite({\n origins: ['https://account.acme.example'],\n capabilities: [destinations, discoverGetaways, createHandoff, myTrips],\n instructions:\n 'The traveler is signed in. Help them plan from their saved trips, and keep booking on Acme’s own pages through the handoff.',\n }),\n ],\n layout: { mode: 'floating', position: 'bottom-right' },\n labels: {\n welcomeHeading: 'Where would you like to go?',\n signInHeading: 'Continue with your Acme account',\n signInBody: 'Saved trips need an account.',\n signInAction: 'Sign in',\n signUpAction: 'Create free account',\n },\n }),\n knowledge: [destinations],\n },\n [discoverGetaways, createHandoff, shortlistGetaway, captureLead, myTrips],\n);\n" },
39
+ { relPath: "examples/acme-discovery/src/server.ts", content: "import {\n annotations,\n authenticatedWebsite,\n connector,\n embeddedAssistant,\n file,\n knowledge,\n noodleManaged,\n publicWebsite,\n secret,\n server,\n site,\n tool,\n variable,\n z,\n} from '@noodleseed/one';\n\n// Acme Getaways is a fictional travel brand. This app is deliberately top-of-funnel: discovery and\n// configuration happen inside ChatGPT; the booking/transaction happens off-app on Acme's own site,\n// reached through a signed, attributable handoff deep link. Destinations are the partner's own\n// catalog (grounding) — the app never invents a place, price, or best-month.\n//\n// Authoring note: a tool `fulfil` is *recorded*, not run as live JS. Inputs flow through as\n// `${input.x}` substitutions when placed directly into an output string; do not transform them\n// (no URL-encoding, arithmetic, or filtering on an input value — those break substitution). The\n// curated catalog below is static data the runtime returns verbatim.\n\nconst catalog = [\n {\n id: 'coral_bay',\n name: 'Coral Bay',\n region: 'Adriatic coast',\n vibe: 'beach',\n priceFrom: 890,\n bestMonths: 'May–Sep',\n why: 'Calm swimming coves and a walkable old town — easy for a relaxed first trip.',\n },\n {\n id: 'monte_alto',\n name: 'Monte Alto',\n region: 'Northern Alps',\n vibe: 'mountains',\n priceFrom: 1120,\n bestMonths: 'Dec–Mar',\n why: 'Ski-in village with beginner slopes and long groomed runs.',\n },\n {\n id: 'old_quarter',\n name: 'Old Quarter',\n region: 'Central Europe',\n vibe: 'culture',\n priceFrom: 640,\n bestMonths: 'Apr–Oct',\n why: 'Dense museum district and food halls, all reachable on foot.',\n },\n {\n id: 'harbor_city',\n name: 'Harbor City',\n region: 'Pacific rim',\n vibe: 'city',\n priceFrom: 980,\n bestMonths: 'Sep–Nov',\n why: 'Waterfront nightlife and day-trip islands a short ferry away.',\n },\n] as const;\n\n// A closed set of URL-safe month values. A `fulfil` cannot url-encode an input (recording would break\n// substitution), so the deep link carries `month` only if it is already safe — the model maps natural\n// phrasing (\"early June\") onto one of these when it fills the tool.\nconst monthEnum = z\n .enum([\n 'January',\n 'February',\n 'March',\n 'April',\n 'May',\n 'June',\n 'July',\n 'August',\n 'September',\n 'October',\n 'November',\n 'December',\n ])\n .default('June');\n\n// The catalog ids are already url-safe slugs. Constrain the handoff `destination` to this closed set so\n// only a real, url-safe id can reach the deep link.\nconst destinationId = z.enum(['coral_bay', 'monte_alto', 'old_quarter', 'harbor_city']);\n\nconst discoverInput = z.object({\n vibe: z.enum(['beach', 'mountains', 'culture', 'city']).default('beach'),\n month: monthEnum,\n travelers: z.number().int().min(1).default(2),\n});\n\n// Tool annotations for host planners: reads are read-only, the handoff opens an external link, and\n// shortlisting is a local non-destructive write.\nconst readOnly = annotations.readOnly();\nconst openLink = annotations.openAction();\nconst localWrite = annotations.localAction({ destructive: false, confirm: false });\n\nconst destinationOutput = z.object({\n id: z.string(),\n name: z.string(),\n region: z.string(),\n vibe: z.string(),\n priceFrom: z.number(),\n bestMonths: z.string(),\n why: z.string(),\n});\n\nconst discoverGetaways = tool('discover_getaways', {\n title: 'Discover getaways',\n description:\n 'Suggest Acme Getaways destinations for a vibe and month and render a discovery carousel.',\n annotations: readOnly,\n input: discoverInput,\n output: z.object({\n status: z.string(),\n vibe: z.string(),\n month: z.string(),\n travelers: z.number(),\n // Bounded list: the curated catalog is fixed and small, and the declared ceiling tells the\n // model and host the payload cannot grow. `noodle check` reports `tool_design_output_bounds`.\n options: z.array(destinationOutput).max(20),\n }),\n // The carousel presents Acme's curated catalog; the model narrates which fit the stated vibe.\n // (A tool cannot filter on an input value — that is connector/flow work — so all are returned.)\n fulfil: ({ input }) => ({\n status: `Acme Getaways for a ${input.vibe} trip in ${input.month}, ${input.travelers} traveler(s).`,\n vibe: input.vibe,\n month: input.month,\n travelers: input.travelers,\n options: catalog,\n }),\n viewTitle: 'Discover getaways',\n // ChatGPT host status copy (openai/toolInvocation/*) — required for widget-opening tools.\n invoking: 'Finding getaways…',\n invoked: 'Getaways ready',\n domain: 'https://getaways.acme.example',\n view: {\n component: 'discovery-carousel',\n entry: './views/discovery-carousel.tsx',\n },\n viewDescription:\n 'A top-of-funnel discovery carousel: pick a destination, then hand off to Acme to book.',\n csp: {\n connectDomains: ['https://acme.example'],\n resourceDomains: ['https://acme.example'],\n frameDomains: ['https://acme.example'],\n },\n});\n\nconst createHandoff = tool('create_handoff', {\n title: 'Create booking handoff',\n description:\n 'Create the Acme booking deep link for a chosen destination, carrying the configured trip. ' +\n 'Pass the destination id (url-safe slug, e.g. \"coral_bay\") and its display name.',\n annotations: openLink,\n input: z.object({\n destination: destinationId,\n destinationName: z.string().min(1),\n month: monthEnum,\n travelers: z.number().int().min(1).default(2),\n }),\n output: z.object({\n status: z.string(),\n destination: z.string(),\n summary: z.string(),\n handoffUrl: z.string(),\n }),\n // Inline the inputs directly so they substitute at runtime; every value is already url-safe\n // (id slug, month enum, integer), and `src=chatgpt` is the attribution the partner measures\n // ChatGPT-sourced conversions on.\n fulfil: ({ input }) => ({\n status: `Ready to continue on Acme for ${input.destinationName}.`,\n destination: input.destination,\n summary: `${input.destinationName} · ${input.month} · ${input.travelers} traveler(s)`,\n handoffUrl: `https://book.acme.example/plan?dest=${input.destination}&month=${input.month}&pax=${input.travelers}&src=chatgpt`,\n }),\n});\n\nconst shortlistGetaway = tool('shortlist_getaway', {\n visibility: ['app'],\n description: 'Record the traveler’s shortlisted destination from the discovery widget.',\n annotations: localWrite,\n input: z.object({\n destination: z.string(),\n note: z.string().default(''),\n }),\n output: z.object({\n status: z.string(),\n destination: z.string(),\n note: z.string(),\n }),\n fulfil: ({ input }) => ({\n status: `Shortlisted ${input.destination}.`,\n destination: input.destination,\n note: input.note,\n }),\n});\n\n// The consultative sales gateway (ADR 0214): when a visitor would rather not sign up, the assistant\n// may — with explicit confirmation — take their details and deliver them to Acme's own sink. The\n// recipe is a composition of existing primitives, not a platform feature: an ordinary confirm-gated\n// action plus a declarative HTTP connector whose endpoint and credential are operator-managed\n// (`noodle variables set LEAD_SINK_URL …`, `noodle secrets set LEAD_SINK_TOKEN …`). The payload\n// rests only in Acme's own system; the platform stores no lead. A vendor sink is the same shape as\n// data: Resend/Postmark take `auth: { kind: 'apiKey', … }`, a HubSpot private app takes\n// `auth: { kind: 'bearer', … }` — never a named vendor package.\nconst leadSink = connector('lead_sink')\n .version('1.0.0')\n .http({\n baseUrl: variable('LEAD_SINK_URL'),\n allowedOrigins: ['https://acme.example'],\n auth: { kind: 'bearer', secret: secret('LEAD_SINK_TOKEN') },\n operations: {\n submit_lead: {\n type: 'action',\n method: 'POST',\n path: '/api/assistant-lead',\n input: z.object({\n name: z.string().trim().min(2).max(120),\n workEmail: z.email().max(240),\n company: z.string().trim().min(2).max(200),\n note: z.string().max(500),\n }),\n output: z.object({ ok: z.boolean() }),\n request: {\n name: '${args.name}',\n workEmail: '${args.workEmail}',\n company: '${args.company}',\n note: '${args.note}',\n // Fixed attribution, set here rather than model-supplied: Acme's sink can trust it.\n source: 'website-assistant',\n },\n response: { ok: '${response.ok}' },\n },\n },\n });\n\nconst captureLead = tool('capture_lead', {\n title: 'Send my details to Acme',\n description:\n 'Send the visitor’s contact details and trip interest to Acme Getaways so the team may follow ' +\n 'up. Call only after the visitor explicitly agrees to be contacted; the confirmation card is ' +\n 'their consent moment. After a confirmed success, say only that the details were sent — never ' +\n 'promise response timing.',\n annotations: annotations.action({ confirm: true }),\n input: z.object({\n name: z.string().trim().min(2).max(120).meta({ title: 'Your name' }),\n workEmail: z.email().max(240).meta({ title: 'Work email' }),\n company: z.string().trim().min(2).max(200).meta({ title: 'Company' }),\n note: z.string().max(500).default('').meta({ title: 'What are you planning?' }),\n }),\n output: z.object({ ok: z.boolean() }),\n fulfil: ({ input, connectors }) => {\n const result = connectors.leads.submitLead({\n name: input.name,\n workEmail: input.workEmail,\n company: input.company,\n note: input.note,\n });\n return { ok: result.ok };\n },\n});\n\n// The mixed surface's sign-in trigger (ADR 0201): reading `${user.id}` classifies this tool\n// requires-identity, so an anonymous visitor who reaches for it sees the sign-in card instead of an\n// error — and after signing in on Acme's account origin, the conversation continues under the\n// authenticated surface below.\nconst myTrips = tool('my_trips', {\n title: 'My saved trips',\n description: 'Read the signed-in traveler’s saved trips and their booking status.',\n annotations: readOnly,\n input: z.object({}),\n output: z.object({\n traveler: z.string(),\n status: z.string(),\n }),\n fulfil: ({ user }) => ({\n traveler: user.id as string,\n status: 'No trips booked yet — shortlist a getaway to start one.',\n }),\n});\n\n// Grounding beyond the catalog: two controlled files answer policy/pricing/support questions with\n// citations, and Acme's live public site is crawled on deploy and re-crawled on the declared\n// refresh cadence — no sync job, no handwritten search tool. One declaration, one generated\n// `search_destinations` capability. The managed crawler and index are the defaults; a component\n// can instead bring its own via `crawler: firecrawl({ apiKey: secret('FIRECRAWL_API_KEY') })`\n// and `index: algolia({ appId: variable('ALGOLIA_APP_ID'), apiKey: secret('ALGOLIA_API_KEY') })`\n// — the code names the config, `noodle secrets|variables set` supplies the values.\nconst destinations = knowledge('destinations', {\n title: 'Acme Getaways destinations',\n description: 'Public destination, pricing, cancellation, and support information.',\n documents: [\n file('./knowledge/product.md', {\n title: 'Product guide',\n sourceUrl: 'https://getaways.acme.example/product',\n }),\n file('./knowledge/faq.txt', { title: 'FAQ' }),\n ],\n sites: [\n site({\n origin: 'https://getaways.acme.example',\n include: ['/destinations/**', '/pricing', '/support'],\n refresh: '12h',\n }),\n ],\n});\n\nexport default server(\n 'acme_discovery',\n {\n title: 'Acme Getaways',\n version: '1.0.0',\n branding: {\n name: 'Acme Getaways',\n accent: '#0EA5A4',\n surface: '#F0FDFA',\n surfaceDark: '#0B1B1B',\n radius: 'lg',\n density: 'comfortable',\n },\n // The only external destinations the app links out to — the compiler derives ChatGPT's\n // redirect_domains from this so the handoff opens without a safe-link warning.\n handoff: {\n allowedDomains: ['https://book.acme.example', 'https://acme.example'],\n },\n use: { leads: leadSink },\n // The same tools also serve Acme's own websites, with no second tool set. The marketing site is\n // a **mixed** surface (`signIn: true`): a visitor with no account gets discovery, the booking\n // handoff, and the confirm-gated lead capture — and reaching for `my_trips` raises the sign-in\n // card instead of an error, with `signUpAction` offering account creation through Acme's own\n // registration. After the login redirect lands on the account origin, the same conversation\n // continues under the authenticated surface's capabilities and instructions (ADR 0201).\n // `capabilities` is the whole externally reachable surface per front door — short enough to\n // review in one glance, and closed by default when a tool is added to the server later.\n assistant: embeddedAssistant({\n model: noodleManaged(),\n access: [\n publicWebsite({\n origins: ['https://getaways.acme.example'],\n // A browser agent on Acme's marketing page (Gemini-in-Chrome, Claude-in-Chrome) discovers\n // exactly the capabilities listed below and reaches them over the same authorization,\n // confirmation, budget, and audit path the panel's own calls take: `capture_lead` still\n // stops for its confirmation card. `site/index.html` is the page this runs on.\n webmcp: { enabled: true },\n // A visitor who asks about Coral Bay on one page and clicks through to another would\n // otherwise arrive at an empty panel and have to start over. This carries the text they\n // have already read onto the next page, on a fresh session — never the old session's\n // authority, budget, or a half-answered confirmation (ADR 0223). Opt-in because anonymous\n // conversation text is Acme's content on Acme's page; the defaults below are deliberately\n // tighter than the platform ceiling.\n continuity: { enabled: true, windowSeconds: 300, maxRestores: 3 },\n capabilities: [\n destinations,\n discoverGetaways,\n createHandoff,\n shortlistGetaway,\n captureLead,\n myTrips,\n ],\n signIn: true,\n instructions:\n 'Be a friendly, consultative travel guide, never pushy. Help visitors narrow a getaway before suggesting the next useful step. Ground recommendations in Acme knowledge, and clearly separate discovery from booking. When a visitor’s plans firm up, invite them to sign in or create an account; if they would rather not, offer — once — to send their details to the Acme team instead.',\n }),\n authenticatedWebsite({\n origins: ['https://account.acme.example'],\n capabilities: [destinations, discoverGetaways, createHandoff, myTrips],\n instructions:\n 'The traveler is signed in. Help them plan from their saved trips, and keep booking on Acme’s own pages through the handoff.',\n }),\n ],\n layout: { mode: 'floating', position: 'bottom-right' },\n labels: {\n welcomeHeading: 'Where would you like to go?',\n signInHeading: 'Continue with your Acme account',\n signInBody: 'Saved trips need an account.',\n signInAction: 'Sign in',\n signUpAction: 'Create free account',\n },\n }),\n knowledge: [destinations],\n },\n [discoverGetaways, createHandoff, shortlistGetaway, captureLead, myTrips],\n);\n" },
40
40
  { relPath: "examples/acme-discovery/src/views/discovery-carousel.tsx", content: "import { useState } from 'react';\nimport { useCallTool, useLayout, useOpenExternal, useToolInfo, useViewState } from '../helpers.js';\nimport './widget-style.css';\n\ntype Destination = {\n readonly id: string;\n readonly name: string;\n readonly region: string;\n readonly vibe: string;\n readonly priceFrom: number;\n readonly bestMonths: string;\n readonly why: string;\n};\n\nfunction asDiscovery(value: unknown) {\n return value as\n | {\n readonly status?: string;\n readonly month?: string;\n readonly travelers?: number;\n readonly options?: readonly Destination[];\n }\n | undefined;\n}\n\nexport default function DiscoveryCarousel() {\n const { displayMode, theme } = useLayout();\n const openExternal = useOpenExternal();\n const discovery = asDiscovery(useToolInfo('discover_getaways').structuredContent);\n const shortlist = useCallTool('shortlist_getaway');\n const handoff = useCallTool('create_handoff');\n\n const options = discovery?.options ?? [];\n const month = discovery?.month ?? 'June';\n const travelers = discovery?.travelers ?? 2;\n const [chosen, setChosen] = useViewState('chosen', options[0]?.id ?? '');\n const [status, setStatus] = useState(discovery?.status ?? 'Pick a getaway to continue.');\n const selected = options.find((entry) => entry.id === chosen) ?? options[0];\n const continueLabel = handoff.isPending\n ? 'Opening Acme…'\n : `Continue on Acme${selected ? ` · ${selected.name}` : ''}`;\n\n async function shortlistDestination(destination: Destination) {\n setChosen(destination.id);\n try {\n const result = await shortlist.callTool({ destination: destination.name });\n const structured = result.structuredContent as { readonly status?: string } | undefined;\n setStatus(structured?.status ?? `Shortlisted ${destination.name}.`);\n } catch {\n setStatus(`Couldn't shortlist ${destination.name} — try again.`);\n }\n }\n\n async function continueOnAcme() {\n if (selected === undefined) return;\n // The handoff is the product: configure here, transact off-app. The deep link carries the trip.\n // Only open the external target on a successful handoff; surface failures instead of failing silently.\n try {\n const result = await handoff.callTool({\n destination: selected.id,\n destinationName: selected.name,\n month,\n travelers,\n });\n const structured = result.structuredContent as { readonly handoffUrl?: string } | undefined;\n if (structured?.handoffUrl) openExternal(structured.handoffUrl);\n else setStatus('Continue on Acme is unavailable right now — try again.');\n } catch {\n setStatus('Continue on Acme failed — try again.');\n }\n }\n\n return (\n <main\n className={`nw-shell${theme === 'dark' ? ' dark' : ''}`}\n data-llm={`Acme Getaways discovery: ${options.length} options for ${month}, ${travelers} traveler(s); shortlisted ${selected?.name ?? 'none'}`}\n >\n <section className=\"nw-card\">\n <header className=\"nw-header\">\n <span className=\"nw-icon\" aria-hidden=\"true\">\n <CompassIcon />\n </span>\n <div className=\"nw-title-block\">\n <h1 className=\"nw-title\">Acme Getaways</h1>\n <p className=\"nw-subtitle\" aria-live=\"polite\">\n {status}\n </p>\n </div>\n <span className=\"nw-chip\">\n {displayMode === 'fullscreen' ? 'Fullscreen' : 'Discover'}\n </span>\n </header>\n\n <div className=\"nw-carousel\">\n {options.map((entry) => (\n <article\n className={`nw-dest${entry.id === chosen ? ' nw-dest-active' : ''}`}\n key={entry.id}\n >\n <div className=\"nw-dest-head\">\n <span className=\"nw-dest-name\">{entry.name}</span>\n <span className=\"nw-price\">from ${entry.priceFrom}</span>\n </div>\n <p className=\"nw-dest-region\">\n {entry.region} · best {entry.bestMonths}\n </p>\n {/* Grounded copy: the \"why\" comes from Acme's catalog, not invented at runtime. */}\n <p className=\"nw-dest-why\">{entry.why}</p>\n <button\n aria-pressed={entry.id === chosen}\n className=\"nw-button nw-button-ghost\"\n type=\"button\"\n onClick={() => shortlistDestination(entry)}\n >\n {entry.id === chosen ? 'Shortlisted' : 'Shortlist'}\n </button>\n </article>\n ))}\n </div>\n\n <div className=\"nw-actions\">\n <button\n className=\"nw-button nw-button-primary\"\n type=\"button\"\n disabled={selected === undefined || handoff.isPending}\n onClick={continueOnAcme}\n >\n <ExternalIcon />\n {continueLabel}\n </button>\n </div>\n <p className=\"nw-note\">Booking and payment happen on acme.example — never inside chat.</p>\n </section>\n </main>\n );\n}\n\nfunction CompassIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <circle cx=\"12\" cy=\"12\" r=\"9\" />\n <path d=\"m15.5 8.5-2 5-5 2 2-5 5-2Z\" />\n </svg>\n );\n}\n\nfunction ExternalIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n <path d=\"M14 4h6v6\" />\n <path d=\"m20 4-9 9\" />\n <path d=\"M20 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5\" />\n </svg>\n );\n}\n" },
41
41
  { relPath: "examples/acme-discovery/src/views/widget-style.css", content: ":root {\n color-scheme: light dark;\n font-family:\n Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n --nw-bg: #ffffff;\n --nw-surface: #f4fbfa;\n --nw-text: #10201f;\n --nw-muted: #5b6b6a;\n --nw-border: #d4e6e4;\n --nw-accent: #0ea5a4;\n --nw-accent-strong: #0f766e;\n --nw-accent-soft: #e6faf8;\n --nw-radius: 10px;\n --nw-shadow: 0 18px 50px rgb(15 40 40 / 12%);\n}\n\n.dark,\n[data-theme=\"dark\"] {\n --nw-bg: #0b1b1b;\n --nw-surface: #102624;\n --nw-text: #eafaf8;\n --nw-muted: #9fb6b3;\n --nw-border: #244341;\n --nw-accent: #2dd4bf;\n --nw-accent-strong: #14b8a6;\n --nw-accent-soft: #0f3835;\n --nw-shadow: 0 18px 50px rgb(0 0 0 / 30%);\n}\n\n* {\n box-sizing: border-box;\n}\n\nbody {\n margin: 0;\n background: var(--nw-bg);\n color: var(--nw-text);\n}\n\nbutton {\n font: inherit;\n}\n\n.nw-shell {\n min-height: 100vh;\n padding: 14px;\n background: var(--nw-bg);\n color: var(--nw-text);\n}\n\n.nw-card {\n max-width: 720px;\n margin: 0 auto;\n background: var(--nw-surface);\n border: 1px solid var(--nw-border);\n border-radius: var(--nw-radius);\n box-shadow: var(--nw-shadow);\n overflow: hidden;\n}\n\n.nw-header {\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 16px;\n border-bottom: 1px solid var(--nw-border);\n}\n\n.nw-icon svg {\n width: 26px;\n height: 26px;\n fill: none;\n stroke: var(--nw-accent);\n stroke-width: 1.7;\n stroke-linecap: round;\n stroke-linejoin: round;\n}\n\n.nw-title-block {\n flex: 1;\n min-width: 0;\n}\n\n.nw-title {\n margin: 0;\n font-size: 17px;\n font-weight: 700;\n}\n\n.nw-subtitle {\n margin: 2px 0 0;\n font-size: 13px;\n color: var(--nw-muted);\n}\n\n.nw-chip {\n padding: 4px 10px;\n border-radius: 999px;\n background: var(--nw-accent-soft);\n color: var(--nw-accent-strong);\n font-size: 12px;\n font-weight: 600;\n}\n\n.nw-carousel {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));\n gap: 12px;\n padding: 16px;\n}\n\n.nw-dest {\n display: flex;\n flex-direction: column;\n gap: 6px;\n padding: 12px;\n border: 1px solid var(--nw-border);\n border-radius: 12px;\n background: var(--nw-bg);\n}\n\n.nw-dest-active {\n border-color: var(--nw-accent);\n box-shadow: 0 0 0 1px var(--nw-accent);\n}\n\n.nw-dest-head {\n display: flex;\n align-items: baseline;\n justify-content: space-between;\n gap: 8px;\n}\n\n.nw-dest-name {\n font-weight: 700;\n}\n\n.nw-price {\n color: var(--nw-accent-strong);\n font-size: 12px;\n font-weight: 600;\n}\n\n.nw-dest-region {\n margin: 0;\n font-size: 12px;\n color: var(--nw-muted);\n}\n\n.nw-dest-why {\n margin: 0;\n font-size: 13px;\n flex: 1;\n}\n\n.nw-actions {\n display: flex;\n gap: 8px;\n padding: 0 16px 12px;\n}\n\n.nw-button {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 9px 14px;\n border: 1px solid var(--nw-border);\n border-radius: 10px;\n background: var(--nw-bg);\n color: var(--nw-text);\n cursor: pointer;\n}\n\n.nw-button svg {\n width: 16px;\n height: 16px;\n fill: none;\n stroke: currentColor;\n stroke-width: 1.7;\n stroke-linecap: round;\n stroke-linejoin: round;\n}\n\n.nw-button-ghost {\n align-self: flex-start;\n padding: 6px 12px;\n font-size: 13px;\n}\n\n.nw-button-primary {\n background: var(--nw-accent);\n border-color: var(--nw-accent);\n color: #ffffff;\n font-weight: 600;\n}\n\n.nw-button-primary:disabled {\n opacity: 0.6;\n cursor: default;\n}\n\n.nw-note {\n margin: 0;\n padding: 0 16px 16px;\n font-size: 12px;\n color: var(--nw-muted);\n}\n" },
42
42
  { relPath: "examples/acme-discovery/test/server.test.ts", content: "import { describe, expect, it } from 'vitest';\nimport app from '../src/server.js';\n\ndescribe('acme-discovery example', () => {\n it('exports a Noodle server definition', () => {\n expect(typeof app.toManifest).toBe('function');\n });\n\n it('declares the off-app handoff domain the deep link lands on', async () => {\n // Top-of-funnel: the only external target is Acme's booking site, declared once at the server.\n const manifest = await app.toManifest();\n expect(JSON.stringify(manifest)).toContain('https://book.acme.example');\n });\n\n it('exposes the discovery tool and the handoff tool', async () => {\n const manifest = await app.toManifest();\n const text = JSON.stringify(manifest);\n // The discovery tool renders the carousel; the handoff tool emits the deep link; the widget-only\n // helper records a shortlist.\n expect(text).toContain('discover_getaways');\n expect(text).toContain('create_handoff');\n expect(text).toContain('shortlist_getaway');\n });\n\n it('gives the public website a consultative surface-specific goal', async () => {\n const manifest = await app.toManifest();\n expect(manifest.server.assistant?.model).toEqual({ kind: 'noodle-managed' });\n expect(manifest.server.assistant?.surfaces?.[0]?.instructions).toContain(\n 'friendly, consultative travel guide',\n );\n });\n\n it('keeps the lead capture behind explicit confirmation and a managed customer sink', async () => {\n const manifest = (await app.toManifest()) as {\n tools: { name: string; annotations?: Record<string, unknown> }[];\n };\n const captureLead = manifest.tools.find((tool) => tool.name === 'capture_lead');\n // The confirmation card is the visitor's consent moment (ADR 0214): a lead may never leave the\n // conversation without it, and the sink endpoint/credential stay operator-managed data.\n expect(captureLead?.annotations?.confirm).toBe(true);\n const catalog = JSON.stringify(\n (app as unknown as { toConnectorCatalog: () => unknown }).toConnectorCatalog(),\n );\n expect(catalog).toContain('${env.LEAD_SINK_URL}');\n expect(catalog).toContain('LEAD_SINK_TOKEN');\n // Fixed attribution set in the request mapping, never model-supplied; no named vendor host.\n expect(catalog).toContain('website-assistant');\n expect(catalog).not.toContain('api.resend.com');\n expect(catalog).not.toContain('api.hubapi.com');\n });\n\n it('serves a mixed marketing surface and an authenticated account surface from one server', async () => {\n const manifest = await app.toManifest();\n const surfaces = manifest.server.assistant?.surfaces ?? [];\n expect(surfaces.map((surface) => surface.mode)).toEqual(['mixed', 'authenticated']);\n // The sign-in trigger is listed on the mixed surface so the assistant can offer it; the\n // authenticated surface carries its own narrowed list and voice.\n const capabilityNames = (surface: (typeof surfaces)[number]) =>\n surface.capabilities?.map((capability) => capability.name) ?? [];\n expect(capabilityNames(surfaces[0]!)).toContain('my_trips');\n expect(capabilityNames(surfaces[0]!)).toContain('capture_lead');\n expect(capabilityNames(surfaces[1]!)).toEqual([\n 'destinations',\n 'discover_getaways',\n 'create_handoff',\n 'my_trips',\n ]);\n // Authoring the sign-up label is the opt-in for the card's create-account button.\n expect(manifest.server.assistant?.labels?.signUpAction).toBe('Create free account');\n });\n\n it('opens the marketing surface to browser agents and leaves the account surface closed', async () => {\n const manifest = await app.toManifest();\n const surfaces = manifest.server.assistant?.surfaces ?? [];\n\n // Both front doors, asserted by count first: without it the per-surface claims below read a\n // missing surface as `undefined` and pass, so deleting a surface would silently satisfy them.\n expect(surfaces).toHaveLength(2);\n // ADR 0220: the opt-in governs *discovery* — whether the embed registers this session's already\n // projected tools with `document.modelContext`. A browser agent on Acme's marketing page reaches\n // exactly the six capabilities above, over the same authorization, confirmation, and budget path\n // the panel's own calls take. `capture_lead` still stops for its confirmation card.\n expect(surfaces[0]?.webmcp).toEqual({ enabled: true });\n // Per-surface opt-in exists so the two front doors can answer differently, and here they do: the\n // signed-in account surface carries a traveler's identity, so its tools are not advertised to\n // whatever agent happens to be running in that browser.\n expect(surfaces[1]?.webmcp).toBeUndefined();\n });\n\n it('declares the grounded knowledge component and its live site scope', async () => {\n const manifest = (await app.toManifest()) as { server: { knowledge?: unknown[] } };\n // One declaration: controlled files plus the live public site, compiled later into the\n // generated `search_destinations` capability with citations.\n expect(manifest.server.knowledge).toHaveLength(1);\n const text = JSON.stringify(manifest);\n expect(text).toContain('knowledge/product.md');\n expect(text).toContain('https://getaways.acme.example');\n });\n});\n" },
@@ -202,6 +202,8 @@ export function renderEmbeddedAssistantReference() {
202
202
  '',
203
203
  "Set `webmcp: { enabled: true }` on the assistant to let a browser agent reach this session's tools through the page's WebMCP API, and set it on an individual access surface to override that default in either direction — a marketing surface can opt in while a signed-in one opts out, or the reverse. Off unless set, and inert in browsers without `document.modelContext`. It governs discovery: whether the embed registers the tools this session already projects, narrowed to those that are both app-callable and model-visible. Every call executes over the same apps-bridge path the assistant's own calls take, so a browser agent gets the session's authority and nothing more, and a `confirm: true` tool still stops for a human in the panel rather than being accepted on the agent's behalf. It is not a second authorization boundary — the session is the only one. Bridge calls spend their own per-session and per-day budgets instead of model turns, and the surface's daily kill switch stops them too. Prefer this over hand-registering page-local tools that borrow the visitor's session: those carry no scoped authority, policy, or audit trail.",
204
204
  '',
205
+ 'Set `continuity: { enabled: true }` on a public or mixed access surface to let an anonymous visitor keep the conversation they can see when they navigate to another page of the same site. Off unless set, and refused on an authenticated surface, which reattaches through a backend-verified sign-in instead. It restores the visible text on a fresh session and never the old one: no tool authority, no share of a spent turn budget, and no pending confirmation carried across, so an unanswered confirmation stays unanswered. `windowSeconds` defaults to 300 with a 600 ceiling, `maxRestores` to 3 with a ceiling of 10, and 0 for either disables continuity outright; an operator may lower what you declare and can never raise it. The handle lives in `sessionStorage` so it dies with the tab, is single-use, and is valid only for the embed, origin, and visitor it was issued to. Declare it when a marketing site spreads one conversation across several pages; leave it off when anonymous conversation text should not survive a navigation at all.',
206
+ '',
205
207
  'Give every business action a portable `tool(..., { title: "Complete task", description: "This will mark the task complete for everyone.", input: z.object({ task: z.string().meta({ title: "Task" }) }) })` title. The standard confirmation uses the tool title/description plus schema field `title`, `description`, and `format`; it shows Confirm and Don\'t proceed and keeps technical action details secondary. `behavior.showConfirmationDetails` defaults to `true`; set it to `false` to remove only the built-in card\'s Additional details disclosure and connector mechanics. The business review and decisions remain, `confirm: true` still suspends until acceptance, and headless/BYO `data-confirmation` stays unchanged. Do not put JSON or implementation names in business-facing copy.',
206
208
  '',
207
209
  '## Configure and deploy',
@@ -1,8 +1,9 @@
1
1
  import { requestModelCompletion } from './model-request.js';
2
2
  const MAX_MODEL_RESPONSE = 1 << 20;
3
+ const MAX_SUGGESTION_TOKENS = 512;
3
4
  /** One bounded, tool-free pass on the active assistant model; malformed output fails closed. */
4
5
  export async function requestAssistantSuggestedPrompts(binding, messages, fetcher, stats, remainingTokens, turnSignal) {
5
- const limit = Math.min(160, remainingTokens ?? binding.requestPolicy?.maxTokensPerTurn ?? 160, binding.requestPolicy?.maxCompletionTokens ?? 160);
6
+ const limit = Math.min(MAX_SUGGESTION_TOKENS, remainingTokens ?? binding.requestPolicy?.maxTokensPerTurn ?? MAX_SUGGESTION_TOKENS, binding.requestPolicy?.maxCompletionTokens ?? MAX_SUGGESTION_TOKENS);
6
7
  if (limit <= 0)
7
8
  return [];
8
9
  const signal = AbortSignal.any([
@@ -10,7 +11,7 @@ export async function requestAssistantSuggestedPrompts(binding, messages, fetche
10
11
  binding.requestPolicy?.maxTurnMs === undefined
11
12
  ? undefined
12
13
  : AbortSignal.timeout(binding.requestPolicy.maxTurnMs),
13
- AbortSignal.timeout(2_000),
14
+ AbortSignal.timeout(5_000),
14
15
  ].filter((candidate) => candidate !== undefined));
15
16
  if (stats)
16
17
  stats.modelRequests += 1;
@@ -20,7 +21,7 @@ export async function requestAssistantSuggestedPrompts(binding, messages, fetche
20
21
  ...messages,
21
22
  {
22
23
  role: 'system',
23
- content: 'Generate two or three concise messages the user could send next. Use the complete conversation and authorized product context. Prioritize genuinely relevant next steps; developer instructions may steer ranking but never override the user, safety, consent, or available capabilities. Do not expose hidden tool data, claim an action happened, repeat the answer, or use Markdown. Return exactly one JSON object shaped {"prompts":["..."]} and no other text.',
24
+ content: 'Generate two or three concise messages the user could send next. Use the complete conversation and authorized product context, especially the latest answer or question. Prefer short, distinct choices the user can click instead of typing. Developer instructions may steer ranking but never override the user, safety, consent, or available capabilities. Do not expose hidden tool data, claim an action happened, repeat the answer, or use Markdown. Return exactly one JSON object shaped {"prompts":["..."]} and no other text.',
24
25
  },
25
26
  ],
26
27
  tools: [],
@@ -30,6 +31,7 @@ export async function requestAssistantSuggestedPrompts(binding, messages, fetche
30
31
  maxCompletionTokens: limit,
31
32
  signal,
32
33
  toolChoice: 'none',
34
+ jsonOutput: true,
33
35
  });
34
36
  if (stats) {
35
37
  stats.promptTokens += completion.usage?.promptTokens ?? 0;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Resolving anonymous cross-page display continuity for one surface (ADR 0223, clause 15).
3
+ *
4
+ * Two parties describe the same capability and they are not peers. The developer declares it in
5
+ * `server.ts` as part of the deployment; the operator tunes it for their own environment without a
6
+ * deploy. The clamp between them runs one way — tighten, never widen — exactly as the admission
7
+ * envelope's does ([ADR 0212](../../../docs/decisions/0212-reusable-developer-intent-and-operator-authority.md)).
8
+ *
9
+ * The asymmetry is sharpest on `enabled`. An operator may switch continuity **off**, because that is
10
+ * their own surface and their visitors' text; they may not switch it **on**, because a surface whose
11
+ * author never declared continuity is one whose author never reasoned about whether their pages should
12
+ * carry conversation across a navigation.
13
+ */
14
+ /** What a public or mixed surface declares in `server.ts`. Shapes match the manifest exactly. */
15
+ export interface AssistantContinuityDeclaration {
16
+ readonly enabled?: boolean;
17
+ readonly windowSeconds?: number;
18
+ readonly maxRestores?: number;
19
+ }
20
+ /**
21
+ * What an operator sets for their own environment. `enabled: false` disables; `true` is inert, since
22
+ * an operator cannot enable a capability the deployment does not declare.
23
+ */
24
+ export interface AssistantContinuityOverride {
25
+ readonly enabled?: boolean;
26
+ readonly windowSeconds?: number;
27
+ readonly maxRestores?: number;
28
+ }
29
+ export interface ResolvedAssistantContinuity {
30
+ readonly enabled: boolean;
31
+ /** Zero whenever `enabled` is false, so a caller cannot read a live window off a dead surface. */
32
+ readonly windowMs: number;
33
+ readonly maxRestores: number;
34
+ }
35
+ export declare function resolveContinuity(declaration: AssistantContinuityDeclaration | undefined, override: AssistantContinuityOverride | undefined): ResolvedAssistantContinuity;
36
+ //# sourceMappingURL=continuity-bounds.d.ts.map
@@ -0,0 +1,52 @@
1
+ import { ASSISTANT_CONTINUITY_MAX_RESTORES, ASSISTANT_CONTINUITY_RESTORE_CEILING, ASSISTANT_CONTINUITY_WINDOW_CEILING_MS, ASSISTANT_CONTINUITY_WINDOW_MS, continuityMaxRestores, continuityWindowMs, } from './continuity-store.js';
2
+ const OFF = { enabled: false, windowMs: 0, maxRestores: 0 };
3
+ /**
4
+ * A declared bound that is not a non-negative integer fails the surface closed rather than falling back
5
+ * to the shipped default.
6
+ *
7
+ * This is deliberately the opposite of the spend ladder's malformed-allowance rule, which fails to
8
+ * *absent* because an outage costs more than a day of unbudgeted spend. The thing configured here is a
9
+ * capability, and the safe failure for a capability is not to exist: a typo that silently grants a
10
+ * 300-second window nobody asked for is worse than a typo that grants nothing.
11
+ */
12
+ function boundedInteger(value) {
13
+ if (value === undefined)
14
+ return undefined;
15
+ return Number.isInteger(value) && value >= 0 ? value : 'invalid';
16
+ }
17
+ export function resolveContinuity(declaration, override) {
18
+ if (declaration?.enabled !== true)
19
+ return OFF;
20
+ if (override?.enabled === false)
21
+ return OFF;
22
+ const declaredWindow = boundedInteger(declaration.windowSeconds);
23
+ const declaredRestores = boundedInteger(declaration.maxRestores);
24
+ const overriddenWindow = boundedInteger(override?.windowSeconds);
25
+ const overriddenRestores = boundedInteger(override?.maxRestores);
26
+ if (declaredWindow === 'invalid' ||
27
+ declaredRestores === 'invalid' ||
28
+ overriddenWindow === 'invalid' ||
29
+ overriddenRestores === 'invalid') {
30
+ return OFF;
31
+ }
32
+ // Each side is clamped to the structural ceiling first, then the tighter of the two wins. Clamping
33
+ // before the `Math.min` means an over-eager operator value degrades to the ceiling rather than
34
+ // becoming one, which is the same shape `surfaceEnvelope` uses for daily budgets.
35
+ const windowMs = Math.min(declaredWindow === undefined
36
+ ? ASSISTANT_CONTINUITY_WINDOW_MS
37
+ : continuityWindowMs(declaredWindow * 1000), overriddenWindow === undefined
38
+ ? ASSISTANT_CONTINUITY_WINDOW_CEILING_MS
39
+ : continuityWindowMs(overriddenWindow * 1000));
40
+ const maxRestores = Math.min(declaredRestores === undefined
41
+ ? ASSISTANT_CONTINUITY_MAX_RESTORES
42
+ : continuityMaxRestores(declaredRestores), overriddenRestores === undefined
43
+ ? ASSISTANT_CONTINUITY_RESTORE_CEILING
44
+ : continuityMaxRestores(overriddenRestores));
45
+ // Zero from either party is a real value and is the kill switch, so it must not read as "unset".
46
+ // Collapsing it to `OFF` here keeps every caller from having to remember that a zero-length window
47
+ // and a disabled surface are the same thing.
48
+ if (windowMs === 0 || maxRestores === 0)
49
+ return OFF;
50
+ return { enabled: true, windowMs, maxRestores };
51
+ }
52
+ //# sourceMappingURL=continuity-bounds.js.map
@@ -0,0 +1,115 @@
1
+ import type { TenantRef } from './tenant-ref.js';
2
+ /**
3
+ * Anonymous cross-page display continuity (ADR 0223, clauses 11-16).
4
+ *
5
+ * **A display handle, not a session.** Claiming one returns the conversation's visible text for
6
+ * rendering and mints a fresh session; it never revives the original, and grants no tool authority, no
7
+ * share of a spent turn budget, and no bound surface. Deliberately unlike the authenticated reattach in
8
+ * clause 7, it restores no view descriptor and no pending interaction — a capability that survives a
9
+ * navigation is exactly what this design refuses to create.
10
+ *
11
+ * **Its bounds are shaped by one asymmetry.** A sign-in ticket is safe partly because possession alone
12
+ * is worthless: spending one also requires the customer's client credentials, held server-side. An
13
+ * anonymous visitor has no such credential and no server-side counterpart, so possession of a handle is
14
+ * *sufficient by itself*. Hence four independent bounds — single use, a short window, binding to the
15
+ * context it was issued to, and a chain that ends — rather than any one of them carrying the weight.
16
+ */
17
+ /** What a handle is bound to. All three are hashes; no raw origin or visitor id is ever stored. */
18
+ export interface AssistantContinuityContext {
19
+ readonly embedId: string;
20
+ readonly originHash: string;
21
+ readonly visitorHash: string;
22
+ }
23
+ export interface AssistantContinuityRecord {
24
+ readonly id: string;
25
+ readonly sessionId: string;
26
+ readonly tenant: TenantRef;
27
+ readonly context: AssistantContinuityContext;
28
+ readonly handleHash: string;
29
+ /** Restores already spent on this conversation, carried across every rotation. */
30
+ readonly restoreCount: number;
31
+ /** Clamped at issue time and stored, so a record describes its own limit rather than trusting a caller. */
32
+ readonly maxRestores: number;
33
+ readonly createdAt: string;
34
+ readonly expiresAt: string;
35
+ readonly claimedAt?: string;
36
+ }
37
+ export type AssistantContinuityClaim = {
38
+ readonly ok: true;
39
+ readonly record: AssistantContinuityRecord;
40
+ /**
41
+ * The rotated handle for the next navigation, or `undefined` once the chain is spent. Withholding
42
+ * it is how continuity ends: the visitor still sees the text they were promised, and the next page
43
+ * simply has nothing to present, so no refusal code is needed for an ordinary ending.
44
+ */
45
+ readonly handle?: string;
46
+ } | {
47
+ readonly ok: false;
48
+ /**
49
+ * One code per refusal, because they are operationally different: `unknown` is a bad or already
50
+ * spent handle, `expired` is a visitor who took too long, and `context_mismatch` is a handle
51
+ * presented from an embed, origin, or visitor it was not issued to — the exfiltration signal, and
52
+ * the one worth alerting on, so it must not collapse into the first.
53
+ */
54
+ readonly reason: 'unknown' | 'expired' | 'context_mismatch';
55
+ };
56
+ /** Five minutes: long enough to read a page before clicking through, short enough to bound a leak. */
57
+ export declare const ASSISTANT_CONTINUITY_WINDOW_MS: number;
58
+ /**
59
+ * Ten minutes, the sign-in ticket's own TTL: an anonymous display handle must never outlive the
60
+ * authenticated capability it is modelled on, whatever a developer or operator asks for.
61
+ */
62
+ export declare const ASSISTANT_CONTINUITY_WINDOW_CEILING_MS: number;
63
+ /** Three restores covers the engaged visitor's page depth without letting a chain run indefinitely. */
64
+ export declare const ASSISTANT_CONTINUITY_MAX_RESTORES = 3;
65
+ /** Past ten, chain length stops being a meaningful control, so no configuration may exceed it. */
66
+ export declare const ASSISTANT_CONTINUITY_RESTORE_CEILING = 10;
67
+ export declare function continuityWindowMs(requested?: number): number;
68
+ export declare function continuityMaxRestores(requested?: number): number;
69
+ export interface AssistantContinuityStore {
70
+ /**
71
+ * Issue this session's handle for the next navigation, superseding any unclaimed one so a
72
+ * conversation never accumulates one live key per turn.
73
+ *
74
+ * Returns `undefined` when the effective window or chain clamps to zero. The kill switch is enforced
75
+ * here rather than at the call site, so a caller that forgets to check cannot hand out a handle the
76
+ * operator has switched off.
77
+ */
78
+ issue(input: {
79
+ readonly sessionId: string;
80
+ readonly tenant: TenantRef;
81
+ readonly context: AssistantContinuityContext;
82
+ readonly windowMs?: number;
83
+ readonly maxRestores?: number;
84
+ readonly now: Date;
85
+ }): Promise<{
86
+ readonly record: AssistantContinuityRecord;
87
+ readonly handle: string;
88
+ } | undefined>;
89
+ /**
90
+ * Spend a handle exactly once, rotating to a fresh one until the chain is spent. Atomic: two backends
91
+ * racing the same value must not both win, which is why this is one store seam rather than a
92
+ * read-then-write in the route.
93
+ */
94
+ claim(input: {
95
+ readonly handle: string;
96
+ readonly context: AssistantContinuityContext;
97
+ readonly now: Date;
98
+ }): Promise<AssistantContinuityClaim>;
99
+ /**
100
+ * Drop spent and expired rows, returning how many went.
101
+ *
102
+ * Retention belongs to this store rather than to a caller that may forget: handles are short-lived and
103
+ * must be swept, not accumulated. Cleanup may lag expiry safely, because `claim` already refuses a
104
+ * stale handle — the sweep bounds the table, it does not enforce the window.
105
+ */
106
+ sweepExpired(input: {
107
+ readonly now: Date;
108
+ }): Promise<number>;
109
+ }
110
+ export declare function continuityHandle(): string;
111
+ export declare function continuityDigest(value: string): string;
112
+ export declare function continuityHashesEqual(left: string, right: string): boolean;
113
+ /** Every leg must match: a handle is only valid for the embed, origin, and visitor it was issued to. */
114
+ export declare function sameContinuityContext(left: AssistantContinuityContext, right: AssistantContinuityContext): boolean;
115
+ //# sourceMappingURL=continuity-store.d.ts.map
@@ -0,0 +1,51 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ /** Five minutes: long enough to read a page before clicking through, short enough to bound a leak. */
3
+ export const ASSISTANT_CONTINUITY_WINDOW_MS = 5 * 60 * 1000;
4
+ /**
5
+ * Ten minutes, the sign-in ticket's own TTL: an anonymous display handle must never outlive the
6
+ * authenticated capability it is modelled on, whatever a developer or operator asks for.
7
+ */
8
+ export const ASSISTANT_CONTINUITY_WINDOW_CEILING_MS = 10 * 60 * 1000;
9
+ /** Three restores covers the engaged visitor's page depth without letting a chain run indefinitely. */
10
+ export const ASSISTANT_CONTINUITY_MAX_RESTORES = 3;
11
+ /** Past ten, chain length stops being a meaningful control, so no configuration may exceed it. */
12
+ export const ASSISTANT_CONTINUITY_RESTORE_CEILING = 10;
13
+ /**
14
+ * Clamp a requested bound the way the admission envelope does: one direction only.
15
+ *
16
+ * Zero is the deploy-free kill switch, and anything malformed — negative, fractional, NaN — collapses to
17
+ * zero rather than to the default. A typo must fail closed here: unlike a spend allowance, where an
18
+ * outage is worse than a day of unbudgeted cost, a continuity handle is a capability, and the safe
19
+ * failure for a capability is not to exist.
20
+ */
21
+ function clampBound(requested, fallback, ceiling) {
22
+ if (requested === undefined)
23
+ return fallback;
24
+ if (!Number.isInteger(requested) || requested < 0)
25
+ return 0;
26
+ return Math.min(requested, ceiling);
27
+ }
28
+ export function continuityWindowMs(requested) {
29
+ return clampBound(requested, ASSISTANT_CONTINUITY_WINDOW_MS, ASSISTANT_CONTINUITY_WINDOW_CEILING_MS);
30
+ }
31
+ export function continuityMaxRestores(requested) {
32
+ return clampBound(requested, ASSISTANT_CONTINUITY_MAX_RESTORES, ASSISTANT_CONTINUITY_RESTORE_CEILING);
33
+ }
34
+ export function continuityHandle() {
35
+ return `cnt_${randomBytes(24).toString('base64url')}`;
36
+ }
37
+ export function continuityDigest(value) {
38
+ return createHash('sha256').update(value).digest('hex');
39
+ }
40
+ export function continuityHashesEqual(left, right) {
41
+ const a = Buffer.from(left, 'utf8');
42
+ const b = Buffer.from(right, 'utf8');
43
+ return a.length === b.length && timingSafeEqual(a, b);
44
+ }
45
+ /** Every leg must match: a handle is only valid for the embed, origin, and visitor it was issued to. */
46
+ export function sameContinuityContext(left, right) {
47
+ return (left.embedId === right.embedId &&
48
+ left.originHash === right.originHash &&
49
+ left.visitorHash === right.visitorHash);
50
+ }
51
+ //# sourceMappingURL=continuity-store.js.map
@@ -0,0 +1,26 @@
1
+ import { type AssistantContinuityClaim, type AssistantContinuityContext, type AssistantContinuityRecord, type AssistantContinuityStore } from './continuity-store.js';
2
+ import type { TenantRef } from './tenant-ref.js';
3
+ /** The in-memory half of the pair. Both run the shared parity suite; neither is the only proof. */
4
+ export declare class InMemoryAssistantContinuityStore implements AssistantContinuityStore {
5
+ #private;
6
+ issue(input: {
7
+ readonly sessionId: string;
8
+ readonly tenant: TenantRef;
9
+ readonly context: AssistantContinuityContext;
10
+ readonly windowMs?: number;
11
+ readonly maxRestores?: number;
12
+ readonly now: Date;
13
+ }): Promise<{
14
+ readonly record: AssistantContinuityRecord;
15
+ readonly handle: string;
16
+ } | undefined>;
17
+ claim(input: {
18
+ readonly handle: string;
19
+ readonly context: AssistantContinuityContext;
20
+ readonly now: Date;
21
+ }): Promise<AssistantContinuityClaim>;
22
+ sweepExpired(input: {
23
+ readonly now: Date;
24
+ }): Promise<number>;
25
+ }
26
+ //# sourceMappingURL=in-memory-continuity-store.d.ts.map
@@ -0,0 +1,80 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { continuityDigest, continuityHandle, continuityHashesEqual, continuityMaxRestores, continuityWindowMs, sameContinuityContext, } from './continuity-store.js';
3
+ /** The in-memory half of the pair. Both run the shared parity suite; neither is the only proof. */
4
+ export class InMemoryAssistantContinuityStore {
5
+ #rows = new Map();
6
+ async issue(input) {
7
+ return this.#write({ ...input, restoreCount: 0 });
8
+ }
9
+ async claim(input) {
10
+ const hash = continuityDigest(input.handle);
11
+ const found = [...this.#rows.values()].find((row) => continuityHashesEqual(row.handleHash, hash));
12
+ if (!found || found.claimedAt !== undefined)
13
+ return { ok: false, reason: 'unknown' };
14
+ // Context before expiry: a handle replayed from somewhere it was never issued should read as the
15
+ // exfiltration signal it is, whether or not the value happened to be stale by then.
16
+ if (!sameContinuityContext(found.context, input.context)) {
17
+ return { ok: false, reason: 'context_mismatch' };
18
+ }
19
+ if (Date.parse(found.expiresAt) <= input.now.getTime())
20
+ return { ok: false, reason: 'expired' };
21
+ const restoreCount = found.restoreCount + 1;
22
+ const claimed = {
23
+ ...found,
24
+ restoreCount,
25
+ claimedAt: input.now.toISOString(),
26
+ };
27
+ this.#rows.set(found.id, claimed);
28
+ if (restoreCount >= found.maxRestores)
29
+ return { ok: true, record: claimed };
30
+ const rotated = this.#write({
31
+ sessionId: found.sessionId,
32
+ tenant: found.tenant,
33
+ context: found.context,
34
+ // Re-derive from the record so a rotation can never widen what the first issue clamped.
35
+ windowMs: Date.parse(found.expiresAt) - Date.parse(found.createdAt),
36
+ maxRestores: found.maxRestores,
37
+ restoreCount,
38
+ now: input.now,
39
+ });
40
+ return rotated === undefined
41
+ ? { ok: true, record: claimed }
42
+ : { ok: true, record: claimed, handle: rotated.handle };
43
+ }
44
+ async sweepExpired(input) {
45
+ let removed = 0;
46
+ for (const [id, row] of this.#rows) {
47
+ if (row.claimedAt !== undefined || Date.parse(row.expiresAt) <= input.now.getTime()) {
48
+ this.#rows.delete(id);
49
+ removed++;
50
+ }
51
+ }
52
+ return removed;
53
+ }
54
+ #write(input) {
55
+ const windowMs = continuityWindowMs(input.windowMs);
56
+ const maxRestores = continuityMaxRestores(input.maxRestores);
57
+ if (windowMs === 0 || maxRestores === 0)
58
+ return undefined;
59
+ // Supersede rather than accumulate: one live handle per session.
60
+ for (const [id, row] of this.#rows) {
61
+ if (row.sessionId === input.sessionId && row.claimedAt === undefined)
62
+ this.#rows.delete(id);
63
+ }
64
+ const handle = continuityHandle();
65
+ const record = {
66
+ id: `cont_${randomUUID().replaceAll('-', '')}`,
67
+ sessionId: input.sessionId,
68
+ tenant: input.tenant,
69
+ context: input.context,
70
+ handleHash: continuityDigest(handle),
71
+ restoreCount: input.restoreCount,
72
+ maxRestores,
73
+ createdAt: input.now.toISOString(),
74
+ expiresAt: new Date(input.now.getTime() + windowMs).toISOString(),
75
+ };
76
+ this.#rows.set(record.id, record);
77
+ return { record, handle };
78
+ }
79
+ }
80
+ //# sourceMappingURL=in-memory-continuity-store.js.map
@@ -94,6 +94,7 @@ export declare function requestModelCompletion(input: {
94
94
  readonly messages: readonly AssistantModelMessage[];
95
95
  readonly tools: readonly AssistantModelTool[];
96
96
  readonly toolChoice?: 'auto' | 'none' | 'required';
97
+ readonly jsonOutput?: boolean;
97
98
  readonly fetcher: (url: string, init: RequestInit) => Promise<Response>;
98
99
  readonly onContent?: (delta: string) => void;
99
100
  readonly maxResponseBytes?: number;
@@ -17,6 +17,7 @@ export async function requestModelCompletion(input) {
17
17
  store: false,
18
18
  input: responsesInput(input.messages),
19
19
  tools: responsesTools(input.tools),
20
+ ...(input.jsonOutput ? { text: { format: { type: 'json_object' } } } : {}),
20
21
  ...(input.toolChoice === undefined ? {} : { tool_choice: input.toolChoice }),
21
22
  ...(completionLimit === undefined ? {} : { max_output_tokens: completionLimit }),
22
23
  }
@@ -26,6 +27,7 @@ export async function requestModelCompletion(input) {
26
27
  stream: true,
27
28
  messages: input.messages,
28
29
  tools: input.tools,
30
+ ...(input.jsonOutput ? { response_format: { type: 'json_object' } } : {}),
29
31
  ...(input.toolChoice === undefined ? {} : { tool_choice: input.toolChoice }),
30
32
  ...(completionLimit === undefined ? {} : { max_completion_tokens: completionLimit }),
31
33
  });
@@ -0,0 +1,11 @@
1
+ export function nonNegativeInteger(value) {
2
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
3
+ }
4
+ export async function readBoundedText(response, maxBytes) {
5
+ const text = await response.text();
6
+ if (new TextEncoder().encode(text).byteLength > maxBytes) {
7
+ throw new Error('model response too large');
8
+ }
9
+ return text;
10
+ }
11
+ //# sourceMappingURL=model-response-values.js.map
@@ -1,3 +1,4 @@
1
+ import { nonNegativeInteger, readBoundedText } from './model-response-values.js';
1
2
  export function responsesInput(messages) {
2
3
  return messages.flatMap((message) => {
3
4
  if (message.role === 'tool') {
@@ -150,14 +151,4 @@ function parseRecord(value) {
150
151
  }
151
152
  return value;
152
153
  }
153
- function nonNegativeInteger(value) {
154
- return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
155
- }
156
- async function readBoundedText(response, maxBytes) {
157
- const text = await response.text();
158
- if (new TextEncoder().encode(text).byteLength > maxBytes) {
159
- throw new Error('model response too large');
160
- }
161
- return text;
162
- }
163
154
  //# sourceMappingURL=model-responses.js.map
@@ -1,3 +1,4 @@
1
+ import { nonNegativeInteger, readBoundedText } from './model-response-values.js';
1
2
  export async function readModelCompletion(response, onContent, maxBytes = 1 << 20) {
2
3
  const contentType = response.headers.get('content-type') ?? '';
3
4
  if (!contentType.includes('text/event-stream')) {
@@ -111,14 +112,4 @@ function normalizeUsageValue(usage) {
111
112
  ...(reasoningTokens === undefined ? {} : { reasoningTokens }),
112
113
  };
113
114
  }
114
- function nonNegativeInteger(value) {
115
- return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
116
- }
117
- async function readBoundedText(response, maxBytes) {
118
- const text = await response.text();
119
- if (new TextEncoder().encode(text).byteLength > maxBytes) {
120
- throw new Error('model response too large');
121
- }
122
- return text;
123
- }
124
115
  //# sourceMappingURL=model-stream.js.map
@@ -17,12 +17,15 @@ export * from './assistant-store.js';
17
17
  export * from './assistant-transcript.js';
18
18
  export * from './assistant-transcript-events.js';
19
19
  export * from './assistant-view-availability.js';
20
+ export * from './continuity-bounds.js';
21
+ export * from './continuity-store.js';
20
22
  export * from './elevation.js';
21
23
  export * from './elevation-store.js';
22
24
  export * from './embed-operator-view.js';
23
25
  export * from './embed-script.js';
24
26
  export * from './embed-store.js';
25
27
  export * from './in-memory-assistant-appearance-store.js';
28
+ export * from './in-memory-continuity-store.js';
26
29
  export * from './in-memory-elevation-store.js';
27
30
  export * from './in-memory-embed-store.js';
28
31
  export * from './managed-spend.js';
@@ -17,12 +17,15 @@ export * from './assistant-store.js';
17
17
  export * from './assistant-transcript.js';
18
18
  export * from './assistant-transcript-events.js';
19
19
  export * from './assistant-view-availability.js';
20
+ export * from './continuity-bounds.js';
21
+ export * from './continuity-store.js';
20
22
  export * from './elevation.js';
21
23
  export * from './elevation-store.js';
22
24
  export * from './embed-operator-view.js';
23
25
  export * from './embed-script.js';
24
26
  export * from './embed-store.js';
25
27
  export * from './in-memory-assistant-appearance-store.js';
28
+ export * from './in-memory-continuity-store.js';
26
29
  export * from './in-memory-elevation-store.js';
27
30
  export * from './in-memory-embed-store.js';
28
31
  export * from './managed-spend.js';
@@ -173,6 +173,31 @@ export interface AuthenticatedWebsiteAccess {
173
173
  readonly enabled?: boolean;
174
174
  };
175
175
  }
176
+ /**
177
+ * Anonymous cross-page display continuity for one public surface (ADR 0223, clauses 11-16).
178
+ *
179
+ * Off unless asked for. Anonymous conversation text is your content on your page, so whether it
180
+ * survives a navigation is your call rather than a default the platform imposes.
181
+ *
182
+ * What a visitor gets is the text they have already read, re-rendered on the next page, on a fresh
183
+ * session. What it never grants is the old session itself: no tool authority, no share of a spent
184
+ * turn budget, and no pending confirmation carried across — a capability that survives a navigation
185
+ * is exactly what this design refuses to create.
186
+ */
187
+ export interface AssistantContinuityDeclaration {
188
+ readonly enabled?: boolean;
189
+ /**
190
+ * How long a handle stays valid after the turn that issued it, measured from when the visitor last
191
+ * spoke. Defaults to 300; 600 is the structural ceiling, and 0 disables continuity outright. An
192
+ * operator may lower what you declare here and can never raise it.
193
+ */
194
+ readonly windowSeconds?: number;
195
+ /**
196
+ * How many times one conversation may be restored before continuity ends and the next page starts
197
+ * fresh. Defaults to 3, ceiling 10, and 0 disables continuity outright.
198
+ */
199
+ readonly maxRestores?: number;
200
+ }
176
201
  export interface PublicWebsiteAccess {
177
202
  readonly mode: 'public' | 'mixed';
178
203
  readonly origins: readonly string[];
@@ -182,6 +207,8 @@ export interface PublicWebsiteAccess {
182
207
  readonly webmcp?: {
183
208
  readonly enabled?: boolean;
184
209
  };
210
+ /** @see PublicWebsiteInput.continuity */
211
+ readonly continuity?: AssistantContinuityDeclaration;
185
212
  }
186
213
  export type AssistantAccess = AuthenticatedWebsiteAccess | PublicWebsiteAccess;
187
214
  export interface AuthenticatedWebsiteInput {
@@ -226,6 +253,14 @@ export interface PublicWebsiteInput {
226
253
  readonly webmcp?: {
227
254
  readonly enabled?: boolean;
228
255
  };
256
+ /**
257
+ * Let an anonymous visitor keep the conversation they can see when they navigate to another page
258
+ * of this site. Omitted means off, and no existing embed changes behavior.
259
+ *
260
+ * Declared only here, never on an authenticated surface: that direction reattaches through a
261
+ * backend-verified sign-in instead, so a declaration there would configure nothing.
262
+ */
263
+ readonly continuity?: AssistantContinuityDeclaration;
229
264
  }
230
265
  export declare function authenticatedWebsite(input: AuthenticatedWebsiteInput): AuthenticatedWebsiteAccess;
231
266
  export declare function publicWebsite(input: PublicWebsiteInput): PublicWebsiteAccess;
@@ -16,6 +16,7 @@ export function publicWebsite(input) {
16
16
  capabilities: [...input.capabilities],
17
17
  ...(input.instructions === undefined ? {} : { instructions: input.instructions }),
18
18
  ...(input.webmcp === undefined ? {} : { webmcp: { ...input.webmcp } }),
19
+ ...(input.continuity === undefined ? {} : { continuity: { ...input.continuity } }),
19
20
  };
20
21
  }
21
22
  function serializeOrigins(origins, path) {
@@ -59,6 +60,9 @@ export function embeddedAssistant(input) {
59
60
  ? { sessionClaims: structuredClone(surface.sessionClaims) }
60
61
  : {}),
61
62
  ...(surface.webmcp === undefined ? {} : { webmcp: { ...surface.webmcp } }),
63
+ ...(surface.mode !== 'authenticated' && surface.continuity !== undefined
64
+ ? { continuity: { ...surface.continuity } }
65
+ : {}),
62
66
  })),
63
67
  allowedOrigins: surfaces.flatMap((surface) => [...surface.origins]),
64
68
  ...structuredClone(ui),
@@ -0,0 +1,86 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * Shared manifest primitives: colors, https URLs, managed variable expressions, assistant origins,
4
+ * packaged asset references, and the server brand kit.
5
+ *
6
+ * Extracted verbatim from `schema.ts` so that file stays inside its size budget as the assistant
7
+ * surface grows. These are leaf validators with no dependency on tools, prompts, widgets, or the
8
+ * assistant block, which is what makes the split one-directional and free of an import cycle.
9
+ */
10
+ const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'must be a 6-digit hex color');
11
+ export const httpsUrlSchema = z.url().regex(/^https:\/\//, 'must use https');
12
+ const managedVariableExpressionPattern = /^\$\{env\.[A-Za-z0-9_]+\}$/;
13
+ export const managedVariableExpressionSchema = z
14
+ .string()
15
+ .regex(managedVariableExpressionPattern, 'must be an exact managed variable expression');
16
+ // HTTP is allowed only for loopback development origins (mirrors the host-pattern loopback
17
+ // exception used for connector/CSP origins); production embedding origins stay https-only.
18
+ const assistantOriginOrVariablePattern = /^(?:\$\{env\.[A-Za-z0-9_]+\}|https:\/\/[^/?#@\\]+|http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?)$/;
19
+ function isCanonicalAssistantOrigin(value) {
20
+ try {
21
+ return new URL(value).origin === value;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ export const assistantOriginOrVariableSchema = z
28
+ .string()
29
+ .regex(assistantOriginOrVariablePattern, 'must be a canonical bare origin using https (http is allowed only for localhost/127.0.0.1/[::1] loopback development origins)')
30
+ .refine((value) => managedVariableExpressionPattern.test(value) || isCanonicalAssistantOrigin(value), 'must be a canonical bare origin');
31
+ const packagedAssetSchema = z
32
+ .object({
33
+ kind: z.literal('asset'),
34
+ sourcePath: z.string().min(1),
35
+ logicalId: z.string().min(1),
36
+ })
37
+ .strict();
38
+ const brandAssetSchema = z
39
+ .object({
40
+ uri: z.union([httpsUrlSchema, packagedAssetSchema]),
41
+ darkUri: z.union([httpsUrlSchema, packagedAssetSchema]).optional(),
42
+ alt: z.string().trim().min(1),
43
+ })
44
+ .strict();
45
+ const brandThemeTokensSchema = z
46
+ .object({
47
+ surface: hexColorSchema.optional(),
48
+ surfaceRaised: hexColorSchema.optional(),
49
+ surfaceMuted: hexColorSchema.optional(),
50
+ text: hexColorSchema.optional(),
51
+ textMuted: hexColorSchema.optional(),
52
+ accent: hexColorSchema.optional(),
53
+ accentText: hexColorSchema.optional(),
54
+ link: hexColorSchema.optional(),
55
+ border: hexColorSchema.optional(),
56
+ borderStrong: hexColorSchema.optional(),
57
+ focus: hexColorSchema.optional(),
58
+ success: hexColorSchema.optional(),
59
+ warning: hexColorSchema.optional(),
60
+ danger: hexColorSchema.optional(),
61
+ code: hexColorSchema.optional(),
62
+ })
63
+ .strict();
64
+ export const serverBrandingSchema = z
65
+ .object({
66
+ name: z.string().trim().min(1).optional(),
67
+ accent: hexColorSchema.optional(),
68
+ surface: hexColorSchema.optional(),
69
+ surfaceDark: hexColorSchema.optional(),
70
+ logo: brandAssetSchema.optional(),
71
+ mark: brandAssetSchema.optional(),
72
+ avatar: brandAssetSchema.optional(),
73
+ theme: z
74
+ .object({
75
+ light: brandThemeTokensSchema.optional(),
76
+ dark: brandThemeTokensSchema.optional(),
77
+ })
78
+ .strict()
79
+ .optional(),
80
+ radius: z.enum(['none', 'sm', 'md', 'lg']).optional(),
81
+ density: z.enum(['compact', 'comfortable']).optional(),
82
+ typography: z.enum(['system', 'serif', 'mono']).optional(),
83
+ colorScheme: z.enum(['auto', 'light', 'dark']).optional(),
84
+ })
85
+ .strict();
86
+ //# sourceMappingURL=branding-schema.js.map
@@ -1,5 +1,4 @@
1
1
  import { z } from 'zod';
2
- import type { PackagedAssetReference } from '../assets.js';
3
2
  type ToolAuthorizationManifest = {
4
3
  readonly requiredScopes?: readonly string[] | undefined;
5
4
  readonly allowedRoles?: readonly string[] | undefined;
@@ -381,6 +380,11 @@ export declare const manifestV1Schema: z.ZodObject<{
381
380
  webmcp: z.ZodOptional<z.ZodObject<{
382
381
  enabled: z.ZodOptional<z.ZodBoolean>;
383
382
  }, z.core.$strict>>;
383
+ continuity: z.ZodOptional<z.ZodObject<{
384
+ enabled: z.ZodOptional<z.ZodBoolean>;
385
+ windowSeconds: z.ZodOptional<z.ZodNumber>;
386
+ maxRestores: z.ZodOptional<z.ZodNumber>;
387
+ }, z.core.$strict>>;
384
388
  }, z.core.$strict>>>;
385
389
  sessionClaims: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
386
390
  exposeToModel: z.ZodOptional<z.ZodBoolean>;
@@ -395,18 +399,18 @@ export declare const manifestV1Schema: z.ZodObject<{
395
399
  surface: z.ZodOptional<z.ZodString>;
396
400
  surfaceDark: z.ZodOptional<z.ZodString>;
397
401
  logo: z.ZodOptional<z.ZodObject<{
398
- uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>;
399
- darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>>;
402
+ uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>;
403
+ darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>>;
400
404
  alt: z.ZodString;
401
405
  }, z.core.$strict>>;
402
406
  mark: z.ZodOptional<z.ZodObject<{
403
- uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>;
404
- darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>>;
407
+ uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>;
408
+ darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>>;
405
409
  alt: z.ZodString;
406
410
  }, z.core.$strict>>;
407
411
  avatar: z.ZodOptional<z.ZodObject<{
408
- uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>;
409
- darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>>;
412
+ uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>;
413
+ darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>>;
410
414
  alt: z.ZodString;
411
415
  }, z.core.$strict>>;
412
416
  theme: z.ZodOptional<z.ZodObject<{
@@ -864,6 +868,11 @@ export declare const manifestV2Schema: z.ZodObject<{
864
868
  webmcp: z.ZodOptional<z.ZodObject<{
865
869
  enabled: z.ZodOptional<z.ZodBoolean>;
866
870
  }, z.core.$strict>>;
871
+ continuity: z.ZodOptional<z.ZodObject<{
872
+ enabled: z.ZodOptional<z.ZodBoolean>;
873
+ windowSeconds: z.ZodOptional<z.ZodNumber>;
874
+ maxRestores: z.ZodOptional<z.ZodNumber>;
875
+ }, z.core.$strict>>;
867
876
  }, z.core.$strict>>>;
868
877
  sessionClaims: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
869
878
  exposeToModel: z.ZodOptional<z.ZodBoolean>;
@@ -878,18 +887,18 @@ export declare const manifestV2Schema: z.ZodObject<{
878
887
  surface: z.ZodOptional<z.ZodString>;
879
888
  surfaceDark: z.ZodOptional<z.ZodString>;
880
889
  logo: z.ZodOptional<z.ZodObject<{
881
- uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>;
882
- darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>>;
890
+ uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>;
891
+ darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>>;
883
892
  alt: z.ZodString;
884
893
  }, z.core.$strict>>;
885
894
  mark: z.ZodOptional<z.ZodObject<{
886
- uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>;
887
- darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>>;
895
+ uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>;
896
+ darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>>;
888
897
  alt: z.ZodString;
889
898
  }, z.core.$strict>>;
890
899
  avatar: z.ZodOptional<z.ZodObject<{
891
- uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>;
892
- darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>>;
900
+ uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>;
901
+ darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>>;
893
902
  alt: z.ZodString;
894
903
  }, z.core.$strict>>;
895
904
  theme: z.ZodOptional<z.ZodObject<{
@@ -1557,6 +1566,11 @@ export declare const manifestSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
1557
1566
  webmcp: z.ZodOptional<z.ZodObject<{
1558
1567
  enabled: z.ZodOptional<z.ZodBoolean>;
1559
1568
  }, z.core.$strict>>;
1569
+ continuity: z.ZodOptional<z.ZodObject<{
1570
+ enabled: z.ZodOptional<z.ZodBoolean>;
1571
+ windowSeconds: z.ZodOptional<z.ZodNumber>;
1572
+ maxRestores: z.ZodOptional<z.ZodNumber>;
1573
+ }, z.core.$strict>>;
1560
1574
  }, z.core.$strict>>>;
1561
1575
  sessionClaims: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
1562
1576
  exposeToModel: z.ZodOptional<z.ZodBoolean>;
@@ -1571,18 +1585,18 @@ export declare const manifestSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
1571
1585
  surface: z.ZodOptional<z.ZodString>;
1572
1586
  surfaceDark: z.ZodOptional<z.ZodString>;
1573
1587
  logo: z.ZodOptional<z.ZodObject<{
1574
- uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>;
1575
- darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>>;
1588
+ uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>;
1589
+ darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>>;
1576
1590
  alt: z.ZodString;
1577
1591
  }, z.core.$strict>>;
1578
1592
  mark: z.ZodOptional<z.ZodObject<{
1579
- uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>;
1580
- darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>>;
1593
+ uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>;
1594
+ darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>>;
1581
1595
  alt: z.ZodString;
1582
1596
  }, z.core.$strict>>;
1583
1597
  avatar: z.ZodOptional<z.ZodObject<{
1584
- uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>;
1585
- darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>>;
1598
+ uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>;
1599
+ darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>>;
1586
1600
  alt: z.ZodString;
1587
1601
  }, z.core.$strict>>;
1588
1602
  theme: z.ZodOptional<z.ZodObject<{
@@ -2039,6 +2053,11 @@ export declare const manifestSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
2039
2053
  webmcp: z.ZodOptional<z.ZodObject<{
2040
2054
  enabled: z.ZodOptional<z.ZodBoolean>;
2041
2055
  }, z.core.$strict>>;
2056
+ continuity: z.ZodOptional<z.ZodObject<{
2057
+ enabled: z.ZodOptional<z.ZodBoolean>;
2058
+ windowSeconds: z.ZodOptional<z.ZodNumber>;
2059
+ maxRestores: z.ZodOptional<z.ZodNumber>;
2060
+ }, z.core.$strict>>;
2042
2061
  }, z.core.$strict>>>;
2043
2062
  sessionClaims: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
2044
2063
  exposeToModel: z.ZodOptional<z.ZodBoolean>;
@@ -2053,18 +2072,18 @@ export declare const manifestSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
2053
2072
  surface: z.ZodOptional<z.ZodString>;
2054
2073
  surfaceDark: z.ZodOptional<z.ZodString>;
2055
2074
  logo: z.ZodOptional<z.ZodObject<{
2056
- uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>;
2057
- darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>>;
2075
+ uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>;
2076
+ darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>>;
2058
2077
  alt: z.ZodString;
2059
2078
  }, z.core.$strict>>;
2060
2079
  mark: z.ZodOptional<z.ZodObject<{
2061
- uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>;
2062
- darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>>;
2080
+ uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>;
2081
+ darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>>;
2063
2082
  alt: z.ZodString;
2064
2083
  }, z.core.$strict>>;
2065
2084
  avatar: z.ZodOptional<z.ZodObject<{
2066
- uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>;
2067
- darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<PackagedAssetReference, unknown, z.core.$ZodTypeInternals<PackagedAssetReference, unknown>>]>>;
2085
+ uri: z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>;
2086
+ darkUri: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodType<import("../assets.js").PackagedAssetReference, unknown, z.core.$ZodTypeInternals<import("../assets.js").PackagedAssetReference, unknown>>]>>;
2068
2087
  alt: z.ZodString;
2069
2088
  }, z.core.$strict>>;
2070
2089
  theme: z.ZodOptional<z.ZodObject<{
@@ -3,6 +3,7 @@ import { z } from 'zod';
3
3
  import { MAX_COMPILED_WIDGET_HTML_BYTES, MAX_RAW_WIDGET_HTML_BYTES } from '../widget-limits.js';
4
4
  import { agentGuideSchema } from './agent-guide-schema.js';
5
5
  import { serverAuthSchema, serverAuthV2Schema } from './auth-schema.js';
6
+ import { assistantOriginOrVariableSchema, httpsUrlSchema, managedVariableExpressionSchema, serverBrandingSchema, } from './branding-schema.js';
6
7
  import { NAME_PATTERN } from './naming.js';
7
8
  /**
8
9
  * Zod schema for Core v1 and v2 manifests (ADRs 0150 and 0157). V1 remains accepted unchanged;
@@ -133,82 +134,6 @@ const promptSchema = z.object({
133
134
  arguments: z.array(promptArgumentSchema).optional(),
134
135
  fulfilment: fulfilmentSchema,
135
136
  });
136
- const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'must be a 6-digit hex color');
137
- const httpsUrlSchema = z.url().regex(/^https:\/\//, 'must use https');
138
- const managedVariableExpressionPattern = /^\$\{env\.[A-Za-z0-9_]+\}$/;
139
- const managedVariableExpressionSchema = z
140
- .string()
141
- .regex(managedVariableExpressionPattern, 'must be an exact managed variable expression');
142
- // HTTP is allowed only for loopback development origins (mirrors the host-pattern loopback
143
- // exception used for connector/CSP origins); production embedding origins stay https-only.
144
- const assistantOriginOrVariablePattern = /^(?:\$\{env\.[A-Za-z0-9_]+\}|https:\/\/[^/?#@\\]+|http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?)$/;
145
- function isCanonicalAssistantOrigin(value) {
146
- try {
147
- return new URL(value).origin === value;
148
- }
149
- catch {
150
- return false;
151
- }
152
- }
153
- const assistantOriginOrVariableSchema = z
154
- .string()
155
- .regex(assistantOriginOrVariablePattern, 'must be a canonical bare origin using https (http is allowed only for localhost/127.0.0.1/[::1] loopback development origins)')
156
- .refine((value) => managedVariableExpressionPattern.test(value) || isCanonicalAssistantOrigin(value), 'must be a canonical bare origin');
157
- const packagedAssetSchema = z
158
- .object({
159
- kind: z.literal('asset'),
160
- sourcePath: z.string().min(1),
161
- logicalId: z.string().min(1),
162
- })
163
- .strict();
164
- const brandAssetSchema = z
165
- .object({
166
- uri: z.union([httpsUrlSchema, packagedAssetSchema]),
167
- darkUri: z.union([httpsUrlSchema, packagedAssetSchema]).optional(),
168
- alt: z.string().trim().min(1),
169
- })
170
- .strict();
171
- const brandThemeTokensSchema = z
172
- .object({
173
- surface: hexColorSchema.optional(),
174
- surfaceRaised: hexColorSchema.optional(),
175
- surfaceMuted: hexColorSchema.optional(),
176
- text: hexColorSchema.optional(),
177
- textMuted: hexColorSchema.optional(),
178
- accent: hexColorSchema.optional(),
179
- accentText: hexColorSchema.optional(),
180
- link: hexColorSchema.optional(),
181
- border: hexColorSchema.optional(),
182
- borderStrong: hexColorSchema.optional(),
183
- focus: hexColorSchema.optional(),
184
- success: hexColorSchema.optional(),
185
- warning: hexColorSchema.optional(),
186
- danger: hexColorSchema.optional(),
187
- code: hexColorSchema.optional(),
188
- })
189
- .strict();
190
- const serverBrandingSchema = z
191
- .object({
192
- name: z.string().trim().min(1).optional(),
193
- accent: hexColorSchema.optional(),
194
- surface: hexColorSchema.optional(),
195
- surfaceDark: hexColorSchema.optional(),
196
- logo: brandAssetSchema.optional(),
197
- mark: brandAssetSchema.optional(),
198
- avatar: brandAssetSchema.optional(),
199
- theme: z
200
- .object({
201
- light: brandThemeTokensSchema.optional(),
202
- dark: brandThemeTokensSchema.optional(),
203
- })
204
- .strict()
205
- .optional(),
206
- radius: z.enum(['none', 'sm', 'md', 'lg']).optional(),
207
- density: z.enum(['compact', 'comfortable']).optional(),
208
- typography: z.enum(['system', 'serif', 'mono']).optional(),
209
- colorScheme: z.enum(['auto', 'light', 'dark']).optional(),
210
- })
211
- .strict();
212
137
  const assistantPresentationToneSchema = z.enum(['neutral', 'success', 'warning', 'danger']);
213
138
  const optionalStrictObject = (shape) => z.object(shape).strict().optional();
214
139
  const assistantPresentationSchema = z
@@ -357,6 +282,18 @@ const embeddedAssistantSchema = z
357
282
  // `assistantUiSchema.shape` — this is not renderer presentation, and a surface that
358
283
  // acquired it by a spread would be an accident rather than a decision.
359
284
  webmcp: optionalStrictObject({ enabled: z.boolean().optional() }),
285
+ // Anonymous cross-page display continuity (ADR 0223, clauses 11-16), declared per public
286
+ // or mixed surface. These bounds mirror the structural ceilings the gateway clamps to at
287
+ // runtime; the ADR is the single source, and this package cannot import the gateway
288
+ // because the dependency runs the other way. Refusing rather than clamping is deliberate:
289
+ // the gateway narrows an operator's value silently by design, but a developer who asks
290
+ // for more than the ceiling has made a mistake, and silence would hide it until someone
291
+ // measured the live behaviour.
292
+ continuity: optionalStrictObject({
293
+ enabled: z.boolean().optional(),
294
+ windowSeconds: z.number().int().min(0).max(600).optional(),
295
+ maxRestores: z.number().int().min(0).max(10).optional(),
296
+ }),
360
297
  })
361
298
  .strict())
362
299
  .min(1)
@@ -374,6 +311,17 @@ const embeddedAssistantSchema = z
374
311
  .strict()
375
312
  .superRefine((assistant, ctx) => {
376
313
  assistant.surfaces?.forEach((surface, index) => {
314
+ // The authenticated direction already reattaches through ADR 0223 clause 7, authorized by a
315
+ // backend-verified subject rather than by possession of a handle. A `continuity` block here
316
+ // would be a developer believing they configured something nothing reads, so refuse it rather
317
+ // than drop it silently.
318
+ if (surface.mode === 'authenticated' && surface.continuity !== undefined) {
319
+ ctx.addIssue({
320
+ code: 'custom',
321
+ path: ['surfaces', index, 'continuity'],
322
+ message: 'continuity belongs on a public or mixed website surface',
323
+ });
324
+ }
377
325
  if (surface.mode === 'authenticated')
378
326
  return;
379
327
  // A public surface fails closed: an omitted allowlist would otherwise read as "expose
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noodleseed/one",
3
- "version": "0.154.0",
3
+ "version": "0.155.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",