@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
@@ -0,0 +1,145 @@
1
+ import {
2
+ annotations,
3
+ connector,
4
+ customerAuth,
5
+ embeddedAssistant,
6
+ openAICompatible,
7
+ secret,
8
+ server,
9
+ tool,
10
+ variable,
11
+ z,
12
+ } from '@noodleseed/one';
13
+
14
+ const noodleseedApiOrigin = 'https://dev.noodleseed.com';
15
+
16
+ const noodleseedApi = connector('noodleseed_app_api')
17
+ .version('1.0.0')
18
+ .http({
19
+ baseUrl: noodleseedApiOrigin,
20
+ allowedOrigins: [noodleseedApiOrigin],
21
+ auth: {
22
+ kind: 'delegatedSessionCookie',
23
+ provider: 'firebase',
24
+ sessionUrl: `${noodleseedApiOrigin}/api/auth/session`,
25
+ tokenField: 'idToken',
26
+ },
27
+ operations: {
28
+ list_org_apps: {
29
+ type: 'read',
30
+ method: 'GET',
31
+ path: '/api/organizations/${args.org_id}/apps',
32
+ query: ['skip', 'limit'],
33
+ input: z.object({
34
+ org_id: z.string(),
35
+ skip: z.number().optional(),
36
+ limit: z.number().optional(),
37
+ }),
38
+ output: z.object({ result: z.unknown().optional() }),
39
+ response: {
40
+ result: '${response}',
41
+ },
42
+ },
43
+ list_organizations: {
44
+ type: 'read',
45
+ method: 'GET',
46
+ path: '/api/organizations',
47
+ output: z.object({ organizations: z.array(z.unknown()).optional() }),
48
+ response: {
49
+ organizations: '${response.organizations}',
50
+ },
51
+ },
52
+ },
53
+ });
54
+
55
+ export default server(
56
+ 'noodleseed_customer_auth',
57
+ {
58
+ title: 'NoodleSeed.com Customer Auth',
59
+ version: '1.0.0',
60
+ branding: {
61
+ name: 'Noodle Seed Assistant',
62
+ accent: '#E85D24',
63
+ surface: '#FFFFFF',
64
+ surfaceDark: '#171310',
65
+ colorScheme: 'auto',
66
+ theme: {
67
+ light: { accentText: '#FFFFFF', text: '#1C1714' },
68
+ dark: { accent: '#FF8A4C', accentText: '#1C100A', text: '#FFF8F2' },
69
+ },
70
+ },
71
+ use: { app_api: noodleseedApi },
72
+ auth: customerAuth.firebase({
73
+ projectId: variable('FIREBASE_PROJECT_ID'),
74
+ apiKey: variable('FIREBASE_WEB_API_KEY'),
75
+ authDomain: variable('FIREBASE_AUTH_DOMAIN'),
76
+ user: {
77
+ id: 'sub',
78
+ email: 'email',
79
+ name: 'name',
80
+ tenant: 'firebase.tenant',
81
+ orgs: 'claims.orgs',
82
+ roles: 'claims.roles',
83
+ },
84
+ }),
85
+ instructions:
86
+ 'Customer-authenticated demo. Firebase proves the customer identity, while read-only NoodleSeed.com API calls use broker-managed delegated Firebase customer credentials.',
87
+ assistant: embeddedAssistant({
88
+ model: openAICompatible({
89
+ baseUrl: variable('ASSISTANT_MODEL_BASE_URL'),
90
+ model: variable('ASSISTANT_MODEL'),
91
+ apiKey: secret('ASSISTANT_MODEL_API_KEY'),
92
+ }),
93
+ // Production origins are exact HTTPS; http://localhost:<port> is allowed for local development.
94
+ allowedOrigins: [
95
+ 'https://app.noodleseed.com',
96
+ 'https://dev.noodleseed.com',
97
+ 'http://localhost:3000',
98
+ ],
99
+ layout: { mode: 'floating', position: 'bottom-right', panelWidth: 420 },
100
+ labels: {
101
+ welcomeHeading: 'How can I help with Noodle Seed?',
102
+ composerPlaceholder: 'Ask about your apps…',
103
+ },
104
+ suggestedPrompts: ['Show my organizations', 'List the apps in my organization'],
105
+ }),
106
+ },
107
+ [
108
+ tool('list_org_apps', {
109
+ description: 'List NoodleSeed.com apps for an organization from the dev app API.',
110
+ input: z.object({
111
+ org_id: z.string(),
112
+ skip: z.number().int().min(0).optional(),
113
+ limit: z.number().int().min(1).max(100).optional(),
114
+ }),
115
+ output: z.object({
116
+ result: z.unknown(),
117
+ }),
118
+ annotations: annotations.readOnly(),
119
+ fulfil({ input, connectors }) {
120
+ const apps = connectors.app_api.listOrgApps({
121
+ org_id: input.org_id,
122
+ skip: input.skip,
123
+ limit: input.limit,
124
+ });
125
+ return {
126
+ result: apps.result,
127
+ };
128
+ },
129
+ }),
130
+ tool('list_my_organizations', {
131
+ description: 'List the NoodleSeed.com organizations the signed-in customer belongs to.',
132
+ input: z.object({}),
133
+ output: z.object({
134
+ organizations: z.array(z.unknown()),
135
+ }),
136
+ annotations: annotations.readOnly(),
137
+ fulfil({ connectors }) {
138
+ const organizations = connectors.app_api.listOrganizations();
139
+ return {
140
+ organizations: organizations.organizations,
141
+ };
142
+ },
143
+ }),
144
+ ],
145
+ );
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import app from '../src/server.js';
3
+
4
+ describe('customer-auth example', () => {
5
+ it('exports a customer-authenticated, customer-branded embedded assistant', async () => {
6
+ expect(typeof app.toManifest).toBe('function');
7
+ const manifest = await app.toManifest();
8
+ expect(manifest.server.assistant).toMatchObject({
9
+ model: { kind: 'openai-compatible', apiKey: 'ASSISTANT_MODEL_API_KEY' },
10
+ layout: { mode: 'floating' },
11
+ });
12
+ expect(
13
+ manifest.server.assistant?.allowedOrigins.every((origin) => origin.startsWith('https://')),
14
+ ).toBe(true);
15
+ expect(manifest.server.branding).toMatchObject({
16
+ name: 'Noodle Seed Assistant',
17
+ colorScheme: 'auto',
18
+ });
19
+ expect(manifest.server.auth).toMatchObject({
20
+ projectId: '${env.FIREBASE_PROJECT_ID}',
21
+ apiKey: '${env.FIREBASE_WEB_API_KEY}',
22
+ authDomain: '${env.FIREBASE_AUTH_DOMAIN}',
23
+ });
24
+ });
25
+ });
@@ -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,
@@ -1,4 +1,4 @@
1
- import { useMemo, useState } from 'react';
1
+ import { useEffect, useMemo, useState } from 'react';
2
2
  import {
3
3
  ActionBar,
4
4
  AppShell,
@@ -19,7 +19,9 @@ import {
19
19
  useLayout,
20
20
  useSendFollowUpMessage,
21
21
  useToolInfo,
22
+ useUpdateModelContext,
22
23
  useViewState,
24
+ useWidgetLifecycle,
23
25
  View,
24
26
  ViewStack,
25
27
  } from '../helpers.js';
@@ -90,7 +92,7 @@ function modifierLabel(value: string): string {
90
92
  }
91
93
 
92
94
  export default function OrderingFlow() {
93
- const { displayMode, theme } = useLayout();
95
+ const { displayMode, supports, theme } = useLayout();
94
96
  const entry = structured<{
95
97
  readonly customer?: string;
96
98
  readonly stores?: readonly Store[];
@@ -106,6 +108,8 @@ export default function OrderingFlow() {
106
108
  const setView = flow.navigate;
107
109
  const handoff = useHandoff();
108
110
  const sendFollowUpMessage = useSendFollowUpMessage();
111
+ const updateModelContext = useUpdateModelContext();
112
+ const publishLifecycle = useWidgetLifecycle('ordering-flow');
109
113
  const searchStores = useCallTool('search_stores');
110
114
  const loadMenu = useCallTool('load_menu');
111
115
  const loadItem = useCallTool('load_item');
@@ -134,6 +138,7 @@ export default function OrderingFlow() {
134
138
  const setRevision = (value: number) => revisionStore.setState({ value });
135
139
  const [quantity, setQuantity] = useState(1);
136
140
  const [selectedModifiers, setSelectedModifiers] = useState<readonly string[]>([]);
141
+ const [lifecycle, setLifecycle] = useState<'active' | 'submitted'>('active');
137
142
 
138
143
  const storeData = structured<{ readonly stores?: readonly Store[] }>(searchStores.data);
139
144
  const menuData = structured<{
@@ -170,6 +175,37 @@ export default function OrderingFlow() {
170
175
  const llmSummary = `${view} view for ${customer}; ${cart.lines.length} cart lines; subtotal ${currency(
171
176
  cart.subtotal,
172
177
  )}`;
178
+ const canUpdateModelContext = supports?.modelContext === true;
179
+
180
+ useEffect(() => {
181
+ if (!canUpdateModelContext) return;
182
+ // Each call replaces model context, so publish one cohesive snapshot of current surface state.
183
+ void updateModelContext({
184
+ content: [{ type: 'text', text: `Food ordering: ${llmSummary}; ${lifecycle}.` }],
185
+ structuredContent: {
186
+ widget: { name: 'ordering-flow', lifecycle },
187
+ ordering: {
188
+ view,
189
+ customer,
190
+ cartLines: cart.lines.length,
191
+ subtotal: cart.subtotal,
192
+ selectedStoreId: selectedStoreId ?? null,
193
+ selectedItemId: selectedItemId ?? null,
194
+ },
195
+ },
196
+ });
197
+ }, [
198
+ canUpdateModelContext,
199
+ cart.lines.length,
200
+ cart.subtotal,
201
+ customer,
202
+ lifecycle,
203
+ llmSummary,
204
+ selectedItemId,
205
+ selectedStoreId,
206
+ updateModelContext,
207
+ view,
208
+ ]);
173
209
 
174
210
  async function chooseStore(store: Store) {
175
211
  setSelectedStoreId(store.id);
@@ -246,9 +282,26 @@ export default function OrderingFlow() {
246
282
  readonly revision?: number;
247
283
  readonly checkoutUrl?: string;
248
284
  }>(result);
249
- setCart(prepared?.cart ?? cart);
285
+ const preparedCart = prepared?.cart ?? cart;
286
+ setCart(preparedCart);
250
287
  setRevision(prepared?.revision ?? revision + 1);
288
+ setLifecycle('submitted');
251
289
  setView('handoff');
290
+ if (canUpdateModelContext) {
291
+ await publishLifecycle('submitted', {
292
+ view: 'handoff',
293
+ customer,
294
+ cartLines: preparedCart.lines.length,
295
+ subtotal: preparedCart.subtotal,
296
+ selectedStoreId: preparedCart.selectedStoreId ?? null,
297
+ selectedItemId: selectedItemId ?? null,
298
+ });
299
+ }
300
+ if (supports?.followUpMessage) {
301
+ await sendFollowUpMessage({
302
+ prompt: 'Checkout is prepared. Confirm the cart summary and explain the final handoff.',
303
+ });
304
+ }
252
305
  }
253
306
 
254
307
  return (
@@ -10,6 +10,13 @@ describe('food-ordering example', () => {
10
10
  const manifest = (await app.toManifest()) as {
11
11
  server: {
12
12
  name: string;
13
+ context?: {
14
+ defaults?: { locale?: string; timeZone?: string };
15
+ ambient?: {
16
+ outputSchema?: unknown;
17
+ fulfilment?: { output?: unknown };
18
+ };
19
+ };
13
20
  };
14
21
  handoff?: { allowedDomains?: string[] };
15
22
  state?: { handles?: Record<string, { kind: string; scope: string }> };
@@ -17,7 +24,9 @@ describe('food-ordering example', () => {
17
24
  tools: Array<{
18
25
  name: string;
19
26
  visibility?: string[];
27
+ annotations?: Record<string, unknown>;
20
28
  output?: unknown;
29
+ fulfilment?: { steps?: unknown[]; output?: unknown };
21
30
  }>;
22
31
  widgets?: Array<{
23
32
  name: string;
@@ -27,6 +36,17 @@ describe('food-ordering example', () => {
27
36
  };
28
37
 
29
38
  expect(manifest.server.name).toBe('food_ordering');
39
+ expect(manifest.server.context).toMatchObject({
40
+ defaults: { locale: 'en-US', timeZone: 'America/New_York' },
41
+ ambient: {
42
+ fulfilment: {
43
+ output: {
44
+ serviceArea: 'Harbor District',
45
+ orderingDate: '${context.temporal.localDate}',
46
+ },
47
+ },
48
+ },
49
+ });
30
50
  expect(manifest.state?.handles?.cart).toMatchObject({
31
51
  kind: 'cart',
32
52
  scope: 'caller',
@@ -52,7 +72,33 @@ describe('food-ordering example', () => {
52
72
  expect(tools.get(helper)?.visibility).toEqual(['app']);
53
73
  }
54
74
  expect(JSON.stringify(tools.get('open_ordering'))).toContain('featuredItems');
75
+ expect(JSON.stringify(tools.get('open_ordering'))).toContain('${context.temporal.localDate}');
76
+ expect(JSON.stringify(tools.get('open_ordering'))).toContain('${context.ambient.serviceArea}');
55
77
  expect(JSON.stringify(tools.get('sync_cart'))).toContain('revision');
78
+ expect(tools.get('sync_cart')?.annotations?.confirm).toBe(false);
79
+ expect(tools.get('prepare_checkout')?.annotations?.confirm).toBe(false);
80
+ expect(tools.get('plan_order')?.fulfilment).toMatchObject({
81
+ steps: [
82
+ {
83
+ id: 'choose_fulfilment',
84
+ elicit: {
85
+ message: 'How should we fulfil this order?',
86
+ requestedSchema: {
87
+ type: 'object',
88
+ properties: {
89
+ method: { type: 'string', enum: ['pickup', 'delivery'] },
90
+ requestedDate: { type: 'string', format: 'date' },
91
+ },
92
+ required: ['method', 'requestedDate'],
93
+ },
94
+ },
95
+ },
96
+ ],
97
+ output: {
98
+ method: '${steps.choose_fulfilment.method}',
99
+ requestedDate: '${steps.choose_fulfilment.requestedDate}',
100
+ },
101
+ });
56
102
  expect(manifest.widgets?.map((widget) => widget.name)).toContain('capabilities_card');
57
103
  });
58
104
  });
@@ -7,7 +7,9 @@
7
7
  - Repair loop
8
8
  - Connectors
9
9
  - HTTP connector example (full server)
10
+ - Delegated downstream auth (call your API as the signed-in user)
10
11
  - Design tools for the model
12
+ - Invocation context
11
13
  - Compute connector example
12
14
  - Tests
13
15
  - Secrets and variables
@@ -37,7 +39,7 @@ Declare connectors as data, not imperative code:
37
39
 
38
40
  Tools record connector calls into a flow; recording is not execution. Do not branch on runtime outputs with native `if` — use declarative `when(...)` conditions.
39
41
 
40
- HTTP connector auth variants: `bearer` (`{ kind: "bearer", secret: secret("API_TOKEN") }`), `apiKey` (`{ kind: "apiKey", header: "X-API-Key", secret: secret("API_KEY") }`), `clientCredentials`, `delegatedOAuth`, and `delegatedSessionCookie`. Use managed `secret(...)` / `variable(...)` refs for all values that differ by org/app/env.
42
+ HTTP connector auth variants: `bearer` (`{ kind: "bearer", secret: secret("API_TOKEN") }`), `apiKey` (`{ kind: "apiKey", header: "X-API-Key", secret: secret("API_KEY") }`), `clientCredentials`, `delegatedOAuth`, `delegatedSessionCookie`, and `delegatedTokenExchange` (per-user calls to your own API — see "Delegated downstream auth" below). Use managed `secret(...)` / `variable(...)` refs for all values that differ by org/app/env.
41
43
 
42
44
  ## HTTP connector example (full server)
43
45
 
@@ -96,7 +98,76 @@ export default server('support', { title: 'Support', version: '1.0.0', use: { cr
96
98
 
97
99
  Naming: connector operation names and tool names are lowercase-with-underscores. Map with `${args.field}` for tool/operation inputs and `${response.path}` for the response — the parsed JSON body is bound directly to `${response}`, so there is **no `.body` envelope**; use bracket syntax for array indices (`${response.data[0].id}`) — a dotted numeric index like `.0.` is invalid. Declare URL query parameters with the operation-level `query: ["arg"]` array, **not** inside `request` (which builds only the JSON body). `allowedOrigins` must be literal origin URLs (the SSRF allowlist); `baseUrl` may be a `variable(...)` that differs by env.
98
100
 
99
- More: `auth.kind` is `bearer` | `apiKey` (needs `header`) | `clientCredentials` | `delegatedOAuth` | `delegatedSessionCookie`. For client credentials use `{ kind: "clientCredentials", tokenUrl, clientId, clientSecret, scopes? }` (RFC-6749 grant); for a non-standard partner token endpoint add `profile: "custom"` with a `custom: { requestFormat, clientIdField, clientSecretField, tokenResponsePath, expirySource }` descriptor. Do not put credential headers in operation `headers`; use connector `auth`. For per-customer downstream calls use `delegatedOAuth` or `delegatedSessionCookie` with a matching `customerAuth` server option. Use `.compute(name, { input, output, run })` for a sandboxed transform; `provides:` (instead of `use:`) exposes a connector only to compute `callOperation`; and `noodle import openapi <file>` generates a connector from an OpenAPI spec.
101
+ More: `auth.kind` is `bearer` | `apiKey` (needs `header`) | `clientCredentials` | `delegatedOAuth` | `delegatedSessionCookie` | `delegatedTokenExchange`. For client credentials use `{ kind: "clientCredentials", tokenUrl, clientId, clientSecret, scopes? }` (RFC-6749 grant); for a non-standard partner token endpoint add `profile: "custom"` with a `custom: { requestFormat, clientIdField, clientSecretField, tokenResponsePath, expirySource }` descriptor. Do not put credential headers in operation `headers`; use connector `auth`. Use `.compute(name, { input, output, run })` for a sandboxed transform; `provides:` (instead of `use:`) exposes a connector only to compute `callOperation`; and `noodle import openapi <file>` generates a connector from an OpenAPI spec.
102
+
103
+ ## Delegated downstream auth (call your API as the signed-in user)
104
+
105
+ Use delegated connector auth when the downstream API must enforce its own per-user authorization — a shared service credential plus a forwarded user id would bypass it. Three shapes exist; pick by who owns the downstream:
106
+
107
+ - **`delegatedTokenExchange`** — your own API. The platform signs a short-lived, verifiable assertion of the signed-in user and exchanges it at a token endpoint you implement (RFC 8693). Works with `customerAuth.bridge(...)` identities and embedded-assistant sessions; no per-user OAuth enrollment.
108
+ - **`delegatedOAuth` with `provider: "firebase" | "microsoft"`** — Noodle-managed bridge providers using stored per-user refresh tokens. Requires the matching `customerAuth` bridge; any other provider string is the compile error `unsupported_delegated_provider`.
109
+ - **`delegatedSessionCookie`** — Firebase-managed session-cookie apps only; not a generic mechanism.
110
+
111
+ ### The connector (your `server.ts`)
112
+
113
+ ```ts
114
+ auth: {
115
+ kind: 'delegatedTokenExchange',
116
+ tokenUrl: 'https://app.example.com/api/assistant/oauth/token', // origin must be in allowedOrigins
117
+ clientId: variable('EXAMPLE_DELEG_CLIENT_ID'),
118
+ clientSecret: secret('EXAMPLE_DELEG_CLIENT_SECRET'),
119
+ scopes: ['time_off'], // optional
120
+ audience: 'example-api', // optional; assertion + request audience, defaults to tokenUrl
121
+ authMethod: 'client_secret_basic', // default; client_secret_post supported
122
+ }
123
+ ```
124
+
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.
126
+
127
+ ### The exchange request your endpoint receives
128
+
129
+ The broker POSTs `application/x-www-form-urlencoded` to `tokenUrl` with `Authorization: Basic base64(clientId:clientSecret)` (or `client_id`/`client_secret` form fields for `client_secret_post`):
130
+
131
+ ```
132
+ grant_type=urn:ietf:params:oauth:grant-type:token-exchange
133
+ subject_token=<RS256 JWT signed by the platform>
134
+ subject_token_type=urn:ietf:params:oauth:token-type:jwt
135
+ scope=time_off (space-joined, when configured)
136
+ audience=example-api (when configured)
137
+ ```
138
+
139
+ The `subject_token` claims: `iss` (platform issuer; JWKS at `{iss}/.well-known/jwks.json`), `sub` (verified user id), `aud` (your configured audience or the tokenUrl), `email`, `name`, `claims` (declared session claims), `tenant` (`org/app/env`), `deployment`, `iat`, `exp` (about 120 s), `jti`. Respond with `{ "access_token": "...", "token_type": "Bearer", "expires_in": 900 }`; the broker caches per user + connector + scopes until `expires_in` minus 300 s and presents the token downstream as `Authorization: Bearer`.
140
+
141
+ ### The downstream token endpoint (your backend)
142
+
143
+ ```ts
144
+ // POST /api/assistant/oauth/token — Node example with jose.
145
+ import { createRemoteJWKSet, jwtVerify } from 'jose';
146
+
147
+ const PLATFORM_ISSUER = process.env.NOODLE_PLATFORM_ISSUER!; // e.g. https://cloud.noodleseed.dev
148
+ const jwks = createRemoteJWKSet(new URL(`${PLATFORM_ISSUER}/.well-known/jwks.json`));
149
+
150
+ export async function tokenEndpoint(req: Request): Promise<Response> {
151
+ // 1. Authenticate the broker client credential (client_secret_basic).
152
+ const basic = req.headers.get('authorization') ?? '';
153
+ const [clientId, clientSecret] = atob(basic.replace(/^Basic /, '')).split(':');
154
+ if (!isValidClient(clientId, clientSecret)) return new Response(null, { status: 401 });
155
+ // 2. Verify the platform-signed user assertion (never trust a plaintext user id).
156
+ const form = new URLSearchParams(await req.text());
157
+ const { payload } = await jwtVerify(form.get('subject_token') ?? '', jwks, {
158
+ issuer: PLATFORM_ISSUER,
159
+ audience: 'https://app.example.com/api/assistant/oauth/token', // your tokenUrl or configured audience
160
+ });
161
+ if (payload.deployment !== undefined && payload.tenant !== 'your-org/your-app/prod') {
162
+ return new Response(null, { status: 403 }); // optionally pin the calling deployment
163
+ }
164
+ // 3. Mint your own short-lived user-scoped token; your API enforces per-user rules from it.
165
+ const accessToken = await issueAccessToken(String(payload.sub), clientId, form.get('scope') ?? '');
166
+ return Response.json({ access_token: accessToken, token_type: "Bearer", expires_in: 900 });
167
+ }
168
+ ```
169
+
170
+ Diagnose with `noodle auth doctor` (reports each delegated token exchange endpoint and whether managed delegated providers pair with the declared `customerAuth`). Common failures: `disallowed_token_origin` (add the tokenUrl origin to `allowedOrigins`), `unsupported_delegated_provider` (custom provider strings never reach a broker; use `delegatedTokenExchange`), and `delegated token exchange requires a verified customer caller` at runtime (the surface calling the tool has no verified customer identity — check `customerAuth` and, for embeds, `createAssistantSession({ user })`).
100
171
 
101
172
  ## Design tools for the model
102
173
 
@@ -160,6 +231,50 @@ export default server('todo', { title: 'Tasks', version: '1.0.0', use: { tasks }
160
231
 
161
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.
162
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
+
163
278
  ## Compute connector example
164
279
 
165
280
  ```ts
@@ -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. |