@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 }) => ({