@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.
- package/manifest.json +83 -33
- package/package.json +1 -1
- package/skills/claude-code/SKILL.md +1 -1
- package/skills/claude-code/examples/acme-bistro/src/server.ts +1 -1
- package/skills/claude-code/examples/acme-discovery/src/server.ts +1 -1
- package/skills/claude-code/examples/acme-tasks/src/server.ts +4 -2
- package/skills/claude-code/examples/acme-tasks/test/server.test.ts +9 -0
- package/skills/claude-code/examples/customer-auth/README.md +237 -0
- package/skills/claude-code/examples/customer-auth/noodle.json +4 -0
- package/skills/claude-code/examples/customer-auth/package.json +16 -0
- package/skills/claude-code/examples/customer-auth/src/server.ts +145 -0
- package/skills/claude-code/examples/customer-auth/test/server.test.ts +25 -0
- package/skills/claude-code/examples/food-ordering/README.md +7 -3
- package/skills/claude-code/examples/food-ordering/src/helpers.ts +2 -0
- package/skills/claude-code/examples/food-ordering/src/server.ts +47 -2
- package/skills/claude-code/examples/food-ordering/src/views/ordering-flow.tsx +56 -3
- package/skills/claude-code/examples/food-ordering/test/server.test.ts +46 -0
- package/skills/claude-code/references/authoring-workflow.md +117 -2
- package/skills/claude-code/references/compile-errors.md +6 -1
- package/skills/claude-code/references/embedded-assistant.md +95 -10
- package/skills/claude-code/references/examples.md +1 -1
- package/skills/claude-code/references/sdk-surface.md +2 -2
- package/skills/claude-code/references/widgets-and-apps.md +32 -7
- package/skills/codex/SKILL.md +1 -1
- package/skills/codex/examples/acme-bistro/src/server.ts +1 -1
- package/skills/codex/examples/acme-discovery/src/server.ts +1 -1
- package/skills/codex/examples/acme-tasks/src/server.ts +4 -2
- package/skills/codex/examples/acme-tasks/test/server.test.ts +9 -0
- package/skills/codex/examples/customer-auth/README.md +237 -0
- package/skills/codex/examples/customer-auth/noodle.json +4 -0
- package/skills/codex/examples/customer-auth/package.json +16 -0
- package/skills/codex/examples/customer-auth/src/server.ts +145 -0
- package/skills/codex/examples/customer-auth/test/server.test.ts +25 -0
- package/skills/codex/examples/food-ordering/README.md +7 -3
- package/skills/codex/examples/food-ordering/src/helpers.ts +2 -0
- package/skills/codex/examples/food-ordering/src/server.ts +47 -2
- package/skills/codex/examples/food-ordering/src/views/ordering-flow.tsx +56 -3
- package/skills/codex/examples/food-ordering/test/server.test.ts +46 -0
- package/skills/codex/references/authoring-workflow.md +117 -2
- package/skills/codex/references/compile-errors.md +6 -1
- package/skills/codex/references/embedded-assistant.md +95 -10
- package/skills/codex/references/examples.md +1 -1
- package/skills/codex/references/sdk-surface.md +2 -2
- 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,
|
|
5
|
-
CSP/permissions metadata,
|
|
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
|
|
|
@@ -174,7 +174,10 @@ type CartInput = {
|
|
|
174
174
|
};
|
|
175
175
|
|
|
176
176
|
const readOnly = annotations.readOnly();
|
|
177
|
-
|
|
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
|
-
|
|
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 `
|
|
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`.
|
|
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` (
|
|
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. |
|