@noodleseed/agent-kit 0.14.0 → 0.16.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 +549 -9
- package/package.json +1 -1
- package/skills/claude-code/SKILL.md +6 -4
- package/skills/claude-code/examples/acme-bistro/README.md +51 -0
- package/skills/claude-code/examples/acme-bistro/design/UX-Document.md +435 -0
- package/skills/claude-code/examples/acme-bistro/design/api-contract.md +161 -0
- package/skills/claude-code/examples/acme-bistro/design/wireframe.html +573 -0
- package/skills/claude-code/examples/acme-bistro/noodle.json +5 -0
- package/skills/claude-code/examples/acme-bistro/package.json +20 -0
- package/skills/claude-code/examples/acme-bistro/src/helpers.ts +7 -0
- package/skills/claude-code/examples/acme-bistro/src/server.ts +149 -0
- package/skills/claude-code/examples/acme-bistro/src/views/menu-cart.tsx +159 -0
- package/skills/claude-code/examples/acme-bistro/src/views/widget-style.css +228 -0
- package/skills/claude-code/examples/acme-bistro/test/server.test.ts +21 -0
- package/skills/claude-code/examples/acme-bistro/vitest.config.ts +7 -0
- package/skills/claude-code/examples/acme-discovery/README.md +85 -0
- package/skills/claude-code/examples/acme-discovery/design/UX-Document.md +441 -0
- package/skills/claude-code/examples/acme-discovery/design/wireframe.html +670 -0
- package/skills/claude-code/examples/acme-discovery/noodle.json +5 -0
- package/skills/claude-code/examples/acme-discovery/package.json +20 -0
- package/skills/claude-code/examples/acme-discovery/src/helpers.ts +7 -0
- package/skills/claude-code/examples/acme-discovery/src/server.ts +202 -0
- package/skills/claude-code/examples/acme-discovery/src/views/discovery-carousel.tsx +155 -0
- package/skills/claude-code/examples/acme-discovery/src/views/widget-style.css +207 -0
- package/skills/claude-code/examples/acme-discovery/test/server.test.ts +24 -0
- package/skills/claude-code/examples/acme-discovery/vitest.config.ts +7 -0
- package/skills/claude-code/examples/acme-tasks/README.md +80 -0
- package/skills/claude-code/examples/acme-tasks/design/UX-Document.md +277 -0
- package/skills/claude-code/examples/acme-tasks/design/wireframe.html +603 -0
- package/skills/claude-code/examples/acme-tasks/noodle.json +5 -0
- package/skills/claude-code/examples/acme-tasks/package.json +20 -0
- package/skills/claude-code/examples/acme-tasks/src/helpers.ts +6 -0
- package/skills/claude-code/examples/acme-tasks/src/server.ts +141 -0
- package/skills/claude-code/examples/acme-tasks/src/views/task-list.tsx +177 -0
- package/skills/claude-code/examples/acme-tasks/src/views/widget-style.css +227 -0
- package/skills/claude-code/examples/acme-tasks/test/server.test.ts +22 -0
- package/skills/claude-code/examples/acme-tasks/vitest.config.ts +7 -0
- package/skills/claude-code/examples/food-ordering/README.md +82 -0
- package/skills/claude-code/examples/food-ordering/noodle.json +5 -0
- package/skills/claude-code/examples/food-ordering/package.json +22 -0
- package/skills/claude-code/examples/food-ordering/src/helpers.ts +34 -0
- package/skills/claude-code/examples/food-ordering/src/server.ts +412 -0
- package/skills/claude-code/examples/food-ordering/src/views/capabilities-card.tsx +155 -0
- package/skills/claude-code/examples/food-ordering/src/views/ordering-flow.tsx +659 -0
- package/skills/claude-code/examples/food-ordering/src/views/widget-style.css +682 -0
- package/skills/claude-code/examples/food-ordering/test/server.test.ts +58 -0
- package/skills/claude-code/examples/food-ordering/vitest.config.ts +30 -0
- package/skills/claude-code/examples/hello/README.md +13 -0
- package/skills/claude-code/examples/hello/noodle.json +5 -0
- package/skills/claude-code/examples/hello/package.json +16 -0
- package/skills/claude-code/examples/hello/src/server.ts +29 -0
- package/skills/claude-code/examples/hello/test/server.test.ts +8 -0
- package/skills/claude-code/examples/weather/README.md +85 -0
- package/skills/claude-code/examples/weather/noodle.json +4 -0
- package/skills/claude-code/examples/weather/package.json +16 -0
- package/skills/claude-code/examples/weather/src/server.ts +261 -0
- package/skills/claude-code/examples/weather/test/server.test.ts +29 -0
- package/skills/claude-code/references/connect-an-api.md +63 -1
- package/skills/claude-code/references/examples.md +16 -7
- package/skills/claude-code/references/troubleshooting.md +2 -0
- package/skills/codex/SKILL.md +6 -4
- package/skills/codex/examples/acme-bistro/README.md +51 -0
- package/skills/codex/examples/acme-bistro/design/UX-Document.md +435 -0
- package/skills/codex/examples/acme-bistro/design/api-contract.md +161 -0
- package/skills/codex/examples/acme-bistro/design/wireframe.html +573 -0
- package/skills/codex/examples/acme-bistro/noodle.json +5 -0
- package/skills/codex/examples/acme-bistro/package.json +20 -0
- package/skills/codex/examples/acme-bistro/src/helpers.ts +7 -0
- package/skills/codex/examples/acme-bistro/src/server.ts +149 -0
- package/skills/codex/examples/acme-bistro/src/views/menu-cart.tsx +159 -0
- package/skills/codex/examples/acme-bistro/src/views/widget-style.css +228 -0
- package/skills/codex/examples/acme-bistro/test/server.test.ts +21 -0
- package/skills/codex/examples/acme-bistro/vitest.config.ts +7 -0
- package/skills/codex/examples/acme-discovery/README.md +85 -0
- package/skills/codex/examples/acme-discovery/design/UX-Document.md +441 -0
- package/skills/codex/examples/acme-discovery/design/wireframe.html +670 -0
- package/skills/codex/examples/acme-discovery/noodle.json +5 -0
- package/skills/codex/examples/acme-discovery/package.json +20 -0
- package/skills/codex/examples/acme-discovery/src/helpers.ts +7 -0
- package/skills/codex/examples/acme-discovery/src/server.ts +202 -0
- package/skills/codex/examples/acme-discovery/src/views/discovery-carousel.tsx +155 -0
- package/skills/codex/examples/acme-discovery/src/views/widget-style.css +207 -0
- package/skills/codex/examples/acme-discovery/test/server.test.ts +24 -0
- package/skills/codex/examples/acme-discovery/vitest.config.ts +7 -0
- package/skills/codex/examples/acme-tasks/README.md +80 -0
- package/skills/codex/examples/acme-tasks/design/UX-Document.md +277 -0
- package/skills/codex/examples/acme-tasks/design/wireframe.html +603 -0
- package/skills/codex/examples/acme-tasks/noodle.json +5 -0
- package/skills/codex/examples/acme-tasks/package.json +20 -0
- package/skills/codex/examples/acme-tasks/src/helpers.ts +6 -0
- package/skills/codex/examples/acme-tasks/src/server.ts +141 -0
- package/skills/codex/examples/acme-tasks/src/views/task-list.tsx +177 -0
- package/skills/codex/examples/acme-tasks/src/views/widget-style.css +227 -0
- package/skills/codex/examples/acme-tasks/test/server.test.ts +22 -0
- package/skills/codex/examples/acme-tasks/vitest.config.ts +7 -0
- package/skills/codex/examples/food-ordering/README.md +82 -0
- package/skills/codex/examples/food-ordering/noodle.json +5 -0
- package/skills/codex/examples/food-ordering/package.json +22 -0
- package/skills/codex/examples/food-ordering/src/helpers.ts +34 -0
- package/skills/codex/examples/food-ordering/src/server.ts +412 -0
- package/skills/codex/examples/food-ordering/src/views/capabilities-card.tsx +155 -0
- package/skills/codex/examples/food-ordering/src/views/ordering-flow.tsx +659 -0
- package/skills/codex/examples/food-ordering/src/views/widget-style.css +682 -0
- package/skills/codex/examples/food-ordering/test/server.test.ts +58 -0
- package/skills/codex/examples/food-ordering/vitest.config.ts +30 -0
- package/skills/codex/examples/hello/README.md +13 -0
- package/skills/codex/examples/hello/noodle.json +5 -0
- package/skills/codex/examples/hello/package.json +16 -0
- package/skills/codex/examples/hello/src/server.ts +29 -0
- package/skills/codex/examples/hello/test/server.test.ts +8 -0
- package/skills/codex/examples/weather/README.md +85 -0
- package/skills/codex/examples/weather/noodle.json +4 -0
- package/skills/codex/examples/weather/package.json +16 -0
- package/skills/codex/examples/weather/src/server.ts +261 -0
- package/skills/codex/examples/weather/test/server.test.ts +29 -0
- package/skills/codex/references/connect-an-api.md +63 -1
- package/skills/codex/references/examples.md +16 -7
- package/skills/codex/references/troubleshooting.md +2 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "acme-discovery",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "vitest run",
|
|
8
|
+
"validate": "noodle validate",
|
|
9
|
+
"dev": "noodle dev",
|
|
10
|
+
"deploy": "noodle deploy"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@vitejs/plugin-react": "latest",
|
|
14
|
+
"@noodleseed/one": "latest",
|
|
15
|
+
"react": "latest",
|
|
16
|
+
"react-dom": "latest",
|
|
17
|
+
"vite": "latest",
|
|
18
|
+
"vitest": "latest"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ServerDefinition } from '@noodleseed/one';
|
|
2
|
+
import { generateHelpers } from '@noodleseed/one/react';
|
|
3
|
+
|
|
4
|
+
export type AppType = ServerDefinition;
|
|
5
|
+
|
|
6
|
+
export const { useCallTool, useLayout, useOpenExternal, useToolInfo, useViewState } =
|
|
7
|
+
generateHelpers<AppType>();
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { annotations, server, tool, toolForWidget, toolWithWidget, z } from '@noodleseed/one';
|
|
2
|
+
|
|
3
|
+
// Acme Getaways is a fictional travel brand. This app is deliberately top-of-funnel: discovery and
|
|
4
|
+
// configuration happen inside ChatGPT; the booking/transaction happens off-app on Acme's own site,
|
|
5
|
+
// reached through a signed, attributable handoff deep link. Destinations are the partner's own
|
|
6
|
+
// catalog (grounding) — the app never invents a place, price, or best-month.
|
|
7
|
+
//
|
|
8
|
+
// Authoring note: a tool `fulfil` is *recorded*, not run as live JS. Inputs flow through as
|
|
9
|
+
// `${input.x}` substitutions when placed directly into an output string; do not transform them
|
|
10
|
+
// (no URL-encoding, arithmetic, or filtering on an input value — those break substitution). The
|
|
11
|
+
// curated catalog below is static data the runtime returns verbatim.
|
|
12
|
+
|
|
13
|
+
const catalog = [
|
|
14
|
+
{
|
|
15
|
+
id: 'coral_bay',
|
|
16
|
+
name: 'Coral Bay',
|
|
17
|
+
region: 'Adriatic coast',
|
|
18
|
+
vibe: 'beach',
|
|
19
|
+
priceFrom: 890,
|
|
20
|
+
bestMonths: 'May–Sep',
|
|
21
|
+
why: 'Calm swimming coves and a walkable old town — easy for a relaxed first trip.',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'monte_alto',
|
|
25
|
+
name: 'Monte Alto',
|
|
26
|
+
region: 'Northern Alps',
|
|
27
|
+
vibe: 'mountains',
|
|
28
|
+
priceFrom: 1120,
|
|
29
|
+
bestMonths: 'Dec–Mar',
|
|
30
|
+
why: 'Ski-in village with beginner slopes and long groomed runs.',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 'old_quarter',
|
|
34
|
+
name: 'Old Quarter',
|
|
35
|
+
region: 'Central Europe',
|
|
36
|
+
vibe: 'culture',
|
|
37
|
+
priceFrom: 640,
|
|
38
|
+
bestMonths: 'Apr–Oct',
|
|
39
|
+
why: 'Dense museum district and food halls, all reachable on foot.',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'harbor_city',
|
|
43
|
+
name: 'Harbor City',
|
|
44
|
+
region: 'Pacific rim',
|
|
45
|
+
vibe: 'city',
|
|
46
|
+
priceFrom: 980,
|
|
47
|
+
bestMonths: 'Sep–Nov',
|
|
48
|
+
why: 'Waterfront nightlife and day-trip islands a short ferry away.',
|
|
49
|
+
},
|
|
50
|
+
] as const;
|
|
51
|
+
|
|
52
|
+
// A closed set of URL-safe month values. A `fulfil` cannot url-encode an input (recording would break
|
|
53
|
+
// substitution), so the deep link carries `month` only if it is already safe — the model maps natural
|
|
54
|
+
// phrasing ("early June") onto one of these when it fills the tool.
|
|
55
|
+
const monthEnum = z
|
|
56
|
+
.enum([
|
|
57
|
+
'January',
|
|
58
|
+
'February',
|
|
59
|
+
'March',
|
|
60
|
+
'April',
|
|
61
|
+
'May',
|
|
62
|
+
'June',
|
|
63
|
+
'July',
|
|
64
|
+
'August',
|
|
65
|
+
'September',
|
|
66
|
+
'October',
|
|
67
|
+
'November',
|
|
68
|
+
'December',
|
|
69
|
+
])
|
|
70
|
+
.default('June');
|
|
71
|
+
|
|
72
|
+
// The catalog ids are already url-safe slugs. Constrain the handoff `destination` to this closed set so
|
|
73
|
+
// only a real, url-safe id can reach the deep link.
|
|
74
|
+
const destinationId = z.enum(['coral_bay', 'monte_alto', 'old_quarter', 'harbor_city']);
|
|
75
|
+
|
|
76
|
+
const discoverInput = z.object({
|
|
77
|
+
vibe: z.enum(['beach', 'mountains', 'culture', 'city']).default('beach'),
|
|
78
|
+
month: monthEnum,
|
|
79
|
+
travelers: z.number().int().min(1).default(2),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Tool annotations for host planners: reads are read-only, the handoff opens an external link, and
|
|
83
|
+
// shortlisting is a local non-destructive write.
|
|
84
|
+
const readOnly = annotations.readOnly();
|
|
85
|
+
const openLink = annotations.openAction();
|
|
86
|
+
const localWrite = annotations.localAction({ destructive: false });
|
|
87
|
+
|
|
88
|
+
const destinationOutput = z.object({
|
|
89
|
+
id: z.string(),
|
|
90
|
+
name: z.string(),
|
|
91
|
+
region: z.string(),
|
|
92
|
+
vibe: z.string(),
|
|
93
|
+
priceFrom: z.number(),
|
|
94
|
+
bestMonths: z.string(),
|
|
95
|
+
why: z.string(),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
export default server(
|
|
99
|
+
'acme_discovery',
|
|
100
|
+
{
|
|
101
|
+
title: 'Acme Getaways',
|
|
102
|
+
version: '1.0.0',
|
|
103
|
+
branding: {
|
|
104
|
+
name: 'Acme Getaways',
|
|
105
|
+
accent: '#0EA5A4',
|
|
106
|
+
surface: '#F0FDFA',
|
|
107
|
+
surfaceDark: '#0B1B1B',
|
|
108
|
+
radius: 'lg',
|
|
109
|
+
density: 'comfortable',
|
|
110
|
+
},
|
|
111
|
+
// The only external destinations the app links out to — the compiler derives ChatGPT's
|
|
112
|
+
// redirect_domains from this so the handoff opens without a safe-link warning.
|
|
113
|
+
handoff: {
|
|
114
|
+
allowedDomains: ['https://book.acme.example', 'https://acme.example'],
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
[
|
|
118
|
+
toolWithWidget('discover_getaways', {
|
|
119
|
+
description:
|
|
120
|
+
'Suggest Acme Getaways destinations for a vibe and month and render a discovery carousel.',
|
|
121
|
+
annotations: readOnly,
|
|
122
|
+
input: discoverInput,
|
|
123
|
+
output: z.object({
|
|
124
|
+
status: z.string(),
|
|
125
|
+
vibe: z.string(),
|
|
126
|
+
month: z.string(),
|
|
127
|
+
travelers: z.number(),
|
|
128
|
+
options: z.array(destinationOutput),
|
|
129
|
+
}),
|
|
130
|
+
// The carousel presents Acme's curated catalog; the model narrates which fit the stated vibe.
|
|
131
|
+
// (A tool cannot filter on an input value — that is connector/flow work — so all are returned.)
|
|
132
|
+
fulfil: ({ input }) => ({
|
|
133
|
+
status: `Acme Getaways for a ${input.vibe} trip in ${input.month}, ${input.travelers} traveler(s).`,
|
|
134
|
+
vibe: input.vibe,
|
|
135
|
+
month: input.month,
|
|
136
|
+
travelers: input.travelers,
|
|
137
|
+
options: catalog,
|
|
138
|
+
}),
|
|
139
|
+
widgetTitle: 'Discover getaways',
|
|
140
|
+
// ChatGPT host status copy (openai/toolInvocation/*) — required for widget-opening tools.
|
|
141
|
+
invoking: 'Finding getaways…',
|
|
142
|
+
invoked: 'Getaways ready',
|
|
143
|
+
domain: 'https://getaways.acme.example',
|
|
144
|
+
view: {
|
|
145
|
+
component: 'discovery-carousel',
|
|
146
|
+
entry: './views/discovery-carousel.tsx',
|
|
147
|
+
},
|
|
148
|
+
widgetDescription:
|
|
149
|
+
'A top-of-funnel discovery carousel: pick a destination, then hand off to Acme to book.',
|
|
150
|
+
csp: {
|
|
151
|
+
connectDomains: ['https://acme.example'],
|
|
152
|
+
resourceDomains: ['https://acme.example'],
|
|
153
|
+
frameDomains: ['https://acme.example'],
|
|
154
|
+
},
|
|
155
|
+
}),
|
|
156
|
+
tool('create_handoff', {
|
|
157
|
+
description:
|
|
158
|
+
'Create the Acme booking deep link for a chosen destination, carrying the configured trip. ' +
|
|
159
|
+
'Pass the destination id (url-safe slug, e.g. "coral_bay") and its display name.',
|
|
160
|
+
annotations: openLink,
|
|
161
|
+
input: z.object({
|
|
162
|
+
destination: destinationId,
|
|
163
|
+
destinationName: z.string().min(1),
|
|
164
|
+
month: monthEnum,
|
|
165
|
+
travelers: z.number().int().min(1).default(2),
|
|
166
|
+
}),
|
|
167
|
+
output: z.object({
|
|
168
|
+
status: z.string(),
|
|
169
|
+
destination: z.string(),
|
|
170
|
+
summary: z.string(),
|
|
171
|
+
handoffUrl: z.string(),
|
|
172
|
+
}),
|
|
173
|
+
// Inline the inputs directly so they substitute at runtime; every value is already url-safe
|
|
174
|
+
// (id slug, month enum, integer), and `src=chatgpt` is the attribution the partner measures
|
|
175
|
+
// ChatGPT-sourced conversions on.
|
|
176
|
+
fulfil: ({ input }) => ({
|
|
177
|
+
status: `Ready to continue on Acme for ${input.destinationName}.`,
|
|
178
|
+
destination: input.destination,
|
|
179
|
+
summary: `${input.destinationName} · ${input.month} · ${input.travelers} traveler(s)`,
|
|
180
|
+
handoffUrl: `https://book.acme.example/plan?dest=${input.destination}&month=${input.month}&pax=${input.travelers}&src=chatgpt`,
|
|
181
|
+
}),
|
|
182
|
+
}),
|
|
183
|
+
toolForWidget('shortlist_getaway', {
|
|
184
|
+
description: 'Record the traveler’s shortlisted destination from the discovery widget.',
|
|
185
|
+
annotations: localWrite,
|
|
186
|
+
input: z.object({
|
|
187
|
+
destination: z.string(),
|
|
188
|
+
note: z.string().default(''),
|
|
189
|
+
}),
|
|
190
|
+
output: z.object({
|
|
191
|
+
status: z.string(),
|
|
192
|
+
destination: z.string(),
|
|
193
|
+
note: z.string(),
|
|
194
|
+
}),
|
|
195
|
+
fulfil: ({ input }) => ({
|
|
196
|
+
status: `Shortlisted ${input.destination}.`,
|
|
197
|
+
destination: input.destination,
|
|
198
|
+
note: input.note,
|
|
199
|
+
}),
|
|
200
|
+
}),
|
|
201
|
+
],
|
|
202
|
+
);
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useCallTool, useLayout, useOpenExternal, useToolInfo, useViewState } from '../helpers.js';
|
|
3
|
+
import './widget-style.css';
|
|
4
|
+
|
|
5
|
+
type Destination = {
|
|
6
|
+
readonly id: string;
|
|
7
|
+
readonly name: string;
|
|
8
|
+
readonly region: string;
|
|
9
|
+
readonly vibe: string;
|
|
10
|
+
readonly priceFrom: number;
|
|
11
|
+
readonly bestMonths: string;
|
|
12
|
+
readonly why: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function asDiscovery(value: unknown) {
|
|
16
|
+
return value as
|
|
17
|
+
| {
|
|
18
|
+
readonly status?: string;
|
|
19
|
+
readonly month?: string;
|
|
20
|
+
readonly travelers?: number;
|
|
21
|
+
readonly options?: readonly Destination[];
|
|
22
|
+
}
|
|
23
|
+
| undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default function DiscoveryCarousel() {
|
|
27
|
+
const { displayMode, theme } = useLayout();
|
|
28
|
+
const openExternal = useOpenExternal();
|
|
29
|
+
const discovery = asDiscovery(useToolInfo('discover_getaways').structuredContent);
|
|
30
|
+
const shortlist = useCallTool('shortlist_getaway');
|
|
31
|
+
const handoff = useCallTool('create_handoff');
|
|
32
|
+
|
|
33
|
+
const options = discovery?.options ?? [];
|
|
34
|
+
const month = discovery?.month ?? 'June';
|
|
35
|
+
const travelers = discovery?.travelers ?? 2;
|
|
36
|
+
const [chosen, setChosen] = useViewState('chosen', options[0]?.id ?? '');
|
|
37
|
+
const [status, setStatus] = useState(discovery?.status ?? 'Pick a getaway to continue.');
|
|
38
|
+
const selected = options.find((entry) => entry.id === chosen) ?? options[0];
|
|
39
|
+
const continueLabel = handoff.isPending
|
|
40
|
+
? 'Opening Acme…'
|
|
41
|
+
: `Continue on Acme${selected ? ` · ${selected.name}` : ''}`;
|
|
42
|
+
|
|
43
|
+
async function shortlistDestination(destination: Destination) {
|
|
44
|
+
setChosen(destination.id);
|
|
45
|
+
try {
|
|
46
|
+
const result = await shortlist.callTool({ destination: destination.name });
|
|
47
|
+
const structured = result.structuredContent as { readonly status?: string } | undefined;
|
|
48
|
+
setStatus(structured?.status ?? `Shortlisted ${destination.name}.`);
|
|
49
|
+
} catch {
|
|
50
|
+
setStatus(`Couldn't shortlist ${destination.name} — try again.`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function continueOnAcme() {
|
|
55
|
+
if (selected === undefined) return;
|
|
56
|
+
// The handoff is the product: configure here, transact off-app. The deep link carries the trip.
|
|
57
|
+
// Only open the external target on a successful handoff; surface failures instead of failing silently.
|
|
58
|
+
try {
|
|
59
|
+
const result = await handoff.callTool({
|
|
60
|
+
destination: selected.id,
|
|
61
|
+
destinationName: selected.name,
|
|
62
|
+
month,
|
|
63
|
+
travelers,
|
|
64
|
+
});
|
|
65
|
+
const structured = result.structuredContent as { readonly handoffUrl?: string } | undefined;
|
|
66
|
+
if (structured?.handoffUrl) openExternal(structured.handoffUrl);
|
|
67
|
+
else setStatus('Continue on Acme is unavailable right now — try again.');
|
|
68
|
+
} catch {
|
|
69
|
+
setStatus('Continue on Acme failed — try again.');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<main
|
|
75
|
+
className={`nw-shell${theme === 'dark' ? ' dark' : ''}`}
|
|
76
|
+
data-llm={`Acme Getaways discovery: ${options.length} options for ${month}, ${travelers} traveler(s); shortlisted ${selected?.name ?? 'none'}`}
|
|
77
|
+
>
|
|
78
|
+
<section className="nw-card">
|
|
79
|
+
<header className="nw-header">
|
|
80
|
+
<span className="nw-icon" aria-hidden="true">
|
|
81
|
+
<CompassIcon />
|
|
82
|
+
</span>
|
|
83
|
+
<div className="nw-title-block">
|
|
84
|
+
<h1 className="nw-title">Acme Getaways</h1>
|
|
85
|
+
<p className="nw-subtitle" aria-live="polite">
|
|
86
|
+
{status}
|
|
87
|
+
</p>
|
|
88
|
+
</div>
|
|
89
|
+
<span className="nw-chip">
|
|
90
|
+
{displayMode === 'fullscreen' ? 'Fullscreen' : 'Discover'}
|
|
91
|
+
</span>
|
|
92
|
+
</header>
|
|
93
|
+
|
|
94
|
+
<div className="nw-carousel">
|
|
95
|
+
{options.map((entry) => (
|
|
96
|
+
<article
|
|
97
|
+
className={`nw-dest${entry.id === chosen ? ' nw-dest-active' : ''}`}
|
|
98
|
+
key={entry.id}
|
|
99
|
+
>
|
|
100
|
+
<div className="nw-dest-head">
|
|
101
|
+
<span className="nw-dest-name">{entry.name}</span>
|
|
102
|
+
<span className="nw-price">from ${entry.priceFrom}</span>
|
|
103
|
+
</div>
|
|
104
|
+
<p className="nw-dest-region">
|
|
105
|
+
{entry.region} · best {entry.bestMonths}
|
|
106
|
+
</p>
|
|
107
|
+
{/* Grounded copy: the "why" comes from Acme's catalog, not invented at runtime. */}
|
|
108
|
+
<p className="nw-dest-why">{entry.why}</p>
|
|
109
|
+
<button
|
|
110
|
+
aria-pressed={entry.id === chosen}
|
|
111
|
+
className="nw-button nw-button-ghost"
|
|
112
|
+
type="button"
|
|
113
|
+
onClick={() => shortlistDestination(entry)}
|
|
114
|
+
>
|
|
115
|
+
{entry.id === chosen ? 'Shortlisted' : 'Shortlist'}
|
|
116
|
+
</button>
|
|
117
|
+
</article>
|
|
118
|
+
))}
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<div className="nw-actions">
|
|
122
|
+
<button
|
|
123
|
+
className="nw-button nw-button-primary"
|
|
124
|
+
type="button"
|
|
125
|
+
disabled={selected === undefined || handoff.isPending}
|
|
126
|
+
onClick={continueOnAcme}
|
|
127
|
+
>
|
|
128
|
+
<ExternalIcon />
|
|
129
|
+
{continueLabel}
|
|
130
|
+
</button>
|
|
131
|
+
</div>
|
|
132
|
+
<p className="nw-note">Booking and payment happen on acme.example — never inside chat.</p>
|
|
133
|
+
</section>
|
|
134
|
+
</main>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function CompassIcon() {
|
|
139
|
+
return (
|
|
140
|
+
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
141
|
+
<circle cx="12" cy="12" r="9" />
|
|
142
|
+
<path d="m15.5 8.5-2 5-5 2 2-5 5-2Z" />
|
|
143
|
+
</svg>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function ExternalIcon() {
|
|
148
|
+
return (
|
|
149
|
+
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
150
|
+
<path d="M14 4h6v6" />
|
|
151
|
+
<path d="m20 4-9 9" />
|
|
152
|
+
<path d="M20 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5" />
|
|
153
|
+
</svg>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: light dark;
|
|
3
|
+
font-family:
|
|
4
|
+
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
5
|
+
--nw-bg: #ffffff;
|
|
6
|
+
--nw-surface: #f4fbfa;
|
|
7
|
+
--nw-text: #10201f;
|
|
8
|
+
--nw-muted: #5b6b6a;
|
|
9
|
+
--nw-border: #d4e6e4;
|
|
10
|
+
--nw-accent: #0ea5a4;
|
|
11
|
+
--nw-accent-strong: #0f766e;
|
|
12
|
+
--nw-accent-soft: #e6faf8;
|
|
13
|
+
--nw-radius: 10px;
|
|
14
|
+
--nw-shadow: 0 18px 50px rgb(15 40 40 / 12%);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.dark,
|
|
18
|
+
[data-theme="dark"] {
|
|
19
|
+
--nw-bg: #0b1b1b;
|
|
20
|
+
--nw-surface: #102624;
|
|
21
|
+
--nw-text: #eafaf8;
|
|
22
|
+
--nw-muted: #9fb6b3;
|
|
23
|
+
--nw-border: #244341;
|
|
24
|
+
--nw-accent: #2dd4bf;
|
|
25
|
+
--nw-accent-strong: #14b8a6;
|
|
26
|
+
--nw-accent-soft: #0f3835;
|
|
27
|
+
--nw-shadow: 0 18px 50px rgb(0 0 0 / 30%);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
* {
|
|
31
|
+
box-sizing: border-box;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
body {
|
|
35
|
+
margin: 0;
|
|
36
|
+
background: var(--nw-bg);
|
|
37
|
+
color: var(--nw-text);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
button {
|
|
41
|
+
font: inherit;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.nw-shell {
|
|
45
|
+
min-height: 100vh;
|
|
46
|
+
padding: 14px;
|
|
47
|
+
background: var(--nw-bg);
|
|
48
|
+
color: var(--nw-text);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.nw-card {
|
|
52
|
+
max-width: 720px;
|
|
53
|
+
margin: 0 auto;
|
|
54
|
+
background: var(--nw-surface);
|
|
55
|
+
border: 1px solid var(--nw-border);
|
|
56
|
+
border-radius: var(--nw-radius);
|
|
57
|
+
box-shadow: var(--nw-shadow);
|
|
58
|
+
overflow: hidden;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.nw-header {
|
|
62
|
+
display: flex;
|
|
63
|
+
align-items: center;
|
|
64
|
+
gap: 12px;
|
|
65
|
+
padding: 16px;
|
|
66
|
+
border-bottom: 1px solid var(--nw-border);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.nw-icon svg {
|
|
70
|
+
width: 26px;
|
|
71
|
+
height: 26px;
|
|
72
|
+
fill: none;
|
|
73
|
+
stroke: var(--nw-accent);
|
|
74
|
+
stroke-width: 1.7;
|
|
75
|
+
stroke-linecap: round;
|
|
76
|
+
stroke-linejoin: round;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.nw-title-block {
|
|
80
|
+
flex: 1;
|
|
81
|
+
min-width: 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.nw-title {
|
|
85
|
+
margin: 0;
|
|
86
|
+
font-size: 17px;
|
|
87
|
+
font-weight: 700;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.nw-subtitle {
|
|
91
|
+
margin: 2px 0 0;
|
|
92
|
+
font-size: 13px;
|
|
93
|
+
color: var(--nw-muted);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.nw-chip {
|
|
97
|
+
padding: 4px 10px;
|
|
98
|
+
border-radius: 999px;
|
|
99
|
+
background: var(--nw-accent-soft);
|
|
100
|
+
color: var(--nw-accent-strong);
|
|
101
|
+
font-size: 12px;
|
|
102
|
+
font-weight: 600;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.nw-carousel {
|
|
106
|
+
display: grid;
|
|
107
|
+
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
|
108
|
+
gap: 12px;
|
|
109
|
+
padding: 16px;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.nw-dest {
|
|
113
|
+
display: flex;
|
|
114
|
+
flex-direction: column;
|
|
115
|
+
gap: 6px;
|
|
116
|
+
padding: 12px;
|
|
117
|
+
border: 1px solid var(--nw-border);
|
|
118
|
+
border-radius: 12px;
|
|
119
|
+
background: var(--nw-bg);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.nw-dest-active {
|
|
123
|
+
border-color: var(--nw-accent);
|
|
124
|
+
box-shadow: 0 0 0 1px var(--nw-accent);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.nw-dest-head {
|
|
128
|
+
display: flex;
|
|
129
|
+
align-items: baseline;
|
|
130
|
+
justify-content: space-between;
|
|
131
|
+
gap: 8px;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.nw-dest-name {
|
|
135
|
+
font-weight: 700;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.nw-price {
|
|
139
|
+
color: var(--nw-accent-strong);
|
|
140
|
+
font-size: 12px;
|
|
141
|
+
font-weight: 600;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.nw-dest-region {
|
|
145
|
+
margin: 0;
|
|
146
|
+
font-size: 12px;
|
|
147
|
+
color: var(--nw-muted);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.nw-dest-why {
|
|
151
|
+
margin: 0;
|
|
152
|
+
font-size: 13px;
|
|
153
|
+
flex: 1;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.nw-actions {
|
|
157
|
+
display: flex;
|
|
158
|
+
gap: 8px;
|
|
159
|
+
padding: 0 16px 12px;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.nw-button {
|
|
163
|
+
display: inline-flex;
|
|
164
|
+
align-items: center;
|
|
165
|
+
gap: 6px;
|
|
166
|
+
padding: 9px 14px;
|
|
167
|
+
border: 1px solid var(--nw-border);
|
|
168
|
+
border-radius: 10px;
|
|
169
|
+
background: var(--nw-bg);
|
|
170
|
+
color: var(--nw-text);
|
|
171
|
+
cursor: pointer;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.nw-button svg {
|
|
175
|
+
width: 16px;
|
|
176
|
+
height: 16px;
|
|
177
|
+
fill: none;
|
|
178
|
+
stroke: currentColor;
|
|
179
|
+
stroke-width: 1.7;
|
|
180
|
+
stroke-linecap: round;
|
|
181
|
+
stroke-linejoin: round;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.nw-button-ghost {
|
|
185
|
+
align-self: flex-start;
|
|
186
|
+
padding: 6px 12px;
|
|
187
|
+
font-size: 13px;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.nw-button-primary {
|
|
191
|
+
background: var(--nw-accent);
|
|
192
|
+
border-color: var(--nw-accent);
|
|
193
|
+
color: #ffffff;
|
|
194
|
+
font-weight: 600;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.nw-button-primary:disabled {
|
|
198
|
+
opacity: 0.6;
|
|
199
|
+
cursor: default;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.nw-note {
|
|
203
|
+
margin: 0;
|
|
204
|
+
padding: 0 16px 16px;
|
|
205
|
+
font-size: 12px;
|
|
206
|
+
color: var(--nw-muted);
|
|
207
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import app from '../src/server.js';
|
|
3
|
+
|
|
4
|
+
describe('acme-discovery example', () => {
|
|
5
|
+
it('exports a Noodle server definition', () => {
|
|
6
|
+
expect(typeof app.toManifest).toBe('function');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('declares the off-app handoff domain the deep link lands on', async () => {
|
|
10
|
+
// Top-of-funnel: the only external target is Acme's booking site, declared once at the server.
|
|
11
|
+
const manifest = await app.toManifest();
|
|
12
|
+
expect(JSON.stringify(manifest)).toContain('https://book.acme.example');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('exposes the discovery tool and the handoff tool', async () => {
|
|
16
|
+
const manifest = await app.toManifest();
|
|
17
|
+
const text = JSON.stringify(manifest);
|
|
18
|
+
// The discovery tool renders the carousel; the handoff tool emits the deep link; the widget-only
|
|
19
|
+
// helper records a shortlist.
|
|
20
|
+
expect(text).toContain('discover_getaways');
|
|
21
|
+
expect(text).toContain('create_handoff');
|
|
22
|
+
expect(text).toContain('shortlist_getaway');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
// Local config so `npm test` (vitest run) discovers this example's own tests instead of inheriting a
|
|
4
|
+
// parent monorepo config's include globs.
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
test: { include: ['test/**/*.test.ts'] },
|
|
7
|
+
});
|