@noodleseed/agent-kit 0.22.0 → 0.23.1

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 (36) hide show
  1. package/manifest.json +35 -35
  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/food-ordering/README.md +7 -3
  9. package/skills/claude-code/examples/food-ordering/src/helpers.ts +2 -0
  10. package/skills/claude-code/examples/food-ordering/src/server.ts +47 -2
  11. package/skills/claude-code/examples/food-ordering/src/views/ordering-flow.tsx +56 -3
  12. package/skills/claude-code/examples/food-ordering/test/server.test.ts +46 -0
  13. package/skills/claude-code/references/authoring-workflow.md +46 -1
  14. package/skills/claude-code/references/cli-commands.md +1 -0
  15. package/skills/claude-code/references/compile-errors.md +6 -1
  16. package/skills/claude-code/references/deploy-and-ops.md +5 -0
  17. package/skills/claude-code/references/embedded-assistant.md +90 -9
  18. package/skills/claude-code/references/sdk-surface.md +2 -2
  19. package/skills/claude-code/references/widgets-and-apps.md +32 -7
  20. package/skills/codex/SKILL.md +1 -1
  21. package/skills/codex/examples/acme-bistro/src/server.ts +1 -1
  22. package/skills/codex/examples/acme-discovery/src/server.ts +1 -1
  23. package/skills/codex/examples/acme-tasks/src/server.ts +4 -2
  24. package/skills/codex/examples/acme-tasks/test/server.test.ts +9 -0
  25. package/skills/codex/examples/food-ordering/README.md +7 -3
  26. package/skills/codex/examples/food-ordering/src/helpers.ts +2 -0
  27. package/skills/codex/examples/food-ordering/src/server.ts +47 -2
  28. package/skills/codex/examples/food-ordering/src/views/ordering-flow.tsx +56 -3
  29. package/skills/codex/examples/food-ordering/test/server.test.ts +46 -0
  30. package/skills/codex/references/authoring-workflow.md +46 -1
  31. package/skills/codex/references/cli-commands.md +1 -0
  32. package/skills/codex/references/compile-errors.md +6 -1
  33. package/skills/codex/references/deploy-and-ops.md +5 -0
  34. package/skills/codex/references/embedded-assistant.md +90 -9
  35. package/skills/codex/references/sdk-surface.md +2 -2
  36. package/skills/codex/references/widgets-and-apps.md +32 -7
@@ -9,6 +9,7 @@
9
9
  - HTTP connector example (full server)
10
10
  - Delegated downstream auth (call your API as the signed-in user)
11
11
  - Design tools for the model
12
+ - Invocation context
12
13
  - Compute connector example
13
14
  - Tests
14
15
  - Secrets and variables
@@ -121,7 +122,7 @@ auth: {
121
122
  }
122
123
  ```
123
124
 
124
- Inside tools, `${user.id}` / `${user.email}` / `${user.name}` / `${user.claims.*}` stay available as verified context; the delegated credential is what makes the *downstream call itself* run as that user.
125
+ Inside tools, `${user.subject}` / `${user.email}` / `${user.name}` / `${user.locale}` / `${user.timeZone}` / `${user.claims.*}` stay available as verified context; the delegated credential is what makes the *downstream call itself* run as that user.
125
126
 
126
127
  ### The exchange request your endpoint receives
127
128
 
@@ -230,6 +231,50 @@ export default server('todo', { title: 'Tasks', version: '1.0.0', use: { tasks }
230
231
 
231
232
  The model never sees a task id from the user; `find_tasks` returns `{ id, title }` summaries it can pick from, then `complete_task` acts by id. Keep write actions (`complete_task`) separate and explicitly described so the host can gate them.
232
233
 
234
+ ## Invocation context
235
+
236
+ Every executable invocation receives one immutable server-authoritative temporal snapshot. Canonical TypeScript `server()` authoring emits an empty context declaration even when you omit the option, so MCP clients get the reserved read-only `noodle_context` temporal tool with zero setup. Use `server(..., { context })` only to add locale/time-zone defaults and trusted ambient facts that every surface should resolve the same way. Tools, resources, prompts, and the embedded assistant consume the same snapshot; do not create a host-specific date tool.
237
+
238
+ ```ts
239
+ context: {
240
+ defaults: { locale: 'en-GB', timeZone: 'Europe/London' },
241
+ ambient: {
242
+ output: z.object({ defaultTeamId: z.string(), holidays: z.array(z.string()) }),
243
+ fulfil: ({ user, context, connectors }) => {
244
+ const calendar = connectors.people.getCalendar({
245
+ subject: user.subject,
246
+ asOf: context.temporal.instant,
247
+ });
248
+ return { defaultTeamId: calendar.default_team_id, holidays: calendar.holidays };
249
+ },
250
+ },
251
+ },
252
+ ```
253
+
254
+ Ambient providers are recorded as fulfilment data at author time, may call read-only connector operations only, and have a declared output schema. Later fulfilments read `${context.temporal.localDate}`, `${context.temporal.timeZone}`, `${context.ambient.defaultTeamId}`, and `${context.ambientStatus}`. If ambient resolution fails, the status is `unavailable`; never invent the missing business facts. TypeScript-authored servers always reserve `noodle_context`; only a raw Core-v1 manifest that omits `server.context` retains that tool name. Ambient/model-visible context is capped at 16 KiB serialized JSON, depth 8, and 128 entries per container; credential-shaped keys are rejected.
255
+
256
+ ## Ask for structured missing input
257
+
258
+ Use `ctx.elicit` inside a tool fulfilment when execution needs one bounded value from the user. The call records an `elicit` flow step and returns its symbolic scope; it does not prompt at author time:
259
+
260
+ ```ts
261
+ tool('prepare_time_off', {
262
+ description: 'Resolve a time-off request before proposing the write.',
263
+ input: z.object({ start: z.string(), end: z.string() }),
264
+ output: z.object({ start: z.string(), end: z.string(), teamId: z.string() }),
265
+ fulfil: ({ input, elicit }) => {
266
+ const answer = elicit({
267
+ id: 'choose_team',
268
+ message: 'Which team should receive this request?',
269
+ input: z.object({ teamId: z.string().describe('Team') }),
270
+ });
271
+ return { start: input.start, end: input.end, teamId: answer.teamId };
272
+ },
273
+ });
274
+ ```
275
+
276
+ Use a stable lowercase/number/underscore id and a flat form of string/number/integer/boolean, string choices or multi-select, with optional `email`, `uri`, `date`, or `date-time` formats. Nested objects and credential-shaped fields fail with `invalid_elicitation_schema`. Every interactive flow must place all `ctx.elicit` calls before its first connector operation or compilation fails with `invalid_elicitation_flow`. Embedded/headless clients receive `input_requested`; bidirectional MCP transports map the primitive to standard form `elicitation/create`. An adapter that cannot carry the request fails before executing the tool. Accept validates and resumes without rerunning completed steps; invalid assistant content returns `arg_invalid` with the same interaction still pending for correction; decline/cancel stop. Elicitation gathers missing input and does not replace confirmation for a subsequent write. In a flow marked `confirm: true`, every eligible `input_requested` precedes `tool_proposed`; the final proposal reviews original tool input, elicited values, and the sole exact connector version/operation/resolved arguments. Accept is bound to that action and only then may execution start. A confirmable flow may contain at most one connector operation or compilation fails with `invalid_confirmation_flow`. MCP uses a final standard form-elicitation confirmation on capable bidirectional transports and fails closed otherwise. At the manifest/runtime boundary and in TypeScript action helpers, omitted or `false` executes directly; action, destructive, and open-world hints alone never gate execution. `annotations.action({ confirm: false })` is equivalent to omission, while `annotations.action({ confirm: true })` explicitly enables confirmation.
277
+
233
278
  ## Compute connector example
234
279
 
235
280
  ```ts
@@ -86,6 +86,7 @@ Every `noodle` command, grouped by area. Local authoring commands (`validate`, `
86
86
  | Command | What it does |
87
87
  | :-- | :-- |
88
88
  | `noodle audit` | Operator governance audit status and event queries. |
89
+ | `noodle billing` | Super-admin preview of the explicit legacy billing-account migration without writing data (`billing migration preview`). |
89
90
  | `noodle logs` | View service/deployment logs. |
90
91
  | `noodle metrics` | MCP analytics for a deployed server (volume, sessions, latency percentiles, two-tier errors, tools, clients). Agents: `noodle metrics --agent-output` for a health verdict + next actions. |
91
92
  | `noodle events` | The per-request MCP event stream with status/tool/client filters; `--session <id>` replays one session in order. Agents: add `--json` and filter (`--status tool_error\|mcp_error`) when debugging. |
@@ -17,8 +17,9 @@ Run `noodle validate` (add `--json` for the machine-readable envelope, `--fix-pr
17
17
  | `invalid_shape` | A field has the wrong type or structure; match the shape the compiler reports under `path` against the SDK builder you used. |
18
18
  | `invalid_name` | Rename the identifier to match the allowed pattern (lowercase, no spaces/reserved characters) cited at `path`. |
19
19
  | `duplicate_name` | Two tools/components share a name; give each a unique name at the cited `path`. |
20
+ | `reserved_name` | Rename the tool at `path`; TypeScript `server()` always reserves `noodle_context`, while raw manifests reserve it when `server.context` is present. |
20
21
  | `unsupported_manifest_version` | Update the SDK/CLI so the emitted manifest version is supported; do not pin an old manifest shape. |
21
- | `reserved_for_future_version` | The verb at `path` (e.g. `compute`, `elicit`) is reserved for a future core version; express the step with `use` (a connector operation) or `map` (a pure mapping) instead. |
22
+ | `reserved_for_future_version` | The verb at `path` (currently `compute` as a flow step) is reserved for a future core version; express the step with `use` (a connector operation), `map` (a pure mapping), or the shipped `ctx.elicit` input primitive instead. |
22
23
  | `invalid_operation_ref` | Fix the connector operation reference to `alias.operation` for an operation that exists on that connector. |
23
24
  | `external_ref` | Remove the external/remote `$ref`; schemas must be self-contained — inline the definition instead of dereferencing a URL. |
24
25
  | `invalid_schema_ref` | Correct the `$use` schema reference syntax at `path`; it does not name a resolvable local schema. |
@@ -34,7 +35,11 @@ Run `noodle validate` (add `--json` for the machine-readable envelope, `--fix-pr
34
35
  | `self_step_ref` | A step references its own output; remove the self-reference. |
35
36
  | `duplicate_step_id` | Two recorded steps share an id; the recorder derives ids from calls — restructure so each connector call is distinct. |
36
37
  | `invalid_fulfilment` | The `fulfil` function records something the compiler cannot model (e.g. branching on a runtime value); record a linear sequence of connector calls and use declarative conditions. |
38
+ | `invalid_elicitation_schema` | Make the requested input a flat object of supported string/number/boolean/enum fields with no credential-shaped keys, and keep `required` names aligned with declared properties. |
39
+ | `invalid_elicitation_flow` | Move every `ctx.elicit` before the first connector operation in the flow, so suspension cannot strand an already-applied side effect. |
40
+ | `invalid_confirmation_flow` | Limit a tool marked `confirm: true` to one connector operation, or split the workflow so its complete resolved action can be reviewed and bound. |
37
41
  | `arg_type_mismatch` | A connector call argument has the wrong type; match the operation input type shown under `expected`/`got`. |
42
+ | `ambient_context_action` | Replace the ambient provider call at `path` with a read-only connector operation; per-invocation context resolution must not cause side effects. |
38
43
  | `duplicate_resource` | Two resources share an identity; give each `resource(...)` a unique name. |
39
44
  | `duplicate_prompt` | Two prompts share a name; rename one `prompt(...)`. |
40
45
  | `duplicate_resource_uri` | Two resources resolve to the same URI; make each resource URI unique. |
@@ -59,6 +59,10 @@ Once deployed, register the server as a tool in a host with `noodle connect <hos
59
59
 
60
60
  Manage runtime config with `noodle secrets` / `noodle variables` (scoped org/app/env). Operators use `noodle logs`, `noodle audit`, and `noodle policy` for logs, governance audit, and policy.
61
61
 
62
+ ## Billing migration preview
63
+
64
+ `noodle billing migration preview [--file <mapping.json>]` is a super-admin, read-only inventory and validation command for legacy organizations. It never creates billing accounts, links organizations, or changes entitlements, and there is no apply command. Keep real mapping files outside the repository. The versioned file must classify production apps for every organization: `linkState: "unlinked"` selects a current owner subject under the fixed hosted issuer `https://accounts.google.com`, while `linkState: "linked"` asserts the exact current billing-account ID and link version. Inventory apps with `noodle apps list --archived --json` so archive state is visible. A blocked preview exits 1 even though the JSON response is a successful preview envelope; inspect `data.preview.blockers` and resolve every blocker before a future cutover workflow exists.
65
+
62
66
  ## Agent-safe CLI recipes
63
67
 
64
68
  Use explicit flags in headless runs so commands never wait for a prompt:
@@ -74,6 +78,7 @@ noodle validate --json
74
78
  noodle test --json
75
79
  noodle deploy --json
76
80
  noodle smoke --json
81
+ noodle billing migration preview --json
77
82
  noodle agents doctor --json
78
83
  ```
79
84
 
@@ -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,17 +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.
158
190
 
159
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").
160
192
 
161
193
  ## The session response
162
194
 
163
- 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.
164
196
 
165
- ## Mount the browser component
197
+ ## Choose a browser renderer
166
198
 
167
199
  Use the React wrapper in React applications:
168
200
 
@@ -176,19 +208,65 @@ Or import the package root once and mount `<noodle-assistant session-endpoint="/
176
208
 
177
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`.
178
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
+
179
256
  ## Toolchain requirements
180
257
 
181
258
  - Node.js 20+ for `@noodleseed/assistant/server`.
182
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.
183
- - 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.
184
261
 
185
262
  ## Verify the boundary
186
263
 
187
264
  - Signed-out session exchange returns `401`.
188
265
  - The browser network/DOM/storage contains no client secret or model key.
189
266
  - The local and production origins match `allowedOrigins` character-for-character.
190
- - 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.
191
- - 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.
192
270
  - Wrong-origin and malformed-origin requests fail closed.
193
271
 
194
272
  ## Troubleshooting: symptom to diagnosis
@@ -203,10 +281,13 @@ The component renders a custom element and must mount client-side. In a Next.js
203
281
  | Session exchange returns 404 | `serviceUrl` points at the deployment MCP endpoint | Use the control-plane service URL printed by `noodle assistant clients create` |
204
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 |
205
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`) |
206
- | 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 |
207
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 |
208
286
  | `${user.name}` is empty | Backend did not pass `user.name` to `createAssistantSession` | Pass the verified name from the authenticated backend session |
209
287
  | The model does not know a claim you passed | Claim is tools-only | Mark it `exposeToModel: true` in `sessionClaims` |
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 |
210
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 |
211
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 |
212
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) |
@@ -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.22.0
4
+ version: 0.23.1
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
  });
@@ -1,8 +1,9 @@
1
1
  # Food Ordering
2
2
 
3
3
  **Owns:** The flagship consumer ordering MCP App example: React view authoring, app-only helper tools,
4
- caller-scoped cart state handles, packaged image assets, checkout handoff policy, host actions,
5
- CSP/permissions metadata, and widget preview coverage.
4
+ caller-scoped cart state handles, invocation context, model-visible widget state/lifecycle, packaged image
5
+ assets, portable structured elicitation, checkout handoff policy, host actions, CSP/permissions metadata,
6
+ and widget preview coverage.
6
7
 
7
8
  Food Ordering is a generic, synthetic version of a live marketplace ordering app. It lets a user search
8
9
  stores, browse menus, customize an item, build a multi-line cart, review the order, and hand off checkout to
@@ -14,10 +15,13 @@ private customer data.
14
15
  | Capability | Example |
15
16
  | :--- | :--- |
16
17
  | Public entry tool | `open_ordering` returns structured fallback content and renders the React widget |
17
- | App-only helper tools | `search_stores`, `load_menu`, `load_item`, `read_cart`, `sync_cart`, `prepare_checkout` |
18
+ | App-only helper tools | `search_stores`, `load_menu`, `load_item`, `read_cart`, `sync_cart`, `prepare_checkout`; mutating widget-owned helpers use `confirm: false` (equivalent to omission) and execute directly because action hints alone never gate |
18
19
  | Durable cart state | `server(..., { state: { handles: { cart } }, use: { state } })` with caller scope and revision checks |
19
20
  | React app runtime kit | `@noodleseed/one/react` supplies app flow, shell/nav/view, async state, form, quantity, choice, and handoff primitives |
20
21
  | Multi-step widget flow | One React shell navigates stores, menu, item customization, cart, review, and handoff views through `useAppFlow` |
22
+ | Invocation context | `server.context` sets locale/time-zone defaults, derives an ambient service area/date, and makes the same snapshot available to tools and the reserved `noodle_context` MCP adapter |
23
+ | Structured missing input | `plan_order` uses `ctx.elicit` to collect a fulfilment method and date in embedded/headless renderers and bidirectional MCP adapters; the stateless hosted MCP endpoint does not yet carry the server-initiated exchange |
24
+ | Model-visible widget state | `useUpdateModelContext` publishes one cohesive replacement snapshot when supported; `useWidgetLifecycle` auto-publishes mounted/cancelled/dismissed and reports author-owned submitted milestones for future context (not host-presentation proof), while the user-triggered submit pairs `useSendFollowUpMessage` for an immediate reply |
21
25
  | Handoff | `handoff.allowedDomains` allows only `https://orders.example.com` checkout URLs |
22
26
  | Progressive enhancement | Non-Apps hosts still receive stores, featured items, and a readable fallback summary |
23
27
 
@@ -30,5 +30,7 @@ export const {
30
30
  useOpenExternal,
31
31
  useSendFollowUpMessage,
32
32
  useToolInfo,
33
+ useUpdateModelContext,
33
34
  useViewState,
35
+ useWidgetLifecycle,
34
36
  } = generateHelpers<AppType>();
@@ -174,7 +174,10 @@ type CartInput = {
174
174
  };
175
175
 
176
176
  const readOnly = annotations.readOnly();
177
- const action = annotations.openAction({ destructive: false });
177
+ // These writes are app-only controls inside the cart widget. The widget already presents the
178
+ // reviewed state and explicit button; `confirm: false` documents direct execution and is equivalent
179
+ // to omission because action/open-world hints alone never enable the confirmation gate.
180
+ const action = annotations.openAction({ destructive: false, confirm: false });
178
181
 
179
182
  function checkoutUrl(customer: string): string {
180
183
  return `https://orders.example.com/checkout?customer=${encodeURIComponent(customer)}`;
@@ -198,6 +201,16 @@ export default server(
198
201
  title: 'Food Ordering',
199
202
  version: '1.0.0',
200
203
  use: { state },
204
+ context: {
205
+ defaults: { locale: 'en-US', timeZone: 'America/New_York' },
206
+ ambient: {
207
+ output: z.object({ serviceArea: z.string(), orderingDate: z.string() }),
208
+ fulfil: ({ context }) => ({
209
+ serviceArea: 'Harbor District',
210
+ orderingDate: context.temporal.localDate,
211
+ }),
212
+ },
213
+ },
201
214
  state: {
202
215
  handles: {
203
216
  cart: {
@@ -239,13 +252,17 @@ export default server(
239
252
  customer: z.string(),
240
253
  stores: z.array(storeShape),
241
254
  featuredItems: z.array(menuItemShape),
255
+ localDate: z.string(),
256
+ serviceArea: z.string(),
242
257
  fallback: z.string(),
243
258
  }),
244
- fulfil: ({ input }) => ({
259
+ fulfil: ({ input, context }) => ({
245
260
  status: 'Ready to build a food order.',
246
261
  customer: input.customer,
247
262
  stores,
248
263
  featuredItems: menu,
264
+ localDate: context.temporal.localDate,
265
+ serviceArea: context.ambient.serviceArea,
249
266
  fallback: 'Open stores: Harbor Noodles (Noodles), Garden Wraps (Vegetarian).',
250
267
  }),
251
268
  viewTitle: 'Food ordering',
@@ -363,6 +380,34 @@ export default server(
363
380
  }),
364
381
  fulfil: () => ({ stores, featuredItems: menu }),
365
382
  }),
383
+ tool('plan_order', {
384
+ description:
385
+ 'Collect a fulfilment method and requested date as structured input, then return a reviewable order plan without placing an order.',
386
+ annotations: readOnly,
387
+ input: z.object({ customer: z.string().default('Guest') }),
388
+ output: z.object({
389
+ customer: z.string(),
390
+ method: z.enum(['pickup', 'delivery']),
391
+ requestedDate: z.string(),
392
+ serviceArea: z.string(),
393
+ }),
394
+ fulfil: ({ input, context, elicit }) => {
395
+ const preference = elicit({
396
+ id: 'choose_fulfilment',
397
+ message: 'How should we fulfil this order?',
398
+ input: z.object({
399
+ method: z.enum(['pickup', 'delivery']).describe('Fulfilment method'),
400
+ requestedDate: z.string().describe('Requested date').meta({ format: 'date' }),
401
+ }),
402
+ });
403
+ return {
404
+ customer: input.customer,
405
+ method: preference.method,
406
+ requestedDate: preference.requestedDate,
407
+ serviceArea: context.ambient.serviceArea,
408
+ };
409
+ },
410
+ }),
366
411
  tool('show_capabilities', {
367
412
  description: 'Return a concise summary for the standalone widget capability preview.',
368
413
  annotations: readOnly,