@noodleseed/agent-kit 0.21.1 → 0.23.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 (44) hide show
  1. package/manifest.json +83 -33
  2. package/package.json +1 -1
  3. package/skills/claude-code/SKILL.md +1 -1
  4. package/skills/claude-code/examples/acme-bistro/src/server.ts +1 -1
  5. package/skills/claude-code/examples/acme-discovery/src/server.ts +1 -1
  6. package/skills/claude-code/examples/acme-tasks/src/server.ts +4 -2
  7. package/skills/claude-code/examples/acme-tasks/test/server.test.ts +9 -0
  8. package/skills/claude-code/examples/customer-auth/README.md +237 -0
  9. package/skills/claude-code/examples/customer-auth/noodle.json +4 -0
  10. package/skills/claude-code/examples/customer-auth/package.json +16 -0
  11. package/skills/claude-code/examples/customer-auth/src/server.ts +145 -0
  12. package/skills/claude-code/examples/customer-auth/test/server.test.ts +25 -0
  13. package/skills/claude-code/examples/food-ordering/README.md +7 -3
  14. package/skills/claude-code/examples/food-ordering/src/helpers.ts +2 -0
  15. package/skills/claude-code/examples/food-ordering/src/server.ts +47 -2
  16. package/skills/claude-code/examples/food-ordering/src/views/ordering-flow.tsx +56 -3
  17. package/skills/claude-code/examples/food-ordering/test/server.test.ts +46 -0
  18. package/skills/claude-code/references/authoring-workflow.md +117 -2
  19. package/skills/claude-code/references/compile-errors.md +6 -1
  20. package/skills/claude-code/references/embedded-assistant.md +95 -10
  21. package/skills/claude-code/references/examples.md +1 -1
  22. package/skills/claude-code/references/sdk-surface.md +2 -2
  23. package/skills/claude-code/references/widgets-and-apps.md +32 -7
  24. package/skills/codex/SKILL.md +1 -1
  25. package/skills/codex/examples/acme-bistro/src/server.ts +1 -1
  26. package/skills/codex/examples/acme-discovery/src/server.ts +1 -1
  27. package/skills/codex/examples/acme-tasks/src/server.ts +4 -2
  28. package/skills/codex/examples/acme-tasks/test/server.test.ts +9 -0
  29. package/skills/codex/examples/customer-auth/README.md +237 -0
  30. package/skills/codex/examples/customer-auth/noodle.json +4 -0
  31. package/skills/codex/examples/customer-auth/package.json +16 -0
  32. package/skills/codex/examples/customer-auth/src/server.ts +145 -0
  33. package/skills/codex/examples/customer-auth/test/server.test.ts +25 -0
  34. package/skills/codex/examples/food-ordering/README.md +7 -3
  35. package/skills/codex/examples/food-ordering/src/helpers.ts +2 -0
  36. package/skills/codex/examples/food-ordering/src/server.ts +47 -2
  37. package/skills/codex/examples/food-ordering/src/views/ordering-flow.tsx +56 -3
  38. package/skills/codex/examples/food-ordering/test/server.test.ts +46 -0
  39. package/skills/codex/references/authoring-workflow.md +117 -2
  40. package/skills/codex/references/compile-errors.md +6 -1
  41. package/skills/codex/references/embedded-assistant.md +95 -10
  42. package/skills/codex/references/examples.md +1 -1
  43. package/skills/codex/references/sdk-surface.md +2 -2
  44. package/skills/codex/references/widgets-and-apps.md +32 -7
@@ -8,9 +8,10 @@
8
8
  - Access modes and customer auth
9
9
  - Create the backend client
10
10
  - Integrate the customer backend
11
+ - Ground time and ambient facts
11
12
  - Verified session context (identity and claims)
12
13
  - The session response
13
- - Mount the browser component
14
+ - Choose a browser renderer
14
15
  - Toolchain requirements
15
16
  - Verify the boundary
16
17
  - Troubleshooting: symptom to diagnosis
@@ -32,6 +33,7 @@ Use the same server tools in the embed; do not create a second tool set. Declare
32
33
 
33
34
  ```ts
34
35
  branding: { name: "Acme", accent: "#3157D5" },
36
+ context: { defaults: { locale: "en-GB", timeZone: "Europe/London" } },
35
37
  assistant: embeddedAssistant({
36
38
  model: openAICompatible({
37
39
  baseUrl: variable("ASSISTANT_MODEL_BASE_URL"),
@@ -105,6 +107,8 @@ export async function POST(request: Request) {
105
107
  origin: process.env.PUBLIC_APP_ORIGIN!,
106
108
  user: { id: user.id, email: user.email, roles: user.roles },
107
109
  context,
110
+ // Saved, backend-verified user preferences outrank browser hints.
111
+ preferences: { locale: user.locale, timeZone: user.timeZone },
108
112
  });
109
113
  return Response.json(session);
110
114
  }
@@ -114,6 +118,34 @@ Authenticate before exchange. Source `origin` from trusted server configuration
114
118
 
115
119
  `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.
116
120
 
121
+ ## Ground time and ambient facts
122
+
123
+ 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.
124
+
125
+ Use the server-level context declaration for application facts that every surface should share:
126
+
127
+ ```ts
128
+ context: {
129
+ defaults: { locale: 'en-GB', timeZone: 'Europe/London' },
130
+ ambient: {
131
+ output: z.object({ defaultTeamId: z.string(), holidays: z.array(z.string()) }),
132
+ fulfil: ({ user, context, connectors }) => {
133
+ const calendar = connectors.people.getCalendar({
134
+ subject: user.subject,
135
+ asOf: context.temporal.instant,
136
+ });
137
+ return { defaultTeamId: calendar.default_team_id, holidays: calendar.holidays };
138
+ },
139
+ },
140
+ },
141
+ ```
142
+
143
+ 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. Canonical TypeScript `server()` authoring emits an empty context declaration even when you omit the option, so MCP clients receive the reserved read-only `noodle_context` temporal tool with zero setup; add `server.context` only for custom defaults or ambient facts. Raw Core-v1 manifests activate the adapter only when that field is present. Never declare a TypeScript author tool named `noodle_context`. Keep ambient facts compact: the platform caps serialized JSON at 16 KiB, depth 8, and 128 entries per container, and rejects credential-shaped keys.
144
+
145
+ ## Structured missing input
146
+
147
+ A tool authored with `ctx.elicit({ id, message, input })` produces `input_requested` when it reaches the missing value. The built-in and headless renderers consume the same advertised endpoints and interaction-event protocol: either renderer presents that event and, after the current turn stream completes, calls `respond(id, { action: "accept", content })`; decline/cancel stop the flow. Accepted content is schema-validated and completed steps are not rerun; invalid content returns `arg_invalid` and leaves the same request pending for correction. A chained request receives a fresh id and `tool_completed` appears only after the final answer. Every interactive flow must collect all elicited input before its first connector operation. Elicitation gathers an input; it does not approve a later write, which remains separately confirmation-gated. In a flow marked `confirm: true`, every eligible `input_requested` precedes `tool_proposed`; the final proposal reviews the original tool input, elicited values, and sole exact connector version/operation/resolved arguments. Accept is bound to that action and only then may execution start. Confirmable flows may contain at most one connector operation. Bidirectional MCP transports map missing input to standard form `elicitation/create`; an adapter that cannot carry that request fails before executing the tool. The same negotiated form capability carries a final affirmative confirmation and fails closed when unavailable. At the manifest/runtime boundary and in TypeScript action helpers, omitted or `false` preserves direct execution; action hints alone never gate. `annotations.action({ confirm: false })`, like omission, runs directly, while `annotations.action({ confirm: true })` explicitly enables confirmation.
148
+
117
149
  ## Verified session context (identity and claims)
118
150
 
119
151
  The embedding developer defines what authenticated session context the assistant receives. One mechanism, three hops:
@@ -152,15 +184,17 @@ tool("greet", {
152
184
  });
153
185
  ```
154
186
 
155
- Manifest expressions use `${user.name}`, `${user.email}`, `${user.subject}`, `${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.
187
+ 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.
156
188
 
157
- Page `context` from the widget remains untrusted hint data; verified facts belong in `claims`, never in `context`.
189
+ 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.
190
+
191
+ 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").
158
192
 
159
193
  ## The session response
160
194
 
161
- The exchange returns the versioned Embedded Assistant v1 contract. `token`, `expiresAt`, and `endpoints.turns` / `endpoints.toolConfirmations` (absolute URLs) are always present; `configuration` is optional theming data. Forward the body unchanged; the widget posts turns to `endpoints.turns` itself. Do not rebuild, filter, or rewrite the response.
195
+ 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.
162
196
 
163
- ## Mount the browser component
197
+ ## Choose a browser renderer
164
198
 
165
199
  Use the React wrapper in React applications:
166
200
 
@@ -174,19 +208,65 @@ Or import the package root once and mount `<noodle-assistant session-endpoint="/
174
208
 
175
209
  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`.
176
210
 
211
+ For a customer-owned renderer, use the DOM-free client. It keeps the session token in memory, streams the same typed events, and never registers a custom element:
212
+
213
+ ```ts
214
+ import { createAssistantClient } from "@noodleseed/assistant/client";
215
+
216
+ const assistant = createAssistantClient({
217
+ sessionEndpoint: "/api/assistant/session",
218
+ clientContext: () => ({
219
+ locale: navigator.language,
220
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
221
+ }),
222
+ });
223
+
224
+ assistant.updateModelContext({
225
+ content: [{ type: 'text', text: 'The time-off form is mounted.' }],
226
+ structuredContent: { widget: { name: 'time-off', lifecycle: 'mounted' } },
227
+ });
228
+
229
+ let pendingId: string | undefined;
230
+ let requestedSchema: unknown;
231
+ assistant.subscribe((event) => {
232
+ renderAssistantEvent(event);
233
+ if (event.event === 'view_available') {
234
+ renderRegisteredView(event.data.resourceUri, event.data.result);
235
+ }
236
+ if ((event.event === 'tool_proposed' || event.event === 'input_requested') && typeof event.data.id === 'string') {
237
+ pendingId = event.data.id;
238
+ requestedSchema = event.event === 'input_requested' ? event.data.requestedSchema : undefined;
239
+ }
240
+ });
241
+
242
+ await assistant.sendMessage("Book next Thursday and Friday off");
243
+ if (pendingId) {
244
+ const resolution = requestedSchema
245
+ ? { action: 'accept' as const, content: await renderPortableForm(requestedSchema) }
246
+ : { action: 'accept' as const };
247
+ await assistant.respond(pendingId, resolution);
248
+ }
249
+ // The same pending id also accepts { action: 'decline' } or { action: 'cancel' }.
250
+ ```
251
+
252
+ `view_available` means a completed tool has a linked MCP App view. It carries the call/interaction id, tool, `ui://` identity, optional title, and bounded/redacted public result; it does not claim that anything rendered. A headless customer renderer maps that identity to an already-trusted component. Never fetch the `ui://` URI or inject its HTML. The standard element likewise does not render it; it dispatches `assistant-view-available` with the same detail.
253
+
254
+ `clientContext` is recomputed for each turn. `updateContext(...)` changes separate untrusted page context for the next session exchange; call `resetSession()` when it must take effect immediately. `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. It is untrusted per-turn data, not conversation history or authorization input, and both 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, and 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.
255
+
177
256
  ## Toolchain requirements
178
257
 
179
258
  - Node.js 20+ for `@noodleseed/assistant/server`.
180
259
  - 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.
181
- - TypeScript `moduleResolution` `bundler` or `node16` recommended; classic `node` also resolves the `/react` and `/server` subpaths.
260
+ - TypeScript `moduleResolution` `bundler` or `node16` recommended; classic `node` also resolves the `/client`, `/react`, and `/server` subpaths.
182
261
 
183
262
  ## Verify the boundary
184
263
 
185
264
  - Signed-out session exchange returns `401`.
186
265
  - The browser network/DOM/storage contains no client secret or model key.
187
266
  - The local and production origins match `allowedOrigins` character-for-character.
188
- - Auto-run requires the full safe-read annotation (`annotations.readOnly()`: read-only, non-destructive, closed-world); unannotated or partially annotated tools always confirm. Writes require confirmation by design.
189
- - An expired turn re-exchanges once; confirmations never replay.
267
+ - At the manifest/runtime boundary and in TypeScript action helpers, only `confirm: true` enables confirmation; omitted or `false` preserves Core-v1 direct execution. Action hints alone never enforce approval; `annotations.action({ confirm: false })` is equivalent to omission.
268
+ - An expired turn re-exchanges once; interaction decisions never auto-retry. An explicit same-decision repeat returns the stored outcome without executing again.
269
+ - Accept, decline, and cancel are single-use. Only accept executes; the server ignores replacement tool arguments.
190
270
  - Wrong-origin and malformed-origin requests fail closed.
191
271
 
192
272
  ## Troubleshooting: symptom to diagnosis
@@ -201,8 +281,13 @@ The component renders a custom element and must mount client-side. In a Next.js
201
281
  | Session exchange returns 404 | `serviceUrl` points at the deployment MCP endpoint | Use the control-plane service URL printed by `noodle assistant clients create` |
202
282
  | Session exchange returns 403 `origin is not allowed` | Request origin differs from `allowedOrigins` character-for-character | Align the exact scheme/host/port on both sides and redeploy |
203
283
  | Hydration or `HTMLElement is not defined` errors | The component mounted during server rendering | Mount client-only (`"use client"` or `next/dynamic` with `ssr: false`) |
204
- | A read-only tool still asks for confirmation | Its annotations fail the safe-read rule: auto-run requires `readOnlyHint: true`, `destructiveHint: false`, AND `openWorldHint: false` (use `annotations.readOnly()`; `readOnly({ openWorld: true })` confirm-gates) | Fix the annotations; `noodle check --target embedded-assistant` lists every confirm-gated tool |
284
+ | 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 |
205
285
  | `${user.claims.<key>}` is empty | Claim not declared in `sessionClaims` (or key typo) — undeclared claims are dropped at exchange | Declare the key in `embeddedAssistant({ sessionClaims })` and redeploy |
206
286
  | `${user.name}` is empty | Backend did not pass `user.name` to `createAssistantSession` | Pass the verified name from the authenticated backend session |
207
287
  | The model does not know a claim you passed | Claim is tools-only | Mark it `exposeToModel: true` in `sessionClaims` |
208
- | Behavior does not change after `noodle deploy` | Outdated platform: before the 2026-07 fix, clients were pinned to their creation-time deployment | Update the platform; sessions now follow the tenant's active deployment |
288
+ | 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 |
289
+ | 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 |
290
+ | Decline/cancel reports `unsupported_service` | The session came from a legacy service with no `endpoints.interactions` | Upgrade the service; legacy `toolConfirmations` supports accept only |
291
+ | Behavior does not change after `noodle deploy` | Outdated platform: before the 2026-07 fix, clients were pinned to their creation-time deployment | Update the platform; sessions now follow the tenant's active deployment |
292
+ | A delegated connector tool fails with `delegated token exchange requires a verified customer caller` | The calling surface has no verified customer identity (or an old session minted before the platform carried the resource audience) | Verify `customerAuth` is configured and the backend passes the verified `user` to `createAssistantSession`; re-mint the session |
293
+ | 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) |
@@ -14,12 +14,12 @@ Paths are relative to this skill directory. Assets (images/fonts) are omitted fr
14
14
  | `acme-discovery` | Top-of-funnel discovery→handoff: a discovery carousel, a `create_handoff` deep link, and a design-first UX spec + wireframe. | `examples/acme-discovery/src/server.ts` + `design/` |
15
15
  | `acme-tasks` | A two-way productivity app designed around its top-3 prioritized flows (capture/prioritize/complete), with a design-first flow spec + wireframe. | `examples/acme-tasks/src/server.ts` + `design/` |
16
16
  | `acme-bistro` | End-to-end ordering with a payment-only handoff; ships a gold-standard `design/` set (UX doc, wireframe with compliance audit, API contract). | `examples/acme-bistro/src/server.ts` + `design/` |
17
+ | `customer-auth` | End-user (customer) auth via OIDC/Firebase bridge with delegated credentials. | `examples/customer-auth/src/server.ts` |
17
18
 
18
19
  ## In the repository only — `examples/<name>/` on GitHub
19
20
 
20
21
  | Example | Use when |
21
22
  | :-- | :-- |
22
- | `customer-auth` | End-user (customer) auth via OIDC/Firebase bridge with delegated credentials. |
23
23
  | `stateful-draft` | Durable, caller-scoped widget state handles with optimistic revisions. |
24
24
  | `perplexity` | A real SaaS API with bearer auth and a managed `secret`. |
25
25
  | `bitcoin` | API-key HTTP connector, custom auth header, and compute normalization. |
@@ -127,7 +127,7 @@ prompt('summarize_ticket', {
127
127
 
128
128
  ### Non-trivial tool: ctx connectors, annotations, visibility, async
129
129
 
130
- `ctx` is `{ input, user, connectors }`. Bind connectors with `use` on the server, then call one inside `fulfil` to record a step. `annotations.readOnly()` / `annotations.action()` set the tool hints; `visibility` defaults to `['model', 'app']` — set `['app']` to hide a helper from the model. `fulfil` may be `async` (the compiler awaits it while recording).
130
+ `ctx` is `{ input, user, connectors }`. Bind connectors with `use` on the server, then call one inside `fulfil` to record a step. `annotations.readOnly()` declares a closed-world safe read. TypeScript action helpers enforce confirmation only with `{ confirm: true }`; omitted or `false` executes directly, and action/destructive/open-world hints alone never enable the gate. `visibility` defaults to `['model', 'app']` — set `['app']` to hide a helper from the model. `fulfil` may be `async` (the compiler awaits it while recording).
131
131
 
132
132
  ```ts
133
133
  import { annotations, connector, server, tool, z } from '@noodleseed/one';
@@ -169,7 +169,7 @@ export default server('support', { title: 'Support', version: '1.0.0', use: { cr
169
169
  description: 'Echo text back.',
170
170
  input: z.object({ text: z.string() }),
171
171
  output: z.object({ echo: z.string() }),
172
- annotations: annotations.action(), // mutating / world-affecting hint
172
+ annotations: annotations.action(), // world-affecting hint; add { confirm: true } to gate
173
173
  // fulfil may be async — the compiler awaits it while recording the flow.
174
174
  fulfil: async ({ input }) => ({ echo: input.text }),
175
175
  }),
@@ -38,10 +38,12 @@ Author views as React components. `generateHelpers<ServerDefinition>()` (from `@
38
38
  | `useRequestDisplayMode` | Request a host-mediated layout change such as fullscreen; treat it as best-effort and keep inline rendering useful. |
39
39
  | `useOpenExternal` | Open an external link through the host (never `window.open`); the target origin must be listed in the server-level `handoff.allowedDomains`. |
40
40
  | `useSendFollowUpMessage` | Send a follow-up prompt to the model from a user interaction: `send({ prompt })` — trigger only from an explicit user action. |
41
+ | `useUpdateModelContext` | Publish one compact, cohesive author-selected text/structured snapshot through the standard MCP Apps model-context channel; each call replaces the prior snapshot, so include every still-relevant field and check `useLayout().supports?.modelContext` first. |
42
+ | `useWidgetLifecycle` | Calling the hook auto-publishes `mounted` and listens for host `cancelled`/`dismissed`; use its publisher for author-owned `submitted` or app milestones, include a complete safe replacement snapshot, and pair explicit submit/cancel with `useSendFollowUpMessage` when an immediate reply is wanted. |
41
43
  | `useAppFlow` | Manage named widget views with persisted params and back-stack state: `const flow = useAppFlow({ initialView, views })`. |
42
44
  | `useHandoff` | Open server-created HTTP(S) handoff URLs through the host with status/error state; domain policy still comes from `handoff.allowedDomains`. |
43
45
 
44
- Bind interactive elements to tools (`useCallTool("place_order")`), drive named views with `useAppFlow(...)`, open server-created handoffs with `useHandoff()`, and annotate model-facing context with `data-llm`. Use `createViewStore("key", initial)` for multi-component widget state such as carts, filters, or drafts. Use the domain-neutral React components from `@noodleseed/one/react` (`AppShell`, `ShellNav`, `ViewStack`, `AsyncBoundary`, `ActionBar`, `Field`, `QuantityStepper`, `ChoiceGroup`, `HandoffButton`, and related state components) for rich apps before inventing local shell/control scaffolding. Adapt to the host with `useLayout()` — style for both `theme` values, and keep the inline `displayMode` compact (content fits the space; no internal scrolling). Trigger `useOpenExternal()`, `useHandoff()`, and `useSendFollowUpMessage()` only from explicit user actions. A raw `html` escape hatch exists for self-contained widgets (declarative `data-bind`/`data-action`; no inline `<script>`).
46
+ Bind interactive elements to tools (`useCallTool("place_order")`), drive named views with `useAppFlow(...)`, open server-created handoffs with `useHandoff()`, and publish one compact, safe, cohesive snapshot with `useUpdateModelContext()` when `useLayout().supports?.modelContext` is true. Every model-context or lifecycle publication replaces the prior snapshot rather than merging fields, so include everything the model should still know. Calling `useWidgetLifecycle("name")` automatically publishes `mounted`, listens for host `cancelled` and `dismissed`, and returns a publisher for author-owned `submitted` or app-specific milestones; `mounted` is not proof that the host presented pixels. Both hooks use the standard MCP Apps model-context channel, not a host-specific API. These updates affect future model context but do not start a model turn. When an explicit user submit/cancel should receive an immediate reply, also call `useSendFollowUpMessage()` from that user action. `data-llm` may remain a DOM inspection hint, but it is not the bidirectional model-state contract. Use `createViewStore("key", initial)` for multi-component widget state such as carts, filters, or drafts. Use the domain-neutral React components from `@noodleseed/one/react` (`AppShell`, `ShellNav`, `ViewStack`, `AsyncBoundary`, `ActionBar`, `Field`, `QuantityStepper`, `ChoiceGroup`, `HandoffButton`, and related state components) for rich apps before inventing local shell/control scaffolding. Adapt to the host with `useLayout()` — style for both `theme` values, and keep the inline `displayMode` compact (content fits the space; no internal scrolling). Trigger `useOpenExternal()`, `useHandoff()`, and `useSendFollowUpMessage()` only from explicit user actions. A raw `html` escape hatch exists for self-contained widgets (declarative `data-bind`/`data-action`; no inline `<script>`).
45
47
 
46
48
  ## Worked widget recipe
47
49
 
@@ -49,14 +51,15 @@ Minimal, complete, and compile-verified — `noodle validate` bundles the view a
49
51
 
50
52
  ### 1. The view component
51
53
 
52
- Author React. `generateHelpers<ServerDefinition>()` (from `@noodleseed/one/react`) returns the typed host hooks: read the tool result with `useToolInfo`, call a widget-only helper with `useCallTool`, keep local UI state that survives re-render with `useViewState`, open an allowlisted link with `useOpenExternal`, and mirror model-facing context back to the model as text with `data-llm`.
54
+ Author React. `generateHelpers<ServerDefinition>()` (from `@noodleseed/one/react`) returns the typed host hooks: read the tool result with `useToolInfo`, call a widget-only helper with `useCallTool`, keep local UI state that survives re-render with `useViewState`, open an allowlisted link with `useOpenExternal`, and publish cohesive model-context snapshots plus author-owned lifecycle milestones.
53
55
 
54
56
  ```tsx
57
+ import { useEffect } from 'react';
55
58
  import type { ServerDefinition } from '@noodleseed/one';
56
59
  import { Action, ActionBar, AsyncBoundary, Feedback, Field, Flow, Frame, Region, Select, generateHelpers } from '@noodleseed/one/react';
57
60
 
58
61
  // One call wires the typed host bridge; destructure only the hooks this view uses.
59
- const { useToolInfo, useCallTool, useViewState, useOpenExternal } =
62
+ const { useToolInfo, useCallTool, useViewState, useLayout, useOpenExternal, useSendFollowUpMessage, useUpdateModelContext, useWidgetLifecycle } =
60
63
  generateHelpers<ServerDefinition>();
61
64
 
62
65
  type OrderResult = {
@@ -69,14 +72,36 @@ type OrderResult = {
69
72
  export default function OrderStatus() {
70
73
  const shown = useToolInfo('show_order').structuredContent as OrderResult | undefined;
71
74
  const placeOrder = useCallTool('place_order'); // calls the widget-only helper tool
75
+ const { supports } = useLayout();
72
76
  const openExternal = useOpenExternal();
77
+ const sendFollowUpMessage = useSendFollowUpMessage();
78
+ const updateModelContext = useUpdateModelContext();
79
+ const publishLifecycle = useWidgetLifecycle('pickup-order');
73
80
  const [item, setItem] = useViewState('item', shown?.item ?? 'falafel_wrap'); // survives re-render
74
81
  const confirmed = placeOrder.data?.structuredContent as { readonly status?: string } | undefined;
75
82
  const total = shown?.total ?? 0;
76
83
  const checkoutUrl = shown?.checkoutUrl ?? '';
77
84
 
85
+ useEffect(() => {
86
+ if (!supports?.modelContext) return;
87
+ void updateModelContext({
88
+ content: [{ type: 'text', text: `Pickup order: ${item}, total ${total}` }],
89
+ structuredContent: { widget: { name: 'pickup-order', lifecycle: 'active' }, order: { item, total } },
90
+ });
91
+ }, [item, supports?.modelContext, total, updateModelContext]);
92
+
93
+ async function submitOrder() {
94
+ await placeOrder.callTool({ customer: shown?.customer ?? 'Guest', item });
95
+ if (supports?.modelContext) {
96
+ await publishLifecycle('submitted', { surface: 'order', item, total, status: 'submitted' });
97
+ }
98
+ if (supports?.followUpMessage) {
99
+ await sendFollowUpMessage({ prompt: `The pickup order was submitted for ${item}. Confirm the result.` });
100
+ }
101
+ }
102
+
78
103
  return (
79
- // data-llm mirrors the visible state back to the model as text context.
104
+ // data-llm is an inspection hint; useUpdateModelContext above is the model-visible channel.
80
105
  <Frame title="Pickup order" displayMode="auto" data-llm={`Pickup order for ${shown?.customer ?? 'Guest'}: ${item}, total ${total}`}>
81
106
  <Flow variant="stack">
82
107
  <AsyncBoundary state={placeOrder} loading="Placing order…" error={(error) => error.message}>
@@ -92,7 +117,7 @@ export default function OrderStatus() {
92
117
  {confirmed?.status ? <Feedback status="success">{confirmed.status}</Feedback> : null}
93
118
  <ActionBar>
94
119
  <Action variant="primary" pending={placeOrder.isPending} pendingLabel="Placing…"
95
- onClick={() => placeOrder.callTool({ customer: shown?.customer ?? 'Guest', item })}
120
+ onClick={submitOrder}
96
121
  >
97
122
  Place order
98
123
  </Action>
@@ -160,8 +185,8 @@ export default server(
160
185
  tool('place_order', {
161
186
  visibility: ['app'],
162
187
  description: 'Place a pickup order from the widget.',
163
- // A write that reaches the outside world `action()` (not read-only, not destructive).
164
- annotations: annotations.action(),
188
+ // Widget-mediated direct action: explicit false is equivalent to omitting confirmation.
189
+ annotations: annotations.action({ confirm: false }),
165
190
  input: z.object({ customer: z.string().default('Guest'), item }),
166
191
  output: z.object({ status: z.string(), item: z.string(), checkoutUrl: z.string() }),
167
192
  fulfil: ({ input }) => ({
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: noodle-seed
3
3
  description: Use when building, validating, testing, deploying, or operating a local or hosted Noodle Seed MCP server or app authored in TypeScript with the noodle CLI.
4
- version: 0.21.1
4
+ version: 0.23.0
5
5
  hash: cd25535e1d6dbf4f
6
6
  ---
7
7
 
@@ -29,7 +29,7 @@ const menuItemOutput = z.object({
29
29
  // Tool annotations for host planners: the menu read is read-only; cart edits are local writes; checkout
30
30
  // opens an external (payment) link.
31
31
  const readOnly = annotations.readOnly();
32
- const localWrite = annotations.localAction({ destructive: false });
32
+ const localWrite = annotations.localAction({ destructive: false, confirm: false });
33
33
  const openLink = annotations.openAction();
34
34
 
35
35
  export default server(
@@ -83,7 +83,7 @@ const discoverInput = z.object({
83
83
  // shortlisting is a local non-destructive write.
84
84
  const readOnly = annotations.readOnly();
85
85
  const openLink = annotations.openAction();
86
- const localWrite = annotations.localAction({ destructive: false });
86
+ const localWrite = annotations.localAction({ destructive: false, confirm: false });
87
87
 
88
88
  const destinationOutput = z.object({
89
89
  id: z.string(),
@@ -28,6 +28,8 @@ const priority = z.enum(['high', 'medium', 'low']);
28
28
  // non-destructive writes.
29
29
  const readOnly = annotations.readOnly();
30
30
  const localWrite = annotations.localAction({ destructive: false });
31
+ const confirmedWrite = annotations.localAction({ destructive: false, confirm: true });
32
+ const widgetWrite = annotations.localAction({ destructive: false, confirm: false });
31
33
 
32
34
  const taskOutput = z.object({
33
35
  id: z.string(),
@@ -104,7 +106,7 @@ export default server(
104
106
  // Flow 3 — Complete: mark a task done. Model-visible so the model can complete on request.
105
107
  tool('complete_task', {
106
108
  description: 'Mark an Acme task complete. Pass the task id and its title.',
107
- annotations: localWrite,
109
+ annotations: confirmedWrite,
108
110
  input: z.object({
109
111
  task: z.string(),
110
112
  title: z.string().min(1),
@@ -122,7 +124,7 @@ export default server(
122
124
  tool('set_priority', {
123
125
  visibility: ['app'],
124
126
  description: 'Re-prioritize a task from the list widget.',
125
- annotations: localWrite,
127
+ annotations: widgetWrite,
126
128
  input: z.object({
127
129
  task: z.string(),
128
130
  priority,
@@ -19,4 +19,13 @@ describe('acme-tasks example', () => {
19
19
  const text = JSON.stringify(await app.toManifest());
20
20
  expect(text).toMatch(/"tasks":\[\{"id":"email_vendor".*"priority":"high"/);
21
21
  });
22
+
23
+ it('opts the conversational completion action into runtime confirmation', async () => {
24
+ const manifest = await app.toManifest();
25
+ const completeTask = manifest.tools.find((candidate) => candidate.name === 'complete_task');
26
+ const addTask = manifest.tools.find((candidate) => candidate.name === 'add_task');
27
+
28
+ expect(completeTask?.annotations?.confirm).toBe(true);
29
+ expect(addTask?.annotations).not.toHaveProperty('confirm');
30
+ });
22
31
  });
@@ -0,0 +1,237 @@
1
+ # Customer Auth - NoodleSeed.com Firebase customer identity
2
+
3
+ This curated example owns the customer/end-user authentication with Firebase bridge capability slot. It is
4
+ the NoodleSeed.com dogfood app for proving that a SaaS app can protect an MCP endpoint with its own customer
5
+ identity provider while still using the generic Noodle Seed authoring API.
6
+
7
+ It also owns the embedded-assistant showcase: the same authenticated MCP surface can be dropped into the
8
+ SaaS web application as a fully customer-branded assistant with independent light and dark themes. The
9
+ assistant loads the active deployment's instructions and model-visible tools rather than installing a stale
10
+ second skill bundle. Interactive MCP Apps widgets continue to render in supported external hosts; the
11
+ initial embedded surface renders text and native tool confirmations.
12
+
13
+ The public developer entrypoint is [`src/server.ts`](src/server.ts). It declares `customerAuth.firebase(...)` with
14
+ the NoodleSeed.com Firebase project and Firebase Web App public config. It exposes a deliberately small MCP
15
+ surface for org discovery:
16
+
17
+ - `list_my_organizations` lists the NoodleSeed.com organizations the signed-in customer belongs to (no
18
+ arguments — the org set comes from the verified customer session).
19
+ - `list_org_apps` lists apps for one of those organizations through the dev app API.
20
+
21
+ The two tools chain: `list_my_organizations` surfaces the `org_id`s the customer can act on, and
22
+ `list_org_apps` takes one of those `org_id`s. There is no NoodleSeed-specific SDK helper. The downstream API
23
+ is an ordinary authored HTTP connector.
24
+
25
+ During MCP OAuth login, Noodle Cloud hosts the Firebase bridge page at
26
+ `https://cloud.noodleseed.dev/oauth/customer/firebase/authorize`. The customer app does not add an
27
+ authorization route. The SaaS operator only configures Firebase Auth to allow the Noodle Cloud origin, and
28
+ Noodle Cloud signs the customer in with Firebase before posting the Firebase ID token back to its own bridge
29
+ callback.
30
+
31
+ ## How delegated customer credentials are used
32
+
33
+ The example has two declarations that work together:
34
+
35
+ ```ts
36
+ auth: customerAuth.firebase({
37
+ projectId: variable('FIREBASE_PROJECT_ID'),
38
+ apiKey: variable('FIREBASE_WEB_API_KEY'),
39
+ authDomain: variable('FIREBASE_AUTH_DOMAIN'),
40
+ user: {
41
+ id: 'sub',
42
+ email: 'email',
43
+ name: 'name',
44
+ tenant: 'firebase.tenant',
45
+ orgs: 'claims.orgs',
46
+ roles: 'claims.roles',
47
+ },
48
+ }),
49
+ ```
50
+
51
+ That protects the MCP endpoint with the SaaS customer's Firebase identity. The connector then opts into
52
+ delegated customer credentials:
53
+
54
+ ```ts
55
+ auth: {
56
+ kind: 'delegatedSessionCookie',
57
+ provider: 'firebase',
58
+ sessionUrl: `${noodleseedApiOrigin}/api/auth/session`,
59
+ tokenField: 'idToken',
60
+ },
61
+ ```
62
+
63
+ Tool code calls the connector normally:
64
+
65
+ ```ts
66
+ fulfil({ input, connectors }) {
67
+ const apps = connectors.app_api.listOrgApps({
68
+ org_id: input.org_id,
69
+ skip: input.skip,
70
+ limit: input.limit,
71
+ });
72
+
73
+ return { result: apps.result };
74
+ }
75
+ ```
76
+
77
+ At runtime, Noodle Seed verifies the Firebase customer during MCP OAuth, stores that customer's delegated
78
+ Firebase refresh token in the credential broker, and refreshes a short-lived Firebase ID token only when a
79
+ connector-backed tool calls the NoodleSeed.com API. For this app API, the broker exchanges that ID token at
80
+ the existing Next.js `/api/auth/session` route and sends the resulting session cookie to the API. The MCP
81
+ access token remains a Noodle-issued resource-bound token and is never sent to the downstream API.
82
+
83
+ ## Delegated downstream auth for your own API (token exchange)
84
+
85
+ The Firebase path above only works for Firebase-session downstreams. When the downstream is **your own
86
+ API** with its own token issuance, use `delegatedTokenExchange` instead
87
+ ([ADR 0152](../../docs/decisions/0152-delegated-token-exchange-connector-auth.md)): the platform signs a
88
+ short-lived, JWKS-verifiable assertion of the signed-in user and exchanges it (RFC 8693) at a token
89
+ endpoint you implement, which mints your own user-scoped token — so your API enforces its own per-user
90
+ authorization on every call. It works for identities from `customerAuth.bridge(...)` and from embedded
91
+ assistant sessions, with no per-user OAuth enrollment.
92
+
93
+ ```ts
94
+ auth: {
95
+ kind: 'delegatedTokenExchange',
96
+ tokenUrl: 'https://app.example.com/api/assistant/oauth/token', // origin must be in allowedOrigins
97
+ clientId: variable('EXAMPLE_DELEG_CLIENT_ID'),
98
+ clientSecret: secret('EXAMPLE_DELEG_CLIENT_SECRET'),
99
+ scopes: ['time_off'],
100
+ },
101
+ ```
102
+
103
+ Your endpoint authenticates the broker's client credential, verifies the `subject_token` JWT against the
104
+ platform issuer JWKS (claims include the verified `sub`, `email`, `name`, declared session `claims`,
105
+ `tenant`, and `deployment`), mints a short-lived user-scoped token, and returns the standard
106
+ `{ access_token, token_type, expires_in }` response. The exact wire contract and a copyable endpoint
107
+ implementation live in [docs/spec/connectors.md](../../docs/spec/connectors.md) and the Agent Kit
108
+ authoring-workflow reference ("Delegated downstream auth"). `noodle auth doctor` reports each declared
109
+ exchange endpoint.
110
+
111
+ ## Validate
112
+
113
+ ```bash
114
+ noodle auth doctor examples/customer-auth/src/server.ts
115
+ noodle validate examples/customer-auth/src/server.ts
116
+ ```
117
+
118
+ ## Run locally
119
+
120
+ ```bash
121
+ noodle dev examples/customer-auth/src/server.ts --app noodleseed-customer-auth
122
+ ```
123
+
124
+ ## Configuration
125
+
126
+ The embedded assistant uses a customer-supplied OpenAI Chat Completions-compatible endpoint. Configure its
127
+ managed values at the Noodle deployment environment; none of these values belongs in the customer web
128
+ application environment, and the API key never reaches the browser:
129
+
130
+ ```bash
131
+ noodle variables set ASSISTANT_MODEL_BASE_URL https://model.example.com/v1 --scope env
132
+ noodle variables set ASSISTANT_MODEL your-model --scope env
133
+ noodle secrets set ASSISTANT_MODEL_API_KEY --scope env
134
+ noodle variables set FIREBASE_PROJECT_ID your-firebase-project --scope env
135
+ noodle variables set FIREBASE_WEB_API_KEY your-firebase-web-api-key --scope env
136
+ noodle variables set FIREBASE_AUTH_DOMAIN your-firebase-project.firebaseapp.com --scope env
137
+ noodle check --target embedded-assistant src/server.ts
138
+ ```
139
+
140
+ Assistant origins are exact and HTTPS-only. A local embedding application must serve itself over HTTPS and
141
+ declare an origin such as `https://localhost:3000`; `noodle dev` does not provide TLS for that separate web
142
+ application.
143
+
144
+ Create the backend credential after deployment. The CLI writes it to a mode-0600 file and never prints the
145
+ secret:
146
+
147
+ ```bash
148
+ noodle assistant clients create --name web --org noodleseed --app customer-auth --env prod
149
+ ```
150
+
151
+ Only the Noodle service URL, assistant client ID, and assistant client secret belong in the authenticated
152
+ customer backend. The model URL, model name, and model API key remain managed by the Noodle deployment.
153
+
154
+ The customer's authenticated backend calls `createAssistantSession(...)` from
155
+ `@noodleseed/assistant/server`, passing the already-verified user and browser origin. The browser then uses
156
+ the returned short-lived session through the Web Component or React wrapper:
157
+
158
+ ```bash
159
+ pnpm add @noodleseed/assistant
160
+ ```
161
+
162
+ ```tsx
163
+ import { NoodleAssistant } from '@noodleseed/assistant/react';
164
+
165
+ <NoodleAssistant
166
+ sessionEndpoint="/api/noodle-assistant/session"
167
+ theme="auto"
168
+ onSessionExpired={() => console.info('Assistant session renewed')}
169
+ />;
170
+ ```
171
+
172
+ `theme="auto"` follows the SaaS application. The server-level `branding` block is inherited by both MCP App
173
+ widgets and the assistant; documented `--ns-assistant-*` semantic CSS variables remain the final integration
174
+ escape hatch. There is no second assistant branding declaration.
175
+ The end-user UI contains only customer branding.
176
+ Text streams progressively. Expired turns re-exchange through the authenticated backend and retry once;
177
+ consent-bound tool confirmations never replay automatically.
178
+
179
+ The Firebase project ID is required because Firebase ID tokens use the project ID as the token audience and
180
+ issuer suffix. The runtime verifies `aud` against the project ID and `iss` against
181
+ `https://securetoken.google.com/<projectId>`.
182
+
183
+ The Firebase Web API key and auth domain are public Firebase browser configuration. They let the Noodle
184
+ Cloud-hosted bridge initialize Firebase Auth for this customer project; they are not server secrets. Keep
185
+ them out of source with `variable(...)`, restrict the Firebase key to the expected browser origins and APIs,
186
+ and use `secret(...)` only for credentials that must never reach a browser.
187
+
188
+ The `noodleseed_app_api` connector currently points at the NoodleSeed.com dev app surface:
189
+
190
+ ```text
191
+ https://dev.noodleseed.com
192
+ ```
193
+
194
+ When the customer app moves from `dev.noodleseed.com` to `app.noodleseed.com`, update the connector's
195
+ `noodleseedApiOrigin` constant to the production API origin that serves the same paths.
196
+
197
+ The connector uses delegated Firebase customer credentials. During the customer OAuth bridge, Noodle Seed
198
+ verifies the Firebase ID token, stores the Firebase refresh token through the credential broker, and refreshes
199
+ a Firebase ID token when the connector calls the NoodleSeed.com app API. The broker then exchanges that ID
200
+ token for the app's existing Next.js session cookie. There is no shared `NOODLESEED_APP_API_TOKEN` for this
201
+ example.
202
+
203
+ Firebase Auth must list `cloud.noodleseed.dev` as an authorized domain before browser sign-in works in
204
+ production.
205
+
206
+ ## Deploy customer-protected to Noodle Seed Cloud
207
+
208
+ ```bash
209
+ noodle deploy examples/customer-auth/src/server.ts \
210
+ --org noodleseed \
211
+ --app customer-auth \
212
+ --env prod \
213
+ --access customers
214
+ ```
215
+
216
+ Endpoint:
217
+
218
+ ```text
219
+ https://cloud.noodleseed.dev/o/noodleseed/customer-auth/mcp
220
+ ```
221
+
222
+ ## MCP Primitives
223
+
224
+ - Tool `list_my_organizations`: calls `GET /api/organizations` and returns the organizations the signed-in
225
+ customer is a member of. Takes no arguments; the org set is scoped by the verified customer session.
226
+ - Tool `list_org_apps`: calls `GET /api/organizations/{org_id}/apps` for one organization `org_id`.
227
+
228
+ ## Auth boundary
229
+
230
+ Firebase ID-token verification is handled by Noodle Seed's generic Firebase bridge adapter during OAuth
231
+ issuance. The MCP client receives a Noodle-issued, resource-bound access token marked as a Firebase customer
232
+ identity; raw Firebase tokens and inbound MCP bearer tokens are never forwarded to tools, connectors,
233
+ widgets, or downstream systems.
234
+
235
+ The connector-backed tools use the credential broker to turn the signed-in Firebase customer session into the
236
+ same session-cookie credential that the existing NoodleSeed.com Next.js API already expects. The inbound MCP
237
+ bearer token is never used as an app API credential.
@@ -0,0 +1,4 @@
1
+ {
2
+ "entrypoint": "src/server.ts",
3
+ "name": "customer-auth"
4
+ }