@noodleseed/agent-kit 0.90.0 → 0.92.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 (102) hide show
  1. package/manifest.json +707 -275
  2. package/package.json +1 -1
  3. package/skills/claude-code/SKILL.md +1 -1
  4. package/skills/claude-code/authoring-mcp-servers/SKILL.md +4 -4
  5. package/skills/claude-code/authoring-mcp-servers/references/authoring-workflow.md +451 -0
  6. package/skills/claude-code/authoring-mcp-servers/references/build-an-mcp-server.md +56 -0
  7. package/skills/claude-code/authoring-mcp-servers/references/sdk-surface.md +258 -0
  8. package/skills/claude-code/building-mcp-apps/SKILL.md +4 -4
  9. package/skills/claude-code/building-mcp-apps/references/build-an-mcp-app.md +53 -0
  10. package/skills/claude-code/building-mcp-apps/references/experience-design.md +155 -0
  11. package/skills/claude-code/building-mcp-apps/references/widgets-and-apps.md +277 -0
  12. package/skills/claude-code/connecting-apis-to-mcp/SKILL.md +3 -3
  13. package/skills/claude-code/connecting-apis-to-mcp/references/authoring-workflow.md +451 -0
  14. package/skills/claude-code/connecting-apis-to-mcp/references/connect-an-api.md +245 -0
  15. package/skills/claude-code/creating-product-agent-guides/SKILL.md +2 -2
  16. package/skills/claude-code/creating-product-agent-guides/references/product-agent-guides.md +55 -0
  17. package/skills/claude-code/debugging-mcp-delivery/SKILL.md +4 -4
  18. package/skills/claude-code/debugging-mcp-delivery/references/inspect-hosted.md +26 -0
  19. package/skills/claude-code/debugging-mcp-delivery/references/troubleshooting.md +39 -0
  20. package/skills/claude-code/debugging-mcp-delivery/references/verify-and-recover.md +67 -0
  21. package/skills/claude-code/deploying-mcp-services/SKILL.md +3 -3
  22. package/skills/claude-code/deploying-mcp-services/references/cli-commands.md +106 -0
  23. package/skills/claude-code/deploying-mcp-services/references/deploy-and-ops.md +25 -0
  24. package/skills/claude-code/designing-mcp-products/SKILL.md +3 -3
  25. package/skills/claude-code/designing-mcp-products/references/authoring-workflow.md +451 -0
  26. package/skills/claude-code/designing-mcp-products/references/experience-design.md +155 -0
  27. package/skills/claude-code/embedding-mcp-assistants/SKILL.md +11 -10
  28. package/skills/claude-code/embedding-mcp-assistants/references/authoring-workflow.md +451 -0
  29. package/skills/claude-code/embedding-mcp-assistants/references/embedded-assistant.md +876 -0
  30. package/skills/claude-code/examples/acme-discovery/README.md +7 -5
  31. package/skills/claude-code/examples/customer-auth/README.md +200 -52
  32. package/skills/claude-code/examples/customer-auth/src/server.ts +3 -0
  33. package/skills/claude-code/examples/customer-auth/test/server.test.ts +6 -0
  34. package/skills/claude-code/examples/customer-auth/vitest.config.ts +6 -0
  35. package/skills/claude-code/examples/food-ordering/README.md +1 -1
  36. package/skills/claude-code/examples/food-ordering/src/server.ts +1 -0
  37. package/skills/claude-code/executing-noodle-plans/SKILL.md +1 -1
  38. package/skills/claude-code/publishing-mcp-integrations/SKILL.md +3 -3
  39. package/skills/claude-code/publishing-mcp-integrations/references/app-directory-compliance.md +59 -0
  40. package/skills/claude-code/publishing-mcp-integrations/references/publishing.md +63 -0
  41. package/skills/claude-code/references/authoring-workflow.md +8 -0
  42. package/skills/claude-code/references/embedded-assistant.md +259 -67
  43. package/skills/claude-code/references/sdk-surface.md +1 -1
  44. package/skills/claude-code/reporting-noodle-feedback/SKILL.md +2 -2
  45. package/skills/claude-code/reporting-noodle-feedback/references/feedback.md +80 -0
  46. package/skills/claude-code/verifying-mcp-delivery/SKILL.md +3 -3
  47. package/skills/claude-code/verifying-mcp-delivery/references/test-in-hosts.md +39 -0
  48. package/skills/claude-code/verifying-mcp-delivery/references/verify-and-recover.md +67 -0
  49. package/skills/claude-code/wrapping-existing-applications/SKILL.md +4 -4
  50. package/skills/claude-code/wrapping-existing-applications/references/authoring-workflow.md +451 -0
  51. package/skills/claude-code/wrapping-existing-applications/references/tool-design.md +107 -0
  52. package/skills/claude-code/wrapping-existing-applications/references/wrap-existing-app.md +65 -0
  53. package/skills/codex/SKILL.md +1 -1
  54. package/skills/codex/authoring-mcp-servers/SKILL.md +4 -4
  55. package/skills/codex/authoring-mcp-servers/references/authoring-workflow.md +451 -0
  56. package/skills/codex/authoring-mcp-servers/references/build-an-mcp-server.md +56 -0
  57. package/skills/codex/authoring-mcp-servers/references/sdk-surface.md +258 -0
  58. package/skills/codex/building-mcp-apps/SKILL.md +4 -4
  59. package/skills/codex/building-mcp-apps/references/build-an-mcp-app.md +53 -0
  60. package/skills/codex/building-mcp-apps/references/experience-design.md +155 -0
  61. package/skills/codex/building-mcp-apps/references/widgets-and-apps.md +277 -0
  62. package/skills/codex/connecting-apis-to-mcp/SKILL.md +3 -3
  63. package/skills/codex/connecting-apis-to-mcp/references/authoring-workflow.md +451 -0
  64. package/skills/codex/connecting-apis-to-mcp/references/connect-an-api.md +245 -0
  65. package/skills/codex/creating-product-agent-guides/SKILL.md +2 -2
  66. package/skills/codex/creating-product-agent-guides/references/product-agent-guides.md +55 -0
  67. package/skills/codex/debugging-mcp-delivery/SKILL.md +4 -4
  68. package/skills/codex/debugging-mcp-delivery/references/inspect-hosted.md +26 -0
  69. package/skills/codex/debugging-mcp-delivery/references/troubleshooting.md +39 -0
  70. package/skills/codex/debugging-mcp-delivery/references/verify-and-recover.md +67 -0
  71. package/skills/codex/deploying-mcp-services/SKILL.md +3 -3
  72. package/skills/codex/deploying-mcp-services/references/cli-commands.md +106 -0
  73. package/skills/codex/deploying-mcp-services/references/deploy-and-ops.md +25 -0
  74. package/skills/codex/designing-mcp-products/SKILL.md +3 -3
  75. package/skills/codex/designing-mcp-products/references/authoring-workflow.md +451 -0
  76. package/skills/codex/designing-mcp-products/references/experience-design.md +155 -0
  77. package/skills/codex/embedding-mcp-assistants/SKILL.md +11 -10
  78. package/skills/codex/embedding-mcp-assistants/references/authoring-workflow.md +451 -0
  79. package/skills/codex/embedding-mcp-assistants/references/embedded-assistant.md +876 -0
  80. package/skills/codex/examples/acme-discovery/README.md +7 -5
  81. package/skills/codex/examples/customer-auth/README.md +200 -52
  82. package/skills/codex/examples/customer-auth/src/server.ts +3 -0
  83. package/skills/codex/examples/customer-auth/test/server.test.ts +6 -0
  84. package/skills/codex/examples/customer-auth/vitest.config.ts +6 -0
  85. package/skills/codex/examples/food-ordering/README.md +1 -1
  86. package/skills/codex/examples/food-ordering/src/server.ts +1 -0
  87. package/skills/codex/executing-noodle-plans/SKILL.md +1 -1
  88. package/skills/codex/publishing-mcp-integrations/SKILL.md +3 -3
  89. package/skills/codex/publishing-mcp-integrations/references/app-directory-compliance.md +59 -0
  90. package/skills/codex/publishing-mcp-integrations/references/publishing.md +63 -0
  91. package/skills/codex/references/authoring-workflow.md +8 -0
  92. package/skills/codex/references/embedded-assistant.md +259 -67
  93. package/skills/codex/references/sdk-surface.md +1 -1
  94. package/skills/codex/reporting-noodle-feedback/SKILL.md +2 -2
  95. package/skills/codex/reporting-noodle-feedback/references/feedback.md +80 -0
  96. package/skills/codex/verifying-mcp-delivery/SKILL.md +3 -3
  97. package/skills/codex/verifying-mcp-delivery/references/test-in-hosts.md +39 -0
  98. package/skills/codex/verifying-mcp-delivery/references/verify-and-recover.md +67 -0
  99. package/skills/codex/wrapping-existing-applications/SKILL.md +4 -4
  100. package/skills/codex/wrapping-existing-applications/references/authoring-workflow.md +451 -0
  101. package/skills/codex/wrapping-existing-applications/references/tool-design.md +107 -0
  102. package/skills/codex/wrapping-existing-applications/references/wrap-existing-app.md +65 -0
@@ -0,0 +1,876 @@
1
+ # Embedded assistant
2
+
3
+ ## Contents
4
+
5
+ - Architecture
6
+ - Select the architecture before code
7
+ - Author and validate
8
+ - Product workflow guidance
9
+ - Customize the presentation
10
+ - Configure and deploy
11
+ - Access modes and customer auth
12
+ - Create the backend client
13
+ - Integrate the customer backend
14
+ - Ground time and ambient facts
15
+ - Verified session context (identity and claims)
16
+ - The session response
17
+ - Choose a browser renderer
18
+ - Host readiness and promotion
19
+ - Launch and qualified-usage proof
20
+ - Toolchain requirements
21
+ - Verify the boundary
22
+ - Troubleshooting: symptom to diagnosis
23
+
24
+ ## Architecture
25
+
26
+ The browser never receives a model key, assistant client secret, MCP token, or raw application session. The embedding SaaS authenticates its own user, its backend exchanges that verified identity through `@noodleseed/assistant/server`, and the browser receives only a short-lived assistant session.
27
+
28
+ Keep every credential and identity layer separate:
29
+
30
+ | Owner | Values | Destination |
31
+ | --- | --- | --- |
32
+ | Noodle operator | Login, selected org/app/env | Plugin-managed CLI profile and explicit target; never the SaaS runtime |
33
+ | Noodle-managed model | No authored provider, model id, URL, or key | Hosted operator state; available only when Noodle has enrolled the exact org/app/env |
34
+ | Operator-provided model | `ASSISTANT_MODEL_BASE_URL`, `ASSISTANT_MODEL`, `ASSISTANT_MODEL_API_KEY` | `noodle variables set` / `noodle secrets set`; never the SaaS environment |
35
+ | Connector/delegated exchange | Connector credentials and any customer-owned token-exchange client | Noodle managed configuration plus the matching customer backend secret manager |
36
+ | SaaS backend | `NOODLE_SERVICE_URL`, `NOODLE_ASSISTANT_CLIENT_ID`, `NOODLE_ASSISTANT_CLIENT_SECRET`, `PUBLIC_APP_ORIGIN` | Backend-only environment or secret manager; never browser code or public-prefixed variables |
37
+ | Browser | Short-lived assistant session only | In memory; never a client secret, model key, connector credential, or raw application session |
38
+
39
+ ## Select the architecture before code
40
+
41
+ Do not edit either codebase until one architecture brief is decision-complete. Name the external end user, the conversational job, one to three workflows, the exact access mode, renderer, existing host framework, identity/session boundary, server-owned routing authority, model owner, exact origin and mount point, and requested evidence level.
42
+
43
+ If the external user, job, or workflow set is still vague, hand off to `designing-mcp-products` before code. Preserve its bounded product outcome and resume this workflow only when the value path and one representative success path are explicit.
44
+
45
+ Treat the MCP application and embedding application as two code ownership roots, usually two repositories and sometimes two explicitly separate directories in one repository:
46
+
47
+ | Code owner | Owns | Must not own |
48
+ | --- | --- | --- |
49
+ | MCP application | `server.ts`, tools, App views, access surfaces, portable branding defaults, model declaration, validation, and deployment | Host cookies, login UI, application middleware, or browser mounting |
50
+ | Embedding application | Existing framework and package manager, user authentication, same-origin session route when required, browser mount, CSP, cookies/CSRF, and production build | MCP tool semantics, model keys, assistant client secrets in browser code, or caller-selected tenant routing |
51
+
52
+ Develop locally as two local processes: `noodle dev` serves the MCP application, while the embedding application runs its existing development command at the exact authored loopback origin. Do not replace either process with a second scaffold, package manager, chat transport, or copied flagship shell.
53
+
54
+ Select exactly one recommended topology; do not return an unranked menu:
55
+
56
+ | User boundary | Recommended topology | Identity and routing consequence |
57
+ | --- | --- | --- |
58
+ | Anonymous public visitor | `publicWebsite(...)` plus the hosted embed ID and managed renderer | No backend session route, `${user}`, roles/scopes, delegated credentials, or customer routing. Require the explicit capability allowlist and confirmation for side effects. |
59
+ | Signed-in application user | `authenticatedWebsite(...)` plus a same-origin backend session route and the managed renderer | The host backend authenticates the user, calls `createAssistantSession`, and supplies only verified identity, claims, preferences, and server-owned routing. |
60
+ | Visitor who may sign in mid-conversation | `publicWebsite({ signIn: true })` plus the hosted embed ID, host application login, and ticket elevation | Anonymous capabilities remain bounded; the host backend spends the ticket after login and continues through a same-origin session endpoint on an allowed origin. |
61
+
62
+ Use the existing host framework and its package manager. Recommend the managed renderer unless the product names a concrete UI or integration benefit that requires the headless client; a custom renderer accepts the complete transcript-part obligation described below. Recommend `noodleManaged()` when Noodle owns the managed-cloud model for the exact eligible target; recommend `openAICompatible(...)` only when the operator or self-hosted deployment owns that model endpoint and credentials. Never present both as unfinished choices.
63
+
64
+ The architecture brief must state the selected topology, why it fits the named user and job, the two code owners and two local processes, credential and identity flow, routing authority, model owner, files expected to change, and the first requested evidence boundary. If identity/session ownership, exact origin, tenant-routing authority, model ownership, or evidence target is unavailable, return a blocked architecture brief naming the missing owner or decision and do not edit either codebase.
65
+
66
+ ## Author and validate
67
+
68
+ `noodle init` and `noodle init --template widget` deliberately produce credential-free MCP Apps. Add an assistant declaration only when the product explicitly includes a customer-hosted assistant; do not make ordinary external-host widgets depend on model-provider settings.
69
+
70
+ Use the same server tools in the embed; do not create a second tool set. Declare one server-level brand kit and an assistant configuration. `access` decides who may open a session, so choose it before anything else — `authenticatedWebsite(...)` for an in-app embed, `publicWebsite(...)` for a marketing page, or both:
71
+
72
+ ```ts
73
+ branding: { name: "Acme", accent: "#3157D5" },
74
+ context: { defaults: { locale: "en-GB", timeZone: "Europe/London" } },
75
+ assistant: embeddedAssistant({
76
+ model: noodleManaged(),
77
+ access: authenticatedWebsite({
78
+ origins: ["http://localhost:3000", "https://app.example.com"],
79
+ }),
80
+ layout: { mode: "floating", position: "bottom-center" },
81
+ }),
82
+ ```
83
+
84
+ `noodleManaged()` is the zero-configuration Cloud path: the public artifact contains only `{ kind: "noodle-managed" }`. It never exposes a provider or model identifier, and it fails closed unless Noodle has enrolled that exact deployment target. For a customer- or self-hosted model, replace it with `openAICompatible({ baseUrl: variable("ASSISTANT_MODEL_BASE_URL"), model: variable("ASSISTANT_MODEL"), apiKey: secret("ASSISTANT_MODEL_API_KEY"), transport: "responses" })` when the endpoint implements Responses. Omit `transport` or use `"chat-completions"` for Chat Completions. Noodle calls only the selected transport and never probes or falls back to the other endpoint.
85
+
86
+ Origins are exact: scheme, host, and optional port, with no path, trailing slash, or wildcard. Production origins must be HTTPS; plain HTTP is accepted only for loopback development origins (`http://localhost:<port>`, `http://127.0.0.1:<port>`). `noodle dev` serves the MCP project, not the embedding SaaS. For a public surface it also prints a process-local Embed ID and script; mount that script on the separately running loopback website to test anonymous mint, chat, widgets, and confirmation. The local ID is ephemeral, while a hosted deploy provisions the stable ID behind durable admission counters.
87
+
88
+ ## Product workflow guidance
89
+
90
+ Decide whether the product needs `agentGuide` even when the builder does not name it. Multiple permission-gated capabilities, ordered multi-tool work, product-specific grounding, and consequential boundaries are strong signals. Keep `server.instructions` concise and global; put workflow triggers, ordering, and permission-specific prose in the guide. Global guide description, use cases, and boundaries must be safe for every exposed assistant surface.
91
+
92
+ After deployment, the embedded assistant automatically projects the typed guide into compact server-side model context on every turn. It keeps only complete workflows supported by that session surface and by the exact model-visible tools allowed for the backend-verified roles and scopes. A member and an administrator can therefore receive different workflow guidance from the same deployment. Mixed anonymous sessions retain only the explicitly selected surface and sign-in workflows; the next turn is reprojected after successful elevation.
93
+
94
+ No renderer prop, browser package field, or second skill installation is required. The managed Web Component, React component, headless hook, and public client all share the same server-side turn path. Raw guide content and generated skill files never enter session responses or browser events. Missing, incompatible, empty, or oversize guidance is omitted without changing the tool surface or breaking an otherwise valid turn.
95
+
96
+ ### Surfaces: one assistant, every front door
97
+
98
+ A product usually has more than one front door — a marketing site and a signed-in app. One assistant (one brand, one model, one UI) projects onto both; pass `access` an array and each surface owns its own origins and allowlist:
99
+
100
+ ```ts
101
+ access: [
102
+ publicWebsite({
103
+ origins: ["https://www.example.com"],
104
+ capabilities: [answerProductQuestion, requestDemo],
105
+ instructions: "Help visitors understand the best workflow for their goal before inviting a next step.",
106
+ }),
107
+ authenticatedWebsite({
108
+ origins: ["https://app.example.com"],
109
+ sessionClaims: { plan: { exposeToModel: true } },
110
+ }),
111
+ ],
112
+ ```
113
+
114
+ At most one public surface (`public` or `mixed`) and at most one authenticated surface, and no origin may appear on two surfaces — otherwise "which projection is this request?" would be ambiguous. Each gets its own embed snippet, budget, and kill switch.
115
+
116
+ Keep shared, host-neutral product truth in `server.instructions`. Use a surface `instructions` value only for the voice, goals, boundaries, and next-step invitations appropriate to that front door. It is trimmed, non-empty, and at most 4,000 characters. The service injects it only after binding the exact website surface; it never enters MCP `initialize` or another assistant surface. For a public sales assistant, be consultative rather than pushy: deliver useful diagnosis or guidance before asking for contact details, and never put secrets in instructions.
117
+
118
+ `publicWebsite` is for a page with no signed-in user. The visitor is an **anonymous principal**, not an empty user: there is no `${user}`, no roles, no scopes, no customer routing, and no delegated credentials. A tool that needs identity — because it reads `${user}` or declares an `authorization` requirement — cannot be projected to a `public` surface, and the compiler says so.
119
+
120
+ A public surface **must** declare `capabilities`: the exact positive allowlist it may reach. It is required by the type, and it is the whole externally reachable surface — a reviewer should read it in one screenful. Anything absent stays private, and a capability added to the server later is excluded until someone lists it. `authenticatedWebsite` may also take `capabilities` to narrow the in-app surface; omitted, it projects the whole server.
121
+
122
+ A public `embedId` Web Component automatically attaches bounded anonymous same-origin Markdown page context, never DOM text. It is untrusted and nonpersistent, falls back to the URL alone on failure, yields to explicit `pageContext`, and never runs for authenticated or headless clients.
123
+
124
+ ### Mixed surfaces: let a visitor sign in mid-conversation
125
+
126
+ Add `signIn: true` to a public surface when some capabilities need a signed-in visitor. The surface becomes `mixed`: anonymous visitors start immediately, an identity-dependent capability stays **visible** so the assistant can offer it, and reaching for it raises a sign-in prompt instead of executing.
127
+
128
+ ```ts
129
+ access: publicWebsite({
130
+ origins: ["https://www.example.com"],
131
+ capabilities: [answerProductQuestion, requestDemo, myOrders],
132
+ signIn: true, // `myOrders` reads ${user}; visitors sign in to reach it
133
+ }),
134
+ labels: {
135
+ signInHeading: "Continue with your Acme account",
136
+ signInBody: "Order history needs an account.", // optional; empty hides the line
137
+ signInAction: "Sign in",
138
+ signUpAction: "Create free account", // authoring this label is the sign-up opt-in
139
+ },
140
+ ```
141
+
142
+ The sign-in card renders on the same themed chrome as every proposal card and follows the server `branding`. Authoring `signUpAction` adds a second button; leaving it out renders none. Both buttons raise the same `assistant-sign-in-requested` event with the same single-use ticket — the detail adds `intent: "sign-in" | "sign-up"` so the page routes `sign-up` to its registration page instead of its login. The ticket spend after account creation is identical to the one after sign-in; the service does not care which path produced the session. Headless renderers receive the same moment as a `data-sign-in` transcript part from `subscribeChat` (it has no status and is not respondable through `client.respond` — resolution is the elevated session).
143
+
144
+ #### Complete mixed-mode handoff
145
+
146
+ 1. **Capture the ticket.** The widget raises `assistant-sign-in-requested` with a single-use `signInTicket`. POST it to the host application same-origin backend and bind it to the short-lived login transaction; keep it out of query strings, logs, analytics, and durable browser storage.
147
+ 2. **Use the existing login.** Send the visitor through the host application full-page redirect for sign-in or sign-up. Noodle never becomes the customer identity provider.
148
+ 3. **Elevate on the backend.** After authentication, recover the bound ticket and call `createAssistantSession({ ..., signInTicket })` with the verified user, server-owned routing, backend client credentials, and the exact **origin the conversation will continue on**. The allowed destination may differ from the anonymous marketing origin; successful elevation rebinds the session to it.
149
+ 4. **Reattach on the destination origin.** Forward the helper response unchanged through the customer-owned same-origin session endpoint. The widget uses `credentials: "same-origin"`; pointing it at a cross-origin endpoint guarantees a cookie-less `401`. The elevated token replaces the anonymous token, which is dead.
150
+ 5. **Resume and replay.** The widget repaints the bounded visible transcript from `endpoints.transcript`, emits one secure-continuation status, re-resolves the latest linked App view against the current surface, restores at most one live pending confirmation/input request, and then shows follow-up suggestions. Restoring an interaction suppresses auto-resume. Replay includes only rows the panel showed, never tool internals or a spent ticket, and never App HTML, a DOM snapshot, or exact scroll/focus. On an older service without that endpoint the panel starts visually fresh while the model still remembers, so promise only that "the assistant remembers". Pass `restoreConversation: false` when a privacy-sensitive application must suppress browser replay while retaining bounded server/model continuity.
151
+
152
+ Possession of the ticket alone elevates nothing, and the service checks that the backend client tenant owns the conversation. A refused spend throws a typed `AssistantSessionExchangeError`: branch on `elevationRefusal` (`elevation_ticket_expired` re-prompt; `elevation_tenant_mismatch` alert and never retry; `elevation_state_conflict` means the ticket is spent, so open or restart the signed-in flow and never merge generic state). A refused origin does not burn the ticket. The ticket is not the server-held interaction continuation — that value never reaches browser code.
153
+
154
+ By default the pending request completes itself: the service re-attempts the intercepted tool under the new principal and streams it as the elevated session first turn. This is one-shot, is mooted if the visitor types first, and stops at the confirmation card for a confirm-gated tool. Pass `resume: false` beside the ticket only when the application deliberately owns that affordance. Do not build client-side resume scaffolding or a second identity provider.
155
+
156
+ A connector-backed side effect needs **two** independent declarations to be reachable from a public or mixed surface: inclusion in `capabilities` **and** `{ confirm: true }` on the operation. Signing in proves who the visitor is; it does not pre-authorize an effect, so confirmation still applies on a mixed surface. Confirmation is never authentication or business authorization — the customer backend still owns payload validation, abuse controls, and idempotency. Local or session-only widget state needs no confirmation.
157
+
158
+ Origin is a browser boundary, never bot authentication — scripts can reproduce an allowed `Origin` header. Do not tell a user that origins protect a public embed; the real controls are the capability allowlist, confirmation, admission limits, and the per-surface daily budget.
159
+
160
+ Run:
161
+
162
+ ```sh
163
+ noodle validate --json
164
+ noodle check --target embedded-assistant --json
165
+ ```
166
+
167
+ Use `noodle commands --json` before proposing command flags; do not invent flags from memory.
168
+
169
+ ## Customize the presentation
170
+
171
+ Keep portable identity and semantic light/dark colors in the one server-level `branding` block. Put assistant-only structure in the bounded `presentation` object; it accepts curated primitives rather than raw HTML, CSS, SVG, class names, or callbacks:
172
+
173
+ ```ts
174
+ assistant: embeddedAssistant({
175
+ model,
176
+ access: authenticatedWebsite({ origins: ["https://app.example.com"] }),
177
+ theme: "invert",
178
+ layout: { position: "bottom-right", panelWidth: 520, panelMinHeight: 540, panelMaxHeight: 740, edgeOffset: 24 },
179
+ behavior: { showTimestamps: true, showPoweredBy: false, showConfirmationDetails: false },
180
+ labels: { launcherPlaceholder: "Ask Acme anything", composerPlaceholder: "Message Acme Support…", sessionReady: "Acme support is online" },
181
+ presentation: {
182
+ panel: { surface: "solid", elevation: "dramatic", border: "strong", radius: 20 },
183
+ launcher: { style: "bubble", icon: "chat", size: "lg", status: "session", effect: "pulse" },
184
+ header: {
185
+ mark: "status",
186
+ badge: { text: "Online", tone: "success", indicator: true },
187
+ },
188
+ composer: { leadingIcon: "brand-mark", sendIcon: "paper-plane", shape: "rounded" },
189
+ messages: { userStyle: "accent", assistantStyle: "bubble" },
190
+ },
191
+ }),
192
+ ```
193
+
194
+ The Atlas-style product treatment above is the maximum deployment-configurable presentation. The bounded surface covers panel treatment, pill/bubble launcher style plus icon/size/session pulse, header mark/status badge, composer controls, and message treatment; it does not accept custom header actions, structured empty-state layouts, footers, tenant-defined launcher variants/effects, or tenant code.
195
+
196
+ Omitted UI fields retain the complete managed baseline: a bottom-center frosted prompt pill, 970px outer desktop shell with 20px side padding, 85vh/1025px height bounds, 24px panel with built-in `#F8F8F8` light and `#0C0A09` dark surfaces, bottom prompt chips and pill composer, plain assistant messages, 85%-wide user bubbles, Noodle Seed attribution, and mobile fullscreen. The pill morphs into an input before opening; `launcher.style: "bubble"` opens directly, while `panel.surface: "glass"` remains an explicit translucent alternative. `theme: "auto"` follows the host page and `"invert"` selects its opposite. `suggestedPrompts` is the exact initial set only: pass `[]` for no initial chips, or omit it so the active model generates context-aware initial prompts. After the first message, follow-up prompts are always regenerated from the complete authorized conversation context and are never copied into transcript history. The only attribution is the Noodle Seed row, removed by `behavior.showPoweredBy: false`; the baseline carries no third-party promotion. For exact application-owned color roles, pass the typed React `appearance={{ light: { panel: { surface, text, border }, composer: {...}, confirmation: {...}, primaryButton: {...} }, dark: {...} }}` prop or assign the same object to `element.appearance`. CSS custom properties inherit through the assistant host, so those values may reuse existing application tokens such as `surface: "var(--app-surface)"` without copying literals. The appearance surface covers canvas, panel, header, messages, composer, suggestions, confirmation, buttons, launcher, code, and the MCP App frame; the package README publishes the complete role-to-`--ns-assistant-*` map. Exact parseable literal colors are preserved and low contrast emits `assistant-appearance-warning`; contrast for unresolved CSS references remains host-owned. Precedence is host appearance object, host slots/public variables, saved environment operator override, deployed semantic presentation, then defaults. Prefer reusable `server.ts` defaults; use the Console Assistant tab or `noodle assistant appearance show|apply|reset` for environment-owned changes that should reach existing embeds without a redeploy.
197
+
198
+ 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.
199
+
200
+ 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.
201
+
202
+ ## Configure and deploy
203
+
204
+ Local MCP authoring and tests need no account, but an external browser embed needs an active assistant-enabled deployment before a backend client can be created. Start with the canonical deploy:
205
+
206
+ ```sh
207
+ noodle deploy --org <org> --app <app> --env <env>
208
+ ```
209
+
210
+ Deploy preflights the complete target before upload. `noodleManaged()` has no customer model variables or secrets; every billing-attributed managed-cloud deployment is eligible for the bounded sponsored beta without organization enrollment. Missing billing attribution or hosted protection fails closed before provider egress. `openAICompatible()` preflight collects or reports every missing model variable and secret with safe `noodle variables set ... --from-env` / `noodle secrets set ... --from-env` actions. Values never appear in the preflight report or resume state. Do not put BYO model values in the embedding SaaS environment. A production deployment may omit a local origin; include a loopback origin only when local browser integration is required.
211
+
212
+ ## Access modes and customer auth
213
+
214
+ Session exchange authenticates with the backend client credentials, so the embed works under any `--access` mode. The assistant does not select direct MCP access or protected-resource discovery. If protected-resource metadata advertises an unexpected issuer, inspect the exact active deployment before changing auth by following `references/troubleshooting.md`.
215
+
216
+ Add `--access customers` only when verified end customers should also call the MCP endpoint directly. That mode requires `server.auth`; `noodle deploy` preflights the rule locally and fails with `server_auth_required` before contacting the service. Fix by adding auth to server options:
217
+
218
+ ```ts
219
+ auth: customerAuth.federatedOidc({
220
+ issuers: [{ issuer: "https://id.example.com", audience: "https://api.example.com" }],
221
+ }),
222
+ // or a built-in adapter: customerAuth.firebase({ projectId, apiKey })
223
+ ```
224
+
225
+ ## Create the backend client
226
+
227
+ After the deployment is active:
228
+
229
+ ```sh
230
+ noodle assistant clients create --name web --org <org> --app <app> --env <env>
231
+ ```
232
+
233
+ The CLI writes `{ clientId, clientSecret }` to a mode-`0600` file and prints only its path. Move the values into the SaaS backend secret manager without printing or committing them. Rotation invalidates the previous secret.
234
+
235
+ Validate the active deployment, backend credential, exact origin, and delegated credential exchanges that do not require an application-specific customer route:
236
+
237
+ ```sh
238
+ noodle assistant doctor --origin "$PUBLIC_APP_ORIGIN" --org <org> --app <app> --env <env>
239
+ ```
240
+
241
+ The doctor reads `NOODLE_ASSISTANT_CLIENT_ID` / `NOODLE_ASSISTANT_CLIENT_SECRET` or the saved mode-0600 client file and never prints the secret. It makes one bounded synthetic request through the active deployment's exact model transport without business tools or customer conversation data; failures expose only a redacted category, status, and retryability. Pass `--user-id <real-test-user>` only when the downstream exchange requires an existing application user. On a deployment with a mixed surface it also runs a synthetic sign-in round trip (`elevation` check): issue, claim, and elevate against a throwaway anonymous session on the same code path a real sign-in takes, proving the store is configured and that elevation rebinds the issuer basis to the backend client — so a green doctor now certifies the sign-in leg too, not just the authenticated exchange. The assistant doctor does not supply application-specific routes; after the backend mints a routed session, invoke one representative safe read to verify its route-bound exchange and connector together.
242
+
243
+ ## Integrate the customer backend
244
+
245
+ Read the customer repository lockfile or `packageManager` field and install `@noodleseed/assistant` with that existing package manager; never introduce a second lockfile.
246
+
247
+ Create an authenticated same-origin backend route:
248
+
249
+ The route must return a JSON `401` for a signed-out caller rather than an HTML login redirect, require JSON, and compare the request against the exact configured Origin before exchange. The managed renderer sends same-origin cookies but does not invent an application CSRF token. Treat JSON plus exact Origin as the minimum cookie/CSRF boundary; when the host keeps token-based CSRF middleware, use the DOM-free client with an injected `fetch` that supplies the token instead of exempting the route. Resolve claims and connector routes from the authenticated user and server-owned membership only.
250
+
251
+ For the complete framework-neutral server-to-server HTTP exchange in a non-Node host, link to the [Django and Vue guide](https://docs.noodleseed.dev/docs/guides/embedded-assistant-django-vue); keep its framework-specific middleware and mounting mechanics there rather than copying them into agent instructions.
252
+
253
+ ```ts
254
+ import { createAssistantSession } from "@noodleseed/assistant/server";
255
+
256
+ export async function POST(request: Request) {
257
+ const user = await requireCurrentUser(request);
258
+ const { context } = await request.json();
259
+ const session = await createAssistantSession({
260
+ serviceUrl: process.env.NOODLE_SERVICE_URL!,
261
+ clientId: process.env.NOODLE_ASSISTANT_CLIENT_ID!,
262
+ clientSecret: process.env.NOODLE_ASSISTANT_CLIENT_SECRET!,
263
+ origin: process.env.PUBLIC_APP_ORIGIN!,
264
+ user: {
265
+ id: user.id,
266
+ email: user.email,
267
+ roles: user.roles,
268
+ scopes: user.scopes,
269
+ },
270
+ context,
271
+ // Saved, backend-verified user preferences outrank browser hints.
272
+ preferences: { locale: user.locale, timeZone: user.timeZone },
273
+ });
274
+ return Response.json(session);
275
+ }
276
+ ```
277
+
278
+ Authenticate before exchange. Pass backend-verified `user.roles` and OAuth-style `user.scopes` separately; they govern the same per-tool authorization rules as verified MCP bearer claims. Source `origin` from trusted server configuration or strictly match the request origin against the same exact allowlist; never accept an arbitrary request header. Treat page context as untrusted model context, never authorization. Forward the helper response unchanged.
279
+
280
+ `serviceUrl` is the Noodle Seed control-plane base URL: the value `noodle assistant clients create` prints, also stored as `serviceUrl` in `deployment.json`. It is NOT the deployment MCP endpoint (`url`, which ends in `/v1/mcp` and rejects session exchange). Never probe or guess endpoints with real credentials.
281
+
282
+ ### Route customer endpoints from the backend
283
+
284
+ When a connector uses `customerEndpoint("customer_api", ...)`, resolve the signed-in user's API base URL from authenticated, server-owned tenancy data and bind it during session exchange:
285
+
286
+ ```ts
287
+ const user = await requireCurrentUser(request);
288
+ const account = await requireAccountMembership(user.id);
289
+
290
+ const session = await createAssistantSession({
291
+ serviceUrl,
292
+ clientId,
293
+ clientSecret,
294
+ origin,
295
+ user: { id: user.id, email: user.email },
296
+ routing: {
297
+ endpoints: {
298
+ customer_api: account.clusterApiBaseUrl,
299
+ },
300
+ },
301
+ });
302
+ ```
303
+
304
+ The browser does not send `routing`. Authenticate the user and validate account/cluster membership before selecting the URL. Never read it from page context, request headers, session claims, tool arguments, or model output. The endpoint key must match the authored `customerEndpoint` name. Noodle validates the canonical HTTPS URL against the active artifact policy, stores it only in the private short-lived session, and omits it from the session response and caller identity.
305
+
306
+ Routing is optional: static tools continue to work, while a tool whose endpoint was omitted fails closed with `connector_route_unavailable` before credential or connector egress. A confirmed routed action binds a URL-blind fingerprint at proposal time and rejects a missing or changed route on acceptance.
307
+
308
+ ## Ground time and ambient facts
309
+
310
+ Every assistant turn receives a server-authoritative instant and user-local date/time. Locale and IANA time zone resolve in this order: backend-verified `preferences` from session exchange, fresh per-turn browser `clientContext` hints, `server.context.defaults`, then platform defaults (`en-US`/`UTC`). Browser hints affect presentation and relative-date interpretation only; they are untrusted and never authorize a tool.
311
+
312
+ Use the server-level context declaration for application facts that every surface should share:
313
+
314
+ ```ts
315
+ context: {
316
+ defaults: { locale: 'en-GB', timeZone: 'Europe/London' },
317
+ ambient: {
318
+ output: z.object({ defaultTeamId: z.string(), holidays: z.array(z.string()) }),
319
+ fulfil: ({ user, context, connectors }) => {
320
+ const calendar = connectors.people.getCalendar({
321
+ subject: user.subject,
322
+ asOf: context.temporal.instant,
323
+ });
324
+ return { defaultTeamId: calendar.default_team_id, holidays: calendar.holidays };
325
+ },
326
+ },
327
+ },
328
+ ```
329
+
330
+ The callback records declarative fulfilment at author time; the shared runtime executes only read-only connector operations, validates the declared output, and freezes one snapshot for the whole invocation and any accepted interaction. Tools/resources/prompts read `context.temporal`, `context.ambient`, and `context.ambientStatus`. The embedded assistant receives the same snapshot in trusted platform context. For model-visible application context in every host, designate one normal zero-input tool with `contextProvider: true`; the embedded host preloads it per turn and external hosts call it normally. Keep ambient facts compact: the platform caps serialized JSON at 16 KiB, depth 8, and 128 entries per container, and rejects credential-shaped keys.
331
+
332
+ ## Structured missing input
333
+
334
+ A tool authored with `ctx.elicit({ id, message, input })` produces `input_requested` when it reaches missing input. Built-in and headless renderers present it and call `respond(id, { action: "accept", content })`; decline/cancel stop. Accepted content is schema-validated and completed steps are not rerun; invalid content returns `arg_invalid` and keeps the interaction pending. Elicitation gathers an input and does not approve a later write. Every interactive flow collects elicited input before its first connector operation; every eligible `input_requested` precedes `tool_proposed`. In a `confirm: true` flow, the final proposal reviews original input, elicited values, and the sole exact eligible connector action. Conditional branches may declare candidate actions and later reads only when preparation resolves exactly one action, discloses later eligible operations, and fails with `invalid_confirmation_flow` for zero or multiple actions. Accept is bound to that action. Bidirectional MCP maps missing input to standard `elicitation/create`. On a stateless host, a linked MCP App presents the same normal-user form and re-calls the tool through standard `tools/call`, carrying replay answers in request `_meta` so approval copy contains only business fields; without Apps, the model receives the exact structured schema and an advertised reserved retry field. Both paths replay only the operation-free input prefix and never expose runtime continuation or environment state. Setting `interactions: { confirmationFallback: "host" }` explicitly trusts native host approval only after every elicited field is collected and only when confirmation transport is unavailable; it still uses the same prepared-action safety path. Embedded/headless confirmation remains Noodle-owned. Omitted or false annotations execute directly; hints never gate.
335
+
336
+ ## Verified session context (identity and claims)
337
+
338
+ The embedding developer defines what authenticated session context the assistant receives. One mechanism, three hops:
339
+
340
+ 1. The authenticated backend passes standard identity and any verified claims at session exchange (flat scalars only):
341
+
342
+ ```ts
343
+ const session = await createAssistantSession({
344
+ serviceUrl, clientId, clientSecret, origin,
345
+ user: {
346
+ id: user.id,
347
+ email: user.email,
348
+ name: user.name,
349
+ roles: user.roles,
350
+ scopes: user.scopes,
351
+ },
352
+ claims: { displayName: user.name, accountTier: account.tier, region: account.region },
353
+ });
354
+ ```
355
+
356
+ 2. The server author declares the allowlist in `server.ts` — undeclared claims are dropped at session exchange (never rejected, so backend and server deploys may skew safely):
357
+
358
+ ```ts
359
+ assistant: embeddedAssistant({
360
+ model,
361
+ access: authenticatedWebsite({
362
+ origins: ["https://app.example.com"],
363
+ sessionClaims: {
364
+ displayName: { exposeToModel: true },
365
+ accountTier: { exposeToModel: true },
366
+ region: {}, // tools only, never in the prompt
367
+ },
368
+ }),
369
+ }),
370
+ ```
371
+
372
+ 3. Consumption. Tools read the verified identity and declared claims through the `user` scope:
373
+
374
+ ```ts
375
+ tool("greet", {
376
+ description: "Greet the signed-in user.",
377
+ input: z.object({}),
378
+ annotations: annotations.readOnly(),
379
+ fulfil: ({ user }) => ({ message: `Hello, ${user.name}!`, tier: user.claims.accountTier }),
380
+ });
381
+ ```
382
+
383
+ Manifest expressions use `${user.name}`, `${user.email}`, `${user.subject}`, `${user.locale}`, `${user.timeZone}`, and `${user.claims.<key>}`. The model receives one platform identity line automatically: standard identity (name/email) whenever present, plus only the claims marked `exposeToModel: true` — so the assistant greets the actual user and can pass identity into tool arguments. `noodle check --target embedded-assistant` lists the declared claim contract.
384
+
385
+ Page `context` from the widget remains untrusted hint data; verified identity/authorization facts belong in `claims`, saved locale/time-zone choices belong in backend `preferences`, and live business facts belong in `server.context.ambient`. Validated preferences also reach fulfilments as `user.locale` and `user.timeZone`, so connectors format in the same verified zone the invocation snapshot uses.
386
+
387
+ To make the *downstream API call itself* run as the signed-in user (your API enforces its own per-user authorization instead of trusting a forwarded id), give the connector `auth.kind: "delegatedTokenExchange"` — the platform signs a verifiable assertion of this session identity and exchanges it at a token endpoint you implement. Assistant sessions carry the identity this needs; the full contract and a copyable endpoint implementation are in `references/authoring-workflow.md` ("Delegated downstream auth").
388
+
389
+ ## The session response
390
+
391
+ The exchange returns the versioned Embedded Assistant v1 contract. `token`, `expiresAt`, and `endpoints.turns` / legacy `endpoints.toolConfirmations` (absolute URLs) are always present; current services add `endpoints.interactions` for accept/decline/cancel. `configuration` is optional theming data. Forward the body unchanged; browser clients choose the advertised endpoint. Do not rebuild, filter, or rewrite the response.
392
+
393
+ ## Mount a public website surface
394
+
395
+ A `publicWebsite` surface has no backend exchange, because it has no embed secret to protect. `noodle deploy` provisions a non-secret embed id and prints the snippet; the page presents that id directly and receives an anonymous session. Do not build a session route for a public surface — there is nothing for it to hold.
396
+
397
+ ```html
398
+ <script src="https://cloud.noodleseed.dev/v1/assistant/embed.js"
399
+ data-embed-id="pub_7f2q4k9x" async></script>
400
+ ```
401
+
402
+ The script derives its service origin from its own `src`, so one snippet works unchanged in every environment. In a React application, mount `<NoodleAssistant embedId="pub_7f2q4k9x" />` instead. `embedId` and `sessionEndpoint` are mutually exclusive: an embed id beside a backend endpoint is a mistake, and the client refuses rather than guessing which transport was meant.
403
+
404
+ The embed id is safe in page source and stable across deploys — paste it once; redeploy and rollback swap the projection under a page that never changes. Never treat it as a credential, and never put a client secret on a public page.
405
+
406
+ Tell the operator the two commands that matter: `noodle assistant embeds list` shows each surface with its live origins, capabilities, and today's spend against its cap; `noodle assistant budget set --turns-per-day 0` is the kill switch and stops conversations already under way. Raising the cap serves visitors again. Prefer it to revoking an embed, which destroys the pasted id.
407
+
408
+ A public surface is capped per day, so a visitor can meet an exhausted budget. The widget renders that calmly and offers no retry; do not add one. If the embedding page sets a Content-Security-Policy, it must allow the Noodle service origin in `script-src` (the embed script), `connect-src` (session and turns), and `frame-src` (widget sandbox) — a blocked `script-src` runs no widget code at all, so nothing can report it from the page.
409
+
410
+ Run `noodle check --target embedded-assistant` before deploying a public surface: it lists exactly what a stranger can reach and warns when no `privacyUrl` is declared.
411
+
412
+ ## Choose a browser renderer
413
+
414
+ Use the React wrapper in React applications:
415
+
416
+ ```tsx
417
+ import { NoodleAssistant } from "@noodleseed/assistant/react";
418
+
419
+ <NoodleAssistant sessionEndpoint="/api/assistant/session" theme={resolvedTheme} />;
420
+ ```
421
+
422
+ Or import the package root once and mount `<noodle-assistant session-endpoint="/api/assistant/session" theme="auto"></noodle-assistant>`. Mount only inside the authenticated application surface.
423
+
424
+ That custom element is the complete managed assistant in Vue, Angular, or plain DOM; it has no React runtime requirement. Configure the framework to accept `noodle-assistant` as a custom element. If the session exchange needs an authenticated fetch wrapper, create the element imperatively, assign `element.fetch` and then `element.sessionEndpoint`, and append it only after both properties are set.
425
+
426
+ `theme="auto"` follows an explicit host-page light/dark class or data attribute, then the browser operating-system preference; `theme="invert"` selects the opposite. If the SaaS application owns a theme toggle, obtain its resolved application theme (`"light"` or `"dark"`), pass `theme={resolvedTheme}` to `NoodleAssistant`, and update the custom element's `theme` attribute when that value changes.
427
+
428
+ The component renders a custom element and must mount client-side. In a Next.js App Router tree, put the mount in a `"use client"` component; from a server component or the Pages Router, load it with `next/dynamic` and `ssr: false`.
429
+
430
+ ### Minimal fail-closed custom renderer skeleton
431
+
432
+ Use the renderer-free hook only when the product has a concrete reason to own the conversation UI. This is a minimal fail-closed skeleton, not a production-complete renderer: it keeps one client and App host, renders safe transcript content, and refuses interactions whose complete business presentation the host has not implemented. Start from the managed renderer unless the application accepts the full obligation checklist below.
433
+
434
+ ```tsx
435
+ "use client";
436
+
437
+ import { useEffect, useState } from "react";
438
+ import { NoodleAppView } from "@noodleseed/assistant/react";
439
+ import { useNoodleAssistant } from "@noodleseed/assistant/react/client";
440
+
441
+ type SignInRequest = { readonly signInTicket: string; readonly expiresAt: string };
442
+
443
+ export function CustomAssistant({
444
+ principalKey,
445
+ resolvedTheme,
446
+ onSignInRequested,
447
+ }: {
448
+ principalKey: string;
449
+ resolvedTheme: "light" | "dark";
450
+ onSignInRequested: (request: SignInRequest) => Promise<"started" | "cancelled">;
451
+ }) {
452
+ const [draft, setDraft] = useState("");
453
+ const [sessionNotice, setSessionNotice] = useState("");
454
+ const [turnNotice, setTurnNotice] = useState("");
455
+ const [pendingSignInTicket, setPendingSignInTicket] = useState<string>();
456
+ const { client, messages, suggestions, status, error } = useNoodleAssistant({
457
+ sessionEndpoint: "/api/assistant/session",
458
+ principalKey,
459
+ clientContext: () => ({
460
+ locale: navigator.language,
461
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
462
+ }),
463
+ });
464
+ const busy = status === "submitted" || status === "streaming";
465
+ const settle = (operation: Promise<void>) => {
466
+ void operation.catch(() => {
467
+ // The hook exposes this same structured failure through `error`.
468
+ });
469
+ };
470
+ useEffect(
471
+ () =>
472
+ client.subscribe((event) => {
473
+ if (event.event === "session_expired") setSessionNotice("Session expired.");
474
+ if (event.event === "session_started" || event.event === "session_reset") {
475
+ setSessionNotice("");
476
+ }
477
+ }),
478
+ [client],
479
+ );
480
+
481
+ return (
482
+ <section aria-label="Assistant" aria-busy={busy}>
483
+ {messages.map((message) => (
484
+ <article key={message.id} data-role={message.role}>
485
+ {message.parts.map((part, index) => {
486
+ if (part.type === "text") return <p key={index}>{part.text}</p>;
487
+ if (part.type === "data-confirmation") {
488
+ const review = part.data;
489
+ return (
490
+ <section key={review.id} aria-label="Review proposed action">
491
+ <h3>{review.title ?? "Review proposed action"}</h3>
492
+ {review.description ? <p>{review.description}</p> : null}
493
+ <p>This custom renderer has not implemented a complete schema-aware review.</p>
494
+ <button
495
+ disabled={busy || review.status !== "pending"}
496
+ onClick={() => settle(client.respond(review.id, { action: "decline" }))}
497
+ >
498
+ Don't proceed
499
+ </button>
500
+ <button
501
+ disabled={busy || review.status !== "pending"}
502
+ onClick={() => settle(client.respond(review.id, { action: "cancel" }))}
503
+ >
504
+ Cancel
505
+ </button>
506
+ </section>
507
+ );
508
+ }
509
+ if (part.type === "data-input-request") {
510
+ const request = part.data;
511
+ return (
512
+ <section key={request.id} aria-label="Assistant needs input">
513
+ <p>{request.message}</p>
514
+ {/* request.requestedSchema is the sole input-form contract. */}
515
+ <p>This custom renderer has not implemented the requested schema form.</p>
516
+ <button
517
+ disabled={busy || request.status !== "pending"}
518
+ onClick={() => settle(client.respond(request.id, { action: "decline" }))}
519
+ >
520
+ Don't proceed
521
+ </button>
522
+ <button
523
+ disabled={busy || request.status !== "pending"}
524
+ onClick={() => settle(client.respond(request.id, { action: "cancel" }))}
525
+ >
526
+ Cancel
527
+ </button>
528
+ </section>
529
+ );
530
+ }
531
+ if (part.type === "data-tool-result") {
532
+ return (
533
+ <p key={part.data.id} role="status">
534
+ A result is available, but this renderer has no trusted presentation for it.
535
+ </p>
536
+ );
537
+ }
538
+ if (part.type === "data-view") {
539
+ return (
540
+ <NoodleAppView
541
+ key={`${part.data.id}:${part.data.resourceUri}`}
542
+ client={client}
543
+ view={part.data}
544
+ theme={resolvedTheme}
545
+ />
546
+ );
547
+ }
548
+ if (part.type === "data-sign-in") {
549
+ const request = part.data;
550
+ return (
551
+ <section key={request.id} aria-label="Sign in required">
552
+ <p>Continue with your account to use this capability.</p>
553
+ <button
554
+ disabled={busy || pendingSignInTicket !== undefined}
555
+ onClick={() => {
556
+ setPendingSignInTicket(request.signInTicket);
557
+ void Promise.resolve()
558
+ .then(() =>
559
+ onSignInRequested({
560
+ signInTicket: request.signInTicket,
561
+ expiresAt: request.expiresAt,
562
+ }),
563
+ )
564
+ .then(
565
+ (result) => {
566
+ if (result === "cancelled") setPendingSignInTicket(undefined);
567
+ },
568
+ () => setPendingSignInTicket(undefined),
569
+ );
570
+ }}
571
+ >
572
+ Sign in
573
+ </button>
574
+ </section>
575
+ );
576
+ }
577
+ return <p key={index}>Unsupported assistant content.</p>;
578
+ })}
579
+ </article>
580
+ ))}
581
+ <p role="status" aria-live="polite">
582
+ {sessionNotice || turnNotice || (busy ? "Assistant is working" : "")}
583
+ </p>
584
+ {suggestions?.prompts.length ? (
585
+ <nav aria-label="Suggested messages">
586
+ {suggestions.prompts.map((prompt) => (
587
+ <button
588
+ key={prompt}
589
+ type="button"
590
+ disabled={busy}
591
+ onClick={() => {
592
+ setTurnNotice("");
593
+ settle(client.sendMessage(prompt));
594
+ }}
595
+ >
596
+ {prompt}
597
+ </button>
598
+ ))}
599
+ </nav>
600
+ ) : null}
601
+ {error ? <p role="alert">The assistant could not complete that request.</p> : null}
602
+ <form
603
+ onSubmit={(event) => {
604
+ event.preventDefault();
605
+ const message = draft.trim();
606
+ if (!message) return;
607
+ setDraft("");
608
+ setTurnNotice("");
609
+ settle(client.sendMessage(message));
610
+ }}
611
+ >
612
+ <input
613
+ aria-label="Message"
614
+ value={draft}
615
+ onChange={(event) => setDraft(event.currentTarget.value)}
616
+ />
617
+ {busy ? (
618
+ <button
619
+ type="button"
620
+ onClick={() => {
621
+ client.abort();
622
+ setTurnNotice("Response stopped. This does not undo a started action.");
623
+ }}
624
+ >
625
+ Stop
626
+ </button>
627
+ ) : (
628
+ <button type="submit">Send</button>
629
+ )}
630
+ </form>
631
+ </section>
632
+ );
633
+ }
634
+ ```
635
+
636
+ `principalKey` is a browser-local identity for the authenticated user/tenant and is never sent to Noodle. Change it whenever that principal changes; the hook then aborts and clears the previous session and transcript. The hook does not register `<noodle-assistant>` or render Noodle markup. `suggestions` is hook-owned state: each prompt starts an ordinary message through the same guarded `client.sendMessage` path and disappears when the client clears it.
637
+
638
+ Do not send a first turn on mount by default. React effect cleanup can suppress one provisional Strict Mode effect, but it cannot make a remount, dependency change, or client replacement idempotent. Require an explicit user action unless the host owns a durable one-shot state and an application idempotency key that makes repeated sends safe.
639
+
640
+ `settle` must await or catch the command promise; the same structured failure also appears in the hook `error` state.
641
+
642
+ This sample deliberately does not render Confirm until the application supplies a complete schema-aware review of every non-sensitive business field. It also refuses accepted elicitation until a form covers the compiler-approved portable `requestedSchema` subset; decline and cancel remain available and distinct. Use the managed renderer instead of shipping either unsupported branch. A production custom renderer must handle text, suggestions, sign-in, confirmation, elicitation, tool results, linked views, streaming, cancellation, terminal interaction states, and unknown future parts without silently dropping content.
643
+
644
+ `data-sign-in` is not an interaction and has no status: never pass its id to `client.respond`. Give `signInTicket` to an application callback that binds it to the host's existing short-lived login transaction; the authenticated backend spends it after login. Resolve the callback as `started` only after the host owns one active login transaction. Resolve it as `cancelled`, or reject it, when no transaction was started so the renderer can restore the sign-in affordance. A mixed custom renderer uses separate public and authenticated client mounts: the public shell starts with `embedId`, `serviceUrl`, and its visitor `principalKey`; after login, the authenticated destination mounts a new client against the same-origin `sessionEndpoint` with the user/tenant `principalKey`. Never pass both source options or mutate the public client source in place. The elevated session response owns bounded transcript repaint and optional resume. Never put the ticket in a query string, log, analytics event, or durable browser storage.
645
+
646
+ The `session_expired` notice is observational. The client owns its one pre-execution `401` re-exchange and message retry; never rebuild tokens or replay an interaction in renderer code. A retryable transport detail is diagnostic, not proof that resending an ordinary message or business action is safe, so the skeleton never offers a generic Retry button. Reconcile a lost interaction response only by repeating the same id and the same decision through an explicit application recovery affordance. Budget exhaustion and tenant-mismatch elevation never retry.
647
+
648
+ For `data-tool-result`, do not expose technical tool names or raw JSON as customer UI. Prefer the linked `data-view`; otherwise map a known bounded result to an application-trusted native component or keep the explicit unsupported-result state. For `data-view`, use the canonical `<noodle-app-view>` host (or its React `NoodleAppView` adapter). Never inject `part.data.html`, assign it to `srcdoc`, fetch a `ui://` URI, reproduce the bridge, wrap the client in another chat transport, or invent user messages for continuations.
649
+
650
+ `<noodle-app-view>` owns one bridge for the semantic view identity: client + `view.id` + `view.resourceUri`; `NoodleAppView` delegates to it. The host retains the iframe across fresh payload/callback/theme rerenders, publishes later resolved-theme changes through MCP Apps host context, and sends standard App teardown when that semantic identity changes, the element disconnects, or the App requests teardown. App views remain inline by default; the host advertises only inline presentation and rejects widget fullscreen requests. Opt in with `allowFullscreen` on `NoodleAppView` or `allow-fullscreen` on `<noodle-app-view>` only when fullscreen is an intentional part of the customer-owned experience. When fullscreen is accepted, the shared host adds an accessible top-right exit control that returns the same mounted App to inline mode without losing its state. Pass the same resolved application theme used by the conversation shell. Do not key an ancestor by a view object or callback. If the embedding page sets Content-Security-Policy, include the Noodle service origin in both `connect-src` and `frame-src`.
651
+
652
+ ### Framework-neutral DOM client
653
+
654
+ Use the same DOM-free client directly without a component wrapper, and import the isolated App-view entry only when rendering linked Apps. The client keeps the session token in memory, and the element owns only App presentation:
655
+
656
+ ```html
657
+ <div id="assistant-app-views"></div>
658
+ ```
659
+
660
+ ```ts
661
+ import { createAssistantClient } from "@noodleseed/assistant/client";
662
+ import "@noodleseed/assistant/app-view";
663
+ import {
664
+ type AssistantViewAvailableDetail,
665
+ type NoodleAppViewElement,
666
+ } from "@noodleseed/assistant/app-view";
667
+
668
+ const assistant = createAssistantClient({
669
+ sessionEndpoint: "/api/assistant/session",
670
+ clientContext: () => ({
671
+ locale: navigator.language,
672
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
673
+ }),
674
+ });
675
+
676
+ const appViews = document.querySelector("#assistant-app-views");
677
+ if (!appViews) throw new Error("Missing App views host");
678
+ const mountedViews = new Map<string, NoodleAppViewElement>();
679
+ const readResolvedTheme = (): "light" | "dark" =>
680
+ document.documentElement.classList.contains("dark") ? "dark" : "light";
681
+ let resolvedTheme: "light" | "dark" = readResolvedTheme();
682
+ const syncResolvedTheme = () => {
683
+ resolvedTheme = readResolvedTheme();
684
+ for (const mountedView of mountedViews.values()) mountedView.theme = resolvedTheme;
685
+ };
686
+ new MutationObserver(syncResolvedTheme).observe(document.documentElement, {
687
+ attributes: true,
688
+ attributeFilter: ["class"],
689
+ });
690
+ const appViewFor = (view: AssistantViewAvailableDetail) => {
691
+ const key = `${view.id}:${view.resourceUri}`;
692
+ let appView = mountedViews.get(key);
693
+ if (!appView) {
694
+ appView = document.createElement("noodle-app-view") as NoodleAppViewElement;
695
+ appView.client = assistant;
696
+ mountedViews.set(key, appView);
697
+ appViews.append(appView);
698
+ }
699
+ appView.theme = resolvedTheme;
700
+ return appView;
701
+ };
702
+
703
+ assistant.updateModelContext({
704
+ content: [{ type: 'text', text: 'The time-off form is mounted.' }],
705
+ structuredContent: { widget: { name: 'time-off', lifecycle: 'mounted' } },
706
+ });
707
+
708
+ assistant.subscribeChat((state) => {
709
+ renderUIMessageState(state, {
710
+ respond: (id, response) => assistant.respond(id, response),
711
+ });
712
+ const activeViewKeys = new Set<string>();
713
+ for (const message of state.messages) {
714
+ for (const part of message.parts) {
715
+ if (part.type === 'data-view') {
716
+ const key = `${part.data.id}:${part.data.resourceUri}`;
717
+ activeViewKeys.add(key);
718
+ appViewFor(part.data).view = part.data;
719
+ }
720
+ }
721
+ }
722
+ for (const [key, mountedView] of mountedViews) {
723
+ if (!activeViewKeys.has(key)) {
724
+ mountedView.remove();
725
+ mountedViews.delete(key);
726
+ }
727
+ }
728
+ });
729
+ ```
730
+
731
+ `subscribeChat` immediately emits a detached `{ messages, status, error? }` snapshot and then emits as `UIMessage.parts` change. Text uses `text`; Noodle confirmations, input requests, sign-in requests, tool results, and linked views use `data-confirmation`, `data-input-request`, `data-sign-in`, `data-tool-result`, and `data-view`. `renderUIMessageState` is application code: it must show a complete schema-aware review or requested form and require an explicit user gesture before calling the supplied `respond`. Never call `respond` while scanning a transcript snapshot. Interaction data moves through pending/submitting/accepted/declined/cancelled. Use raw `subscribe(...)` only for transport/session lifecycle events that are not transcript content.
732
+
733
+ For a chat-first custom host, raw `tool_started` carries the direct invocation call `id` and technical `tool` name before execution. Map known tools through a finite application-owned label table and use a neutral fallback such as "Working"; never mechanically expose an internal identifier as customer copy. Reserve one stable region with `role="status"` and `aria-live="polite"` for thinking, the mapped activity label, and the linked-view skeleton. `view_available` makes the view ready; raw `error` or chat `error` replaces it with `role="alert"`. Mark decorative skeleton shapes `aria-hidden="true"` and disable shimmer/transitions under `@media (prefers-reduced-motion: reduce)`.
734
+
735
+ ```ts
736
+ const activityByTool: Readonly<Record<string, string>> = {
737
+ list_open_items: "Loading open items",
738
+ };
739
+ const viewSlots = new Map([["ui://workspace/current", "current-workspace"]]);
740
+
741
+ assistant.subscribe((event) => {
742
+ if (event.event === "tool_started") {
743
+ showActivity(event.data.id, activityByTool[event.data.tool] ?? "Working");
744
+ }
745
+ if (event.event === "view_available") {
746
+ const key = viewSlots.get(event.data.resourceUri) ?? `${event.data.id}:${event.data.resourceUri}`;
747
+ showReadyView(key, event.data);
748
+ }
749
+ if (event.event === "error") showActivityError(event.data.code);
750
+ });
751
+ ```
752
+
753
+ `data-view` means a completed tool has a linked MCP App view. Pass that typed part and the existing client to `<noodle-app-view>` in another component framework or plain DOM, or to its `NoodleAppView` React adapter. It retains one bridge for client + `view.id` + `view.resourceUri` and requests standard App teardown on semantic replacement, disconnect, or App request. That pair is transport identity: different call ids are distinct invocations and must not be deduplicated generically. If the product intentionally owns one current panel for a known resource, declare an application-owned slot map and replace only that slot. Deliberately map the bounded result to an application-trusted native component only when replacing the linked App UI.
754
+
755
+ `clientContext` and typed `pageContext` are recomputed for each turn. `updateContext(...)` remains the legacy session-exchange context; `updatePageContext(...)` replaces the fresh per-turn application hint. `updateModelContext({ content, structuredContent })` publishes one cohesive renderer snapshot for later message turns without starting a turn; every call replaces the prior snapshot rather than merging fields. These are untrusted data, not conversation history or authorization input, and the boundaries reject credential-shaped or unbounded updates. A message may re-exchange once after a pre-execution `401`; the client never auto-retries interaction decisions. `tool_proposed.arguments` is a complete schema-aware review projection and, for connector-backed tools, names the sole exact connector version/operation/resolved arguments. Sensitive/write-only fields are redacted; truncating or omitting any non-sensitive action field fails closed. Accept is bound to the server-held action and claims at most one execution attempt—clients cannot replace it. Normal terminal outcomes scrub private arguments and continuations immediately; only an accepted action still executing retains them for the one-hour unknown-outcome recovery window, after which it records `interaction_outcome_unknown` and scrubs. Without downstream idempotency this is not an exactly-once business-effect guarantee. To reconcile a lost response, explicitly repeat the same id and decision: the service returns its durable stored outcome without re-execution.
756
+
757
+ ## Host readiness and promotion
758
+
759
+ Run the non-mutating host preflight from the embedding application before its production build:
760
+
761
+ ```sh
762
+ noodle assistant embed --check --json
763
+ noodle assistant embed --check --json --require-env EXAMPLE_DELEG_CLIENT_SECRET
764
+ ```
765
+
766
+ The check reports only required and missing environment names, never their values. Pass `--surface public|mixed|authenticated` to match the deployment: `public` drops the backend client id/secret requirement (a public embed has neither), and `public`/`mixed` additionally require `script-src` — the one directive whose failure runs no widget code at all, so nothing can report it from inside the page. CSP directives verify against the service origin exactly, via the env placeholder, or through a covering wildcard (`https://*.example.com`); a dynamic expression is marked unverified instead of guessed. Additional `--require-env` names are application-owned; `--env-alias NAME=HOST_NAME` follows a host repo that names an env var differently.
767
+
768
+ Read `evidence.levels` in order and stop at `evidence.firstUnproven`:
769
+
770
+ | Level | What can prove it |
771
+ | --- | --- |
772
+ | Static host | `assistant embed --check`: environment-name presence, control-plane URL shape, canonical session-route/mount risks, same-origin endpoint shape, and statically visible CSP |
773
+ | Local contract | The host's existing local test/build: JSON signed-out `401`, signed-in session exchange, unchanged response, client-only mount, and cookie/CSRF behavior |
774
+ | Hosted session | `assistant doctor` plus one representative routed safe read against the exact deployment |
775
+ | Production browser | A real deployed browser completing keyboard submission, session exchange, one streamed turn, one interaction, and one linked App with console/network inspection |
776
+ | Operations | The launch, rollback, rotation, budget, usage, and qualified-outcome checks below |
777
+
778
+ `static-host` is `passed` only when every canonical static diagnostic is ready or not applicable. It is `partial` when a custom or missing source location is not detected, and `failed` for a known blocker. A top-level `ready: true` means only that the check found no static blocker; static success is never local-contract, hosted-session, or production-browser proof.
779
+
780
+ The value-free diagnostics distinguish `mcp-endpoint` (a deployment MCP URL was supplied where the control-plane service URL belongs), `html-redirect-risk` (the canonical API route appears to redirect instead of returning JSON), `ssr-risk` (the canonical mount is not provably client-only), and `cross-origin-risk` (the browser session endpoint would omit same-origin cookies). `not-detected` and `unverified` are evidence gaps, not passes. The listed `postDeployProbes` are next actions; this command does not execute them.
781
+
782
+ There is no live or browser flag. Do not add one without human approval of its network, identity, data, timeout, and redaction contract; use the host's own test and browser tooling for later evidence levels.
783
+
784
+ Inspect the host repository for generated environment bindings after adding names. Run its existing generator, review the diff, commit generated types only when that repository requires them, then run the production-equivalent host build. Do not invent a framework command or add a second generator.
785
+
786
+ Promotion checklist: provision each environment in the backend secret manager; map names through the CI environment and any secret allowlist or secrets file; run the presence-only host preflight; run the canonical deploy so its configuration preflight completes before asset upload; run the post-deploy probes from the JSON contract; rotate the assistant client and delegated credential independently, then rerun the same checks.
787
+
788
+ Devtools privacy gate: default model and connector exercises to synthetic or mock data. Before Devtools Chat sends real connector data to an external model, disclose the data flow and obtain the user's approval. A local validation pass is not that approval.
789
+
790
+ ## Launch and qualified-usage proof
791
+
792
+ Treat deployed capability, production-browser proof, actual qualified usage, and measured customer outcome as four separate claims. Passing one never proves the next.
793
+
794
+ ### Pre-launch checklist
795
+
796
+ 1. Run `noodle assistant embed --check --json` in the host repository and stop at `evidence.firstUnproven`; complete the host-owned local contract and production-browser checks described above.
797
+ 2. Run `noodle assistant doctor --user-id <real-test-user> --origin <exact-origin> --org <org> --app <app> --env <env> --json`, then exercise one representative routed safe read. Use an approved synthetic account unless real customer data use was explicitly approved.
798
+ 3. Run `noodle assistant appearance show --org <org> --app <app> --env <env> --json` and have the customer owner verify the rendered brand, keyboard path, reduced motion, narrow viewport, confirmation, input, and linked App view in the deployed browser.
799
+ 4. Run `noodle assistant clients list --org <org> --app <app> --env <env> --json`; name the active credential owner and rehearse the secret-manager update and verification sequence for `noodle assistant clients rotate <client-id> --org <org> --app <app> --env <env> --json`. Rotation returns a new secret once; never put it in the PR, shell history, logs, or analytics.
800
+ 5. For a public or mixed surface, inspect its live caps with `noodle assistant embeds list --org <org> --app <app> --env <env> --json`. Record the authorized kill switch, `noodle assistant budget set --surface <embed-id> --turns-per-day 0 --org <org> --app <app> --env <env> --json`, but do not execute it during a rehearsal.
801
+ 6. Record the current and previous deployment identifiers and the named rollback owner. The recovery command is `noodle rollback <previous-deployment-id> --org <org> --app <app> --env <env> --json`; do not run it merely to prove syntax.
802
+ 7. Have the customer workflow owner execute and sign off the behavioral acceptance fixture below against the production-equivalent environment.
803
+
804
+ ### Customer-owned behavioral acceptance fixture
805
+
806
+ Use customer-recognizable inputs and expected business outcomes. Record only the minimum identifiers needed to reproduce a failure; do not paste raw conversations, secrets, or customer records into issue trackers.
807
+
808
+ | Case | Customer-owned setup | Expected observable outcome |
809
+ | --- | --- | --- |
810
+ | normal | One common, authorized job with known data | Completes the named task and presents the business result or linked App clearly |
811
+ | ambiguous | A request with two materially different interpretations | Asks the smallest useful clarifying question before action |
812
+ | missing-data | Omit one required business field or make the read return no record | Requests only the missing input or states that no data exists; never invents it |
813
+ | unauthorized | Use a real test role lacking the required scope | Refuses or withholds the capability without leaking its result, arguments, or internal tool name |
814
+ | tenant-switch | Change the signed-in organization while the panel is open | Aborts and clears the prior principal session; no prior-tenant transcript, route, or result survives |
815
+ | confirmation | Propose one consequential operation, then test decline, cancel, and accept separately | Shows the complete non-sensitive action review; only explicit accept executes, at most once |
816
+ | recovery | Expire a session before execution and lose one interaction response after submission | Safe pre-execution re-exchange succeeds once; same-id/same-decision reconciliation returns the stored outcome without re-execution |
817
+
818
+ ### Post-launch checklist
819
+
820
+ 1. Run `noodle assistant usage --window 7d --org <org> --app <app> --env <env> --json` on an agreed cadence. It reports traffic, depth, engagement, latency, and tokens; it does not establish customer success.
821
+ 2. Re-run the customer-owned fixture with named external users after any tool, permission, model, identity, renderer, or routing change. Record results by case and deployment, not as a single green launch claim.
822
+ 3. Report task completion, refusal correctness, repeat usage, safety, latency, and abandonment separately: completed qualified attempts / qualified attempts; correct refusals / refusal cases; distinct returning qualified users; confirmed cross-tenant, duplicate-effect, or secret-exposure incidents; p50/p95 end-to-end time for the named job; and started qualified attempts with no completion or explicit refusal.
823
+ 4. Treat raw turn volume as utilization evidence, not an outcome. A user can generate many turns because the assistant is useful, confused, slow, or unsafe.
824
+ 5. Recheck caps and credential age. Run the rehearsed `noodle assistant clients rotate <client-id>` sequence only under the named owner, update the backend secret manager, rerun doctor and one browser turn, then revoke the superseded credential after the overlap window.
825
+ 6. When an agreed safety, cost, or correctness threshold is crossed, use the recorded public-surface budget kill switch or the recorded deployment rollback. Verify the restored state with doctor, the browser path, and the affected acceptance cases.
826
+
827
+ Do not add a platform-owned completion event or infer completion from a final assistant message. Any such schema requires a separate human-approved analytics and customer-data decision covering event semantics, customer consent, retention, access, deletion, tenant isolation, and whether the customer application or Noodle is authoritative.
828
+
829
+ ## Toolchain requirements
830
+
831
+ - Node.js 20+ for `@noodleseed/assistant/server`.
832
+ - The package ships ESM and CommonJS with full export conditions; no bundler aliases, `transpilePackages`, or ambient type shims are needed. If resolution fails, the installed package version is outdated: update `@noodleseed/assistant` instead of adding workarounds.
833
+ - TypeScript `moduleResolution` `bundler` or `node16` recommended; classic `node` also resolves the `/app-view`, `/client`, `/react`, `/react/client`, and `/server` subpaths.
834
+
835
+ ## Verify the boundary
836
+
837
+ - Signed-out session exchange returns `401`.
838
+ - The browser network/DOM/storage contains no client secret or model key.
839
+ - The local and production origins match the authored `access.origins` character-for-character.
840
+ - At the manifest/runtime boundary and in TypeScript action helpers, only `confirm: true` enables confirmation; omitted or `false` preserves direct execution. Action hints alone never enforce approval; `annotations.action({ confirm: false })` is equivalent to omission.
841
+ - An expired turn re-exchanges once; interaction decisions never auto-retry. An explicit same-decision repeat returns the stored outcome without executing again.
842
+ - Accept, decline, and cancel are single-use. Only accept executes; the server ignores replacement tool arguments.
843
+ - Wrong-origin and malformed-origin requests fail closed.
844
+ - Browser-controlled fields cannot select or override `routing.endpoints`; a customer-routed connector uses only the backend-verified session route.
845
+ - Run the production-equivalent host build after regenerating environment bindings.
846
+ - In a real browser, submit with the keyboard, inspect console and network failures, complete session exchange and one tool turn, and render one linked App before claiming the host works.
847
+
848
+ ## Troubleshooting: symptom to diagnosis
849
+
850
+ | Symptom | Diagnosis | Fix |
851
+ | --- | --- | --- |
852
+ | Widget renders but no reply arrives and model usage stays zero | Turns are not reaching the service: outdated `@noodleseed/assistant` package, or the session response was rebuilt/filtered by the backend route | Update the package to the latest version; forward the session response unchanged |
853
+ | Widget card shows its title but an empty/blank frame (or a "could not be displayed" note) | The widget frame could not complete its bridge handshake: the page CSP blocks the hosted sandbox document (`frame-src`), the SDK predates hosted-sandbox rendering, or the deployed artifact was compiled by a now-incompatible CLI | Add the service origin to the page's `frame-src` (and `connect-src`) CSP directives; update `@noodleseed/assistant`; redeploy the app with the current `noodle` CLI |
854
+ | `assistant-error` with code `invalid_response` | The turn endpoint returned HTML or non-SSE content (auth redirect, proxy page) | Check the backend session route path and any middleware/rewrites on the embedding app |
855
+ | Build error `Package path ./react is not exported` | Outdated package version with import-only export conditions | Update `@noodleseed/assistant`; do not add webpack aliases or type shims |
856
+ | Deploy fails with `server_auth_required` | `--access customers` without `server.auth` | Add direct/federated OIDC or a built-in Firebase/Microsoft adapter |
857
+ | Validate rejects an origin | Non-loopback HTTP origin in `access.origins` | Use the exact HTTPS production origin; HTTP is only for `localhost`/`127.0.0.1` |
858
+ | Session exchange returns 404 | `serviceUrl` points at the deployment MCP endpoint | Use the control-plane service URL printed by `noodle assistant clients create` |
859
+ | Session exchange returns 403 `origin is not allowed` | Request origin differs from the authored `access.origins` character-for-character | Align the exact scheme/host/port on both sides and redeploy |
860
+ | Session exchange returns `400` with `invalid assistant routing` | The authenticated backend supplied an unknown endpoint name or a malformed/policy-disallowed URL | Resolve the route from server-owned membership, use the exact authored endpoint name, and ensure the canonical HTTPS URL satisfies its active `customerEndpoint` policy; the error never reflects the URL |
861
+ | A routed assistant tool returns `connector_route_unavailable` | The authenticated backend omitted that endpoint during session exchange | Pass the server-verified route as `routing.endpoints.<name>` when minting a new session; keep it out of browser input |
862
+ | Host session 503 | A required backend environment name is absent or mapped into the wrong deployment environment | Run `noodle assistant embed --check --json`, repair the host CI mapping, then probe the session route again |
863
+ | `HEAD` on a widget or session path looks broken | The route contract is `GET` for the hosted sandbox/widget document or `POST` for session exchange; `HEAD` is not the product flow | Exercise the documented method and inspect its response instead of inferring readiness from `HEAD` |
864
+ | Local server reports `listen EPERM` | The coding sandbox blocked loopback binding before application behavior ran | Rerun the same local/browser test with approved loopback permissions; do not change product code |
865
+ | Tool succeeds but the widget is empty | The linked App delivery layer failed: result shape, resource link, CSP frame, or bridge hydration | Inspect the typed result, `view_available`, resource URI, browser console, and hosted frame separately |
866
+ | Hydration or `HTMLElement is not defined` errors | The component mounted during server rendering | Mount client-only (`"use client"` or `next/dynamic` with `ssr: false`) |
867
+ | A tool runs without the expected confirmation | Its compiled annotations omit `confirm: true` or explicitly set `false` | Pass `{ confirm: true }` to the action helper; action hints alone never gate. `noodle check --target embedded-assistant` lists every confirm-gated tool |
868
+ | `${user.claims.<key>}` is empty | Claim not declared in the authenticated surface `sessionClaims` (or key typo) — undeclared claims are dropped at exchange | Declare the key in `authenticatedWebsite({ origins, sessionClaims })` and redeploy |
869
+ | `${user.name}` is empty | Backend did not pass `user.name` to `createAssistantSession` | Pass the verified name from the authenticated backend session |
870
+ | The model does not know a claim you passed | Claim is tools-only | Mark it `exposeToModel: true` in `sessionClaims` |
871
+ | Relative dates use the wrong day or time zone | No verified user preference and the browser hint is missing/stale | Pass saved `preferences` from the backend; provide a fresh per-turn `clientContext` in a headless renderer |
872
+ | The model invents a team/holiday after context lookup fails | The ambient provider returned invalid data or its read-only connector failed (`ambientStatus: unavailable`) | Fix the provider/connector; treat unavailable ambient facts as missing, never prompt instructions |
873
+ | Decline/cancel reports `unsupported_service` | The session came from a legacy service with no `endpoints.interactions` | Upgrade the service; legacy `toolConfirmations` supports accept only |
874
+ | Behavior does not change after `noodle deploy` | The client is not following the tenant's active deployment | Restart the client session and confirm the selected tenant and deployment |
875
+ | An embedded-assistant delegated connector returns `credential_unavailable` | The active deployment cannot complete the managed-secret/token-endpoint exchange for the diagnostic session identity | Run `noodle assistant doctor --user-id <real-test-user> --origin "$PUBLIC_APP_ORIGIN" --org <org> --app <app> --env <env>`; it uses the backend client credential, not a customer bearer token, and never invokes a business operation |
876
+ | Deploy fails with `unsupported_delegated_provider` | `delegatedOAuth.provider` only supports the managed `firebase`/`microsoft` bridges | Use `auth.kind: "delegatedTokenExchange"` for your own token endpoint (see authoring-workflow.md) |