@stackable-labs/mcp-app-extension 0.2.0 → 0.3.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/dist/index.js +191 -182
- package/dist/server.js +3985 -0
- package/package.json +11 -1
package/dist/server.js
ADDED
|
@@ -0,0 +1,3985 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { IDENTITY_EVENTS, ACTIVITY_EVENTS, SURFACE_TARGETS, PERMISSIONS, CAPABILITY_PERMISSION_MAP, ALLOWED_ICONS, UI_TAGS, UI_TAG_ATTRIBUTES, tagToComponentName } from '@stackable-labs/sdk-extension-contracts';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
// src/server.ts
|
|
6
|
+
|
|
7
|
+
// ../../sdk/extension/ai-docs/src/generated/template-content.ts
|
|
8
|
+
var PATTERN_SECTIONS = [
|
|
9
|
+
{
|
|
10
|
+
path: "index.tsx",
|
|
11
|
+
title: "Entry Point",
|
|
12
|
+
code: `import { createExtension } from '@stackable-labs/sdk-extension-react'
|
|
13
|
+
import { Header } from './surfaces/Header'
|
|
14
|
+
import { Content } from './surfaces/Content'
|
|
15
|
+
import { Footer } from './surfaces/Footer'
|
|
16
|
+
|
|
17
|
+
const Extension = () => (
|
|
18
|
+
<>
|
|
19
|
+
<Header />
|
|
20
|
+
<Content />
|
|
21
|
+
<Footer />
|
|
22
|
+
</>
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
createExtension(() => <Extension />, { extensionId: '__EXTENSION_ID__' })`
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
path: "store.ts",
|
|
29
|
+
title: "Store-Based Navigation",
|
|
30
|
+
code: `import { createStore } from '@stackable-labs/sdk-extension-react'
|
|
31
|
+
|
|
32
|
+
export type ViewState = { type: 'menu' }
|
|
33
|
+
|
|
34
|
+
export interface AppState {
|
|
35
|
+
viewState: ViewState
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const appStore = createStore<AppState>({
|
|
39
|
+
viewState: { type: 'menu' },
|
|
40
|
+
})`
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
path: "surfaces/Content.tsx",
|
|
44
|
+
title: "Content Surface with Loading State",
|
|
45
|
+
code: `import { ui, useStore, useContextData, Surface } from '@stackable-labs/sdk-extension-react'
|
|
46
|
+
import { appStore } from '../store'
|
|
47
|
+
|
|
48
|
+
export function Content() {
|
|
49
|
+
const viewState = useStore(appStore, (s) => s.viewState)
|
|
50
|
+
const { loading } = useContextData()
|
|
51
|
+
|
|
52
|
+
if (loading) {
|
|
53
|
+
return (
|
|
54
|
+
<Surface id="slot.content">
|
|
55
|
+
<ui.Stack direction="column" gap="2" className="animate-pulse">
|
|
56
|
+
<ui.Card className="h-24" />
|
|
57
|
+
<ui.Card className="h-32" />
|
|
58
|
+
</ui.Stack>
|
|
59
|
+
</Surface>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<Surface id="slot.content">
|
|
65
|
+
{viewState.type === 'menu' && (
|
|
66
|
+
<ui.Menu>
|
|
67
|
+
{/* Add ui.MenuItem entries here */}
|
|
68
|
+
</ui.Menu>
|
|
69
|
+
)}
|
|
70
|
+
</Surface>
|
|
71
|
+
)
|
|
72
|
+
}`
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
path: "surfaces/Footer.tsx",
|
|
76
|
+
title: "Surface Composition",
|
|
77
|
+
code: `import { ui, Surface } from '@stackable-labs/sdk-extension-react'
|
|
78
|
+
|
|
79
|
+
export function Footer() {
|
|
80
|
+
return (
|
|
81
|
+
<>
|
|
82
|
+
<Surface id="slot.footer">
|
|
83
|
+
<ui.Text className="text-xs">Powered by My Extension</ui.Text>
|
|
84
|
+
</Surface>
|
|
85
|
+
<Surface id="slot.footer-links">
|
|
86
|
+
<ui.FooterLink href="https://example.com">My Extension</ui.FooterLink>
|
|
87
|
+
</Surface>
|
|
88
|
+
</>
|
|
89
|
+
)
|
|
90
|
+
}`
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
path: "surfaces/Header.tsx",
|
|
94
|
+
title: "Simple Surface",
|
|
95
|
+
code: `import { ui, Surface } from '@stackable-labs/sdk-extension-react'
|
|
96
|
+
|
|
97
|
+
export function Header() {
|
|
98
|
+
return (
|
|
99
|
+
<Surface id="slot.header">
|
|
100
|
+
<ui.Text>Header content goes here</ui.Text>
|
|
101
|
+
</Surface>
|
|
102
|
+
)
|
|
103
|
+
}`
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
path: "lib/api.ts",
|
|
107
|
+
title: "API Wrapper (data.query + data.fetch)",
|
|
108
|
+
code: `/**
|
|
109
|
+
* API wrapper patterns \u2014 choose one based on your integration model.
|
|
110
|
+
*
|
|
111
|
+
* data.query \u2192 host-mediated: the host handles the API call, extension sends
|
|
112
|
+
* an action name + params, host returns data.
|
|
113
|
+
* Permission: "data:query"
|
|
114
|
+
*
|
|
115
|
+
* data.fetch \u2192 direct HTTP: the extension calls external APIs through the
|
|
116
|
+
* platform proxy. Domains must be in allowedDomains in manifest.
|
|
117
|
+
* Permission: "data:fetch"
|
|
118
|
+
*
|
|
119
|
+
* Usage in a surface:
|
|
120
|
+
* const capabilities = useCapabilities()
|
|
121
|
+
* const api = createApi(capabilities.data.query)
|
|
122
|
+
* // or: const api = createFetchApi(capabilities.data.fetch)
|
|
123
|
+
*/
|
|
124
|
+
|
|
125
|
+
import type { ApiRequest, FetchRequestInit, FetchResponse } from '@stackable-labs/sdk-extension-contracts'
|
|
126
|
+
|
|
127
|
+
type QueryFn = <T = unknown>(payload: ApiRequest) => Promise<T>
|
|
128
|
+
type FetchFn = (url: string, init?: FetchRequestInit) => Promise<FetchResponse>
|
|
129
|
+
|
|
130
|
+
// \u2500\u2500 data.query wrapper \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
131
|
+
|
|
132
|
+
export function createApi(query: QueryFn) {
|
|
133
|
+
return {
|
|
134
|
+
async getItems(): Promise<unknown[]> {
|
|
135
|
+
return query<unknown[]>({ action: 'getItems' })
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
async getItem(itemId: string): Promise<unknown> {
|
|
139
|
+
return query<unknown>({ action: 'getItem', itemId })
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// \u2500\u2500 data.fetch wrapper \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
145
|
+
|
|
146
|
+
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string
|
|
147
|
+
|
|
148
|
+
export function createFetchApi(fetch: FetchFn) {
|
|
149
|
+
return {
|
|
150
|
+
async getItems(): Promise<unknown[]> {
|
|
151
|
+
const result = await fetch(\`\${API_BASE_URL}/items\`, { method: 'GET' })
|
|
152
|
+
if (!result.ok) throw new Error(\`getItems failed: \${result.status}\`)
|
|
153
|
+
return result.data as unknown[]
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
async getItem(itemId: string): Promise<unknown> {
|
|
157
|
+
const result = await fetch(\`\${API_BASE_URL}/items/\${itemId}\`, { method: 'GET' })
|
|
158
|
+
if (!result.ok) throw new Error(\`getItem failed: \${result.status}\`)
|
|
159
|
+
return result.data as unknown
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
}`
|
|
163
|
+
}
|
|
164
|
+
];
|
|
165
|
+
var RECIPE_SECTIONS = [
|
|
166
|
+
{
|
|
167
|
+
path: "store.ts",
|
|
168
|
+
title: "Current Store & View State",
|
|
169
|
+
code: `import { createStore } from '@stackable-labs/sdk-extension-react'
|
|
170
|
+
|
|
171
|
+
export type ViewState = { type: 'menu' }
|
|
172
|
+
|
|
173
|
+
export interface AppState {
|
|
174
|
+
viewState: ViewState
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export const appStore = createStore<AppState>({
|
|
178
|
+
viewState: { type: 'menu' },
|
|
179
|
+
})`
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
path: "surfaces/Content.tsx",
|
|
183
|
+
title: "Current Content Surface",
|
|
184
|
+
code: `import { ui, useStore, useContextData, Surface } from '@stackable-labs/sdk-extension-react'
|
|
185
|
+
import { appStore } from '../store'
|
|
186
|
+
|
|
187
|
+
export function Content() {
|
|
188
|
+
const viewState = useStore(appStore, (s) => s.viewState)
|
|
189
|
+
const { loading } = useContextData()
|
|
190
|
+
|
|
191
|
+
if (loading) {
|
|
192
|
+
return (
|
|
193
|
+
<Surface id="slot.content">
|
|
194
|
+
<ui.Stack direction="column" gap="2" className="animate-pulse">
|
|
195
|
+
<ui.Card className="h-24" />
|
|
196
|
+
<ui.Card className="h-32" />
|
|
197
|
+
</ui.Stack>
|
|
198
|
+
</Surface>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<Surface id="slot.content">
|
|
204
|
+
{viewState.type === 'menu' && (
|
|
205
|
+
<ui.Menu>
|
|
206
|
+
{/* Add ui.MenuItem entries here */}
|
|
207
|
+
</ui.Menu>
|
|
208
|
+
)}
|
|
209
|
+
</Surface>
|
|
210
|
+
)
|
|
211
|
+
}`
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
path: "lib/api.ts",
|
|
215
|
+
title: "Current API Wrapper",
|
|
216
|
+
code: `/**
|
|
217
|
+
* API wrapper patterns \u2014 choose one based on your integration model.
|
|
218
|
+
*
|
|
219
|
+
* data.query \u2192 host-mediated: the host handles the API call, extension sends
|
|
220
|
+
* an action name + params, host returns data.
|
|
221
|
+
* Permission: "data:query"
|
|
222
|
+
*
|
|
223
|
+
* data.fetch \u2192 direct HTTP: the extension calls external APIs through the
|
|
224
|
+
* platform proxy. Domains must be in allowedDomains in manifest.
|
|
225
|
+
* Permission: "data:fetch"
|
|
226
|
+
*
|
|
227
|
+
* Usage in a surface:
|
|
228
|
+
* const capabilities = useCapabilities()
|
|
229
|
+
* const api = createApi(capabilities.data.query)
|
|
230
|
+
* // or: const api = createFetchApi(capabilities.data.fetch)
|
|
231
|
+
*/
|
|
232
|
+
|
|
233
|
+
import type { ApiRequest, FetchRequestInit, FetchResponse } from '@stackable-labs/sdk-extension-contracts'
|
|
234
|
+
|
|
235
|
+
type QueryFn = <T = unknown>(payload: ApiRequest) => Promise<T>
|
|
236
|
+
type FetchFn = (url: string, init?: FetchRequestInit) => Promise<FetchResponse>
|
|
237
|
+
|
|
238
|
+
// \u2500\u2500 data.query wrapper \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
239
|
+
|
|
240
|
+
export function createApi(query: QueryFn) {
|
|
241
|
+
return {
|
|
242
|
+
async getItems(): Promise<unknown[]> {
|
|
243
|
+
return query<unknown[]>({ action: 'getItems' })
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
async getItem(itemId: string): Promise<unknown> {
|
|
247
|
+
return query<unknown>({ action: 'getItem', itemId })
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// \u2500\u2500 data.fetch wrapper \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
253
|
+
|
|
254
|
+
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string
|
|
255
|
+
|
|
256
|
+
export function createFetchApi(fetch: FetchFn) {
|
|
257
|
+
return {
|
|
258
|
+
async getItems(): Promise<unknown[]> {
|
|
259
|
+
const result = await fetch(\`\${API_BASE_URL}/items\`, { method: 'GET' })
|
|
260
|
+
if (!result.ok) throw new Error(\`getItems failed: \${result.status}\`)
|
|
261
|
+
return result.data as unknown[]
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
async getItem(itemId: string): Promise<unknown> {
|
|
265
|
+
const result = await fetch(\`\${API_BASE_URL}/items/\${itemId}\`, { method: 'GET' })
|
|
266
|
+
if (!result.ok) throw new Error(\`getItem failed: \${result.status}\`)
|
|
267
|
+
return result.data as unknown
|
|
268
|
+
},
|
|
269
|
+
}
|
|
270
|
+
}`
|
|
271
|
+
}
|
|
272
|
+
];
|
|
273
|
+
var CORE_CONTENT = `# Extension SDK \u2014 Editorial Notes
|
|
274
|
+
|
|
275
|
+
> This file is merged into the generated AI editor config files.
|
|
276
|
+
> Keep it minimal \u2014 the generation script handles most content automatically.
|
|
277
|
+
|
|
278
|
+
## Key Requirements
|
|
279
|
+
|
|
280
|
+
### Fetching Data
|
|
281
|
+
- No direct API calls using standard \`fetch\` allowed (blocked for security)
|
|
282
|
+
- Any external API calls must be made using the \`data.fetch\` capability
|
|
283
|
+
- Domain for any API call needs to be safelisted via \`allowedDomains\` in \`manifest.json\`
|
|
284
|
+
|
|
285
|
+
## Troubleshooting
|
|
286
|
+
|
|
287
|
+
### "Permission denied" errors
|
|
288
|
+
- Verify the capability's permission is declared in \`manifest.json\`
|
|
289
|
+
- Check that the permission string matches exactly (e.g., \`data:fetch\`, not \`data.fetch\`)
|
|
290
|
+
|
|
291
|
+
### data.fetch returns network errors
|
|
292
|
+
- Confirm the domain is in \`allowedDomains\` in \`manifest.json\`
|
|
293
|
+
- Only exact hostnames are allowed \u2014 no wildcards, no paths
|
|
294
|
+
- Check that the URL uses HTTPS
|
|
295
|
+
|
|
296
|
+
### Surface not rendering
|
|
297
|
+
- Confirm the \`<Surface id="...">\` matches a target in \`manifest.json\`
|
|
298
|
+
- Confirm the surface component is imported and rendered in \`index.tsx\`
|
|
299
|
+
- Check the browser console for errors
|
|
300
|
+
|
|
301
|
+
### Context data is undefined
|
|
302
|
+
- Ensure \`context:read\` permission is declared
|
|
303
|
+
- Always check the \`loading\` flag before using context values
|
|
304
|
+
- Context is only available when the host provides it (e.g., when viewing a ticket)
|
|
305
|
+
|
|
306
|
+
## Best Practices
|
|
307
|
+
|
|
308
|
+
### Performance
|
|
309
|
+
- Keep bundle size under 500KB
|
|
310
|
+
- Use \`useStore(store, selector)\` with narrow selectors to minimize re-renders
|
|
311
|
+
- Avoid fetching data in multiple surfaces \u2014 fetch once and share via store
|
|
312
|
+
|
|
313
|
+
### Code Organization
|
|
314
|
+
- One file per surface in \`src/surfaces/\`
|
|
315
|
+
- Shared state in \`src/store.ts\`
|
|
316
|
+
- API wrappers in \`src/lib/\`
|
|
317
|
+
- Feature components in \`src/components/\`
|
|
318
|
+
`;
|
|
319
|
+
|
|
320
|
+
// ../../sdk/extension/ai-docs/src/utils.ts
|
|
321
|
+
var frontmatter = (meta) => {
|
|
322
|
+
const lines = ["---"];
|
|
323
|
+
for (const [key, value] of Object.entries(meta)) {
|
|
324
|
+
if (Array.isArray(value)) {
|
|
325
|
+
lines.push(`${key}: ${JSON.stringify(value)}`);
|
|
326
|
+
} else if (typeof value === "object" && value !== null) {
|
|
327
|
+
lines.push(`${key}:`);
|
|
328
|
+
for (const [subKey, subValue] of Object.entries(value)) {
|
|
329
|
+
lines.push(` ${subKey}: ${JSON.stringify(subValue)}`);
|
|
330
|
+
}
|
|
331
|
+
} else {
|
|
332
|
+
lines.push(`${key}: ${JSON.stringify(value)}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
lines.push("---");
|
|
336
|
+
return lines.join("\n");
|
|
337
|
+
};
|
|
338
|
+
var generateCoreReference = () => CORE_CONTENT;
|
|
339
|
+
|
|
340
|
+
// ../../sdk/extension/ai-docs/src/generators/components.ts
|
|
341
|
+
var generateComponents = () => {
|
|
342
|
+
const fm = frontmatter({
|
|
343
|
+
root: false,
|
|
344
|
+
targets: ["*"],
|
|
345
|
+
description: "Available UI components with allowed attributes per tag",
|
|
346
|
+
globs: ["packages/extension/src/**/*.tsx"]
|
|
347
|
+
});
|
|
348
|
+
const componentLines = [];
|
|
349
|
+
for (const tag of UI_TAGS) {
|
|
350
|
+
const attrs = UI_TAG_ATTRIBUTES[tag];
|
|
351
|
+
const name = tagToComponentName(tag);
|
|
352
|
+
componentLines.push(`### \`<ui.${name}>\` (\`${tag}\`)`);
|
|
353
|
+
componentLines.push(`Allowed attributes: ${attrs.map((a2) => `\`${a2}\``).join(", ")}`);
|
|
354
|
+
componentLines.push("");
|
|
355
|
+
}
|
|
356
|
+
const iconList = ALLOWED_ICONS.map((icon) => `\`${icon}\``).join(", ");
|
|
357
|
+
return `${fm}
|
|
358
|
+
|
|
359
|
+
# UI Components
|
|
360
|
+
|
|
361
|
+
Access all components via the \`ui.*\` namespace:
|
|
362
|
+
\`\`\`tsx
|
|
363
|
+
import { ui } from '@stackable-labs/sdk-extension-react'
|
|
364
|
+
\`\`\`
|
|
365
|
+
|
|
366
|
+
**${UI_TAGS.length} available components** \u2014 only use the attributes listed below for each component.
|
|
367
|
+
|
|
368
|
+
${componentLines.join("\n")}
|
|
369
|
+
## Available Icons (${ALLOWED_ICONS.length})
|
|
370
|
+
|
|
371
|
+
Use with \`<ui.Icon name="icon-name" />\`. Valid icon names:
|
|
372
|
+
${iconList}
|
|
373
|
+
`;
|
|
374
|
+
};
|
|
375
|
+
var generateIconReference = () => {
|
|
376
|
+
const iconList = ALLOWED_ICONS.map((icon) => `- \`${icon}\``).join("\n");
|
|
377
|
+
return `# Available Icons
|
|
378
|
+
|
|
379
|
+
Use with \`<ui.Icon name="icon-name" />\`.
|
|
380
|
+
|
|
381
|
+
${iconList}
|
|
382
|
+
`;
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
// ../../sdk/extension/ai-docs/src/snippets/hooks.ts
|
|
386
|
+
var HOOK_SNIPPETS = {
|
|
387
|
+
"events:identity": `import { useIdentityEvent } from '@stackable-labs/sdk-extension-react'
|
|
388
|
+
|
|
389
|
+
useIdentityEvent('login', (event) => {
|
|
390
|
+
console.log('User logged in:', event.data.state.user?.email)
|
|
391
|
+
})
|
|
392
|
+
useIdentityEvent('logout', () => {
|
|
393
|
+
console.log('User logged out')
|
|
394
|
+
})`,
|
|
395
|
+
"events:messaging": `import { useMessagingEvent } from '@stackable-labs/sdk-extension-react'
|
|
396
|
+
|
|
397
|
+
useMessagingEvent('postback:Buy Now', (event) => {
|
|
398
|
+
console.log('Postback:', event.data.actionName, event.data.conversationId)
|
|
399
|
+
})`,
|
|
400
|
+
"events:activity": `import { useActivityEvent } from '@stackable-labs/sdk-extension-react'
|
|
401
|
+
|
|
402
|
+
useActivityEvent('product_view', (event) => {
|
|
403
|
+
console.log('Activity:', event.eventName, event.data)
|
|
404
|
+
})`,
|
|
405
|
+
"extend.identity": `import { useExtendIdentity } from '@stackable-labs/sdk-extension-react'
|
|
406
|
+
|
|
407
|
+
useExtendIdentity((claims) => ({
|
|
408
|
+
external_id: \`custom_\${claims.external_id}\`,
|
|
409
|
+
loyalty_tier: 'gold',
|
|
410
|
+
}))`
|
|
411
|
+
};
|
|
412
|
+
var HOOK_SNIPPETS_MEMOIZED = {
|
|
413
|
+
"events:messaging": `import { useCallback } from 'react'
|
|
414
|
+
import { useMessagingEvent } from '@stackable-labs/sdk-extension-react'
|
|
415
|
+
import type { MessagingEventHandler } from '@stackable-labs/sdk-extension-contracts'
|
|
416
|
+
|
|
417
|
+
const handlePostback = useCallback<MessagingEventHandler>((event) => {
|
|
418
|
+
console.log('Postback:', event.data.actionName, event.data.conversationId)
|
|
419
|
+
}, [])
|
|
420
|
+
useMessagingEvent('postback:Buy Now', handlePostback)`,
|
|
421
|
+
"extend.identity": `import { useCallback } from 'react'
|
|
422
|
+
import { useExtendIdentity } from '@stackable-labs/sdk-extension-react'
|
|
423
|
+
import type { ExtendIdentityHandler } from '@stackable-labs/sdk-extension-contracts'
|
|
424
|
+
|
|
425
|
+
const handleExtend = useCallback<ExtendIdentityHandler>((claims) => ({
|
|
426
|
+
external_id: \`custom_\${claims.external_id}\`,
|
|
427
|
+
loyalty_tier: 'gold',
|
|
428
|
+
}), [])
|
|
429
|
+
useExtendIdentity(handleExtend)`
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
// ../../sdk/extension/ai-docs/src/generators/capabilities.ts
|
|
433
|
+
var identityEventTypes = Object.values(IDENTITY_EVENTS).map((e) => `'${e}'`).join(" | ");
|
|
434
|
+
var activityEventTypes = Object.values(ACTIVITY_EVENTS).map((e) => `'${e}'`).join(" | ");
|
|
435
|
+
var generateCapabilities = () => {
|
|
436
|
+
const fm = frontmatter({
|
|
437
|
+
root: false,
|
|
438
|
+
targets: ["*"],
|
|
439
|
+
description: "Extension capabilities: data.query, data.fetch, context.read, actions.toast, actions.invoke, extend:identity, events:identity, events:messaging, events:activity",
|
|
440
|
+
globs: ["packages/extension/src/**/*.tsx", "packages/extension/src/**/*.ts"]
|
|
441
|
+
});
|
|
442
|
+
return `${fm}
|
|
443
|
+
|
|
444
|
+
# Capabilities
|
|
445
|
+
|
|
446
|
+
Access capabilities via the \`useCapabilities()\` hook:
|
|
447
|
+
\`\`\`tsx
|
|
448
|
+
const capabilities = useCapabilities()
|
|
449
|
+
\`\`\`
|
|
450
|
+
|
|
451
|
+
## data.query \u2014 Host-Mediated Requests
|
|
452
|
+
The host handles the API call. Extension sends an action name + params, host returns data.
|
|
453
|
+
- **Permission required:** \`data:query\`
|
|
454
|
+
- **Usage:** \`capabilities.data.query<T>(payload: ApiRequest): Promise<T>\`
|
|
455
|
+
- **ApiRequest shape:** \`{ action: string; [key: string]: unknown }\`
|
|
456
|
+
- **When to use:** When the host application handles the API integration
|
|
457
|
+
|
|
458
|
+
\`\`\`tsx
|
|
459
|
+
const result = await capabilities.data.query<Customer>({
|
|
460
|
+
action: 'getCustomer',
|
|
461
|
+
customerId: '123',
|
|
462
|
+
})
|
|
463
|
+
\`\`\`
|
|
464
|
+
|
|
465
|
+
## data.fetch \u2014 Direct HTTP from Sandbox
|
|
466
|
+
Extension makes HTTP requests directly. Domain must be in \`allowedDomains\` in manifest.
|
|
467
|
+
- **Permission required:** \`data:fetch\`
|
|
468
|
+
- **Usage:** \`capabilities.data.fetch(url: string, init?: FetchRequestInit): Promise<FetchResponse>\`
|
|
469
|
+
- **FetchRequestInit:** \`{ method?: 'GET'|'POST'|'PUT'|'PATCH'|'DELETE', headers?: Record<string,string>, body?: unknown }\`
|
|
470
|
+
- **FetchResponse:** \`{ status: number, ok: boolean, data: unknown }\`
|
|
471
|
+
- **When to use:** When the extension calls external APIs directly
|
|
472
|
+
|
|
473
|
+
\`\`\`tsx
|
|
474
|
+
const result = await capabilities.data.fetch('https://api.example.com/data', {
|
|
475
|
+
method: 'GET',
|
|
476
|
+
headers: { 'Authorization': 'Bearer token' },
|
|
477
|
+
})
|
|
478
|
+
if (!result.ok) throw new Error(\`Request failed: \${result.status}\`)
|
|
479
|
+
const data = result.data as MyType
|
|
480
|
+
\`\`\`
|
|
481
|
+
|
|
482
|
+
## context.read \u2014 Read Host Context
|
|
483
|
+
Read host-provided context (customer ID, email, etc.).
|
|
484
|
+
- **Permission required:** \`context:read\`
|
|
485
|
+
- **Usage:** \`capabilities.context.read(): Promise<ContextData>\`
|
|
486
|
+
- **ContextData shape:** \`{ customerId?: string, customerEmail?: string, [key: string]: unknown }\`
|
|
487
|
+
- **Convenience hook:** \`useContextData()\` returns \`ContextData & { loading: boolean }\`
|
|
488
|
+
|
|
489
|
+
\`\`\`tsx
|
|
490
|
+
// Preferred: use the hook
|
|
491
|
+
const { loading, customerId, customerEmail } = useContextData()
|
|
492
|
+
|
|
493
|
+
// Alternative: use the capability directly
|
|
494
|
+
const context = await capabilities.context.read()
|
|
495
|
+
\`\`\`
|
|
496
|
+
|
|
497
|
+
## actions.toast \u2014 Show Toast Notifications
|
|
498
|
+
Display a toast notification in the host UI.
|
|
499
|
+
- **Permission required:** \`actions:toast\`
|
|
500
|
+
- **Usage:** \`capabilities.actions.toast(payload: ToastPayload): Promise<void>\`
|
|
501
|
+
- **ToastPayload:** \`{ message: string, type?: 'success'|'error'|'info'|'warning', duration?: number }\`
|
|
502
|
+
|
|
503
|
+
\`\`\`tsx
|
|
504
|
+
capabilities.actions.toast({ message: 'Saved!', type: 'success' })
|
|
505
|
+
\`\`\`
|
|
506
|
+
|
|
507
|
+
## actions.invoke \u2014 Invoke Host Actions
|
|
508
|
+
Trigger host-defined actions (e.g., open a new conversation, set conversation tags/fields).
|
|
509
|
+
- **Permission required:** \`actions:invoke\`
|
|
510
|
+
- **Usage:** \`capabilities.actions.invoke<T>(action: string, payload?: Record<string, unknown>): Promise<T>\`
|
|
511
|
+
- **Available actions:**
|
|
512
|
+
- \`'newConversation'\` \u2014 start a new Zendesk conversation (optionally with tags/fields)
|
|
513
|
+
- \`'setConversationTags'\` \u2014 set tags on the current/next conversation
|
|
514
|
+
- \`'setConversationFields'\` \u2014 set custom fields on the current/next conversation
|
|
515
|
+
- \`'open'\` / \`'close'\` / \`'show'\` / \`'hide'\` \u2014 control the Zendesk messenger widget
|
|
516
|
+
|
|
517
|
+
\`\`\`tsx
|
|
518
|
+
// New conversation with tags and fields
|
|
519
|
+
await capabilities.actions.invoke('newConversation', {
|
|
520
|
+
tags: ['stackable', 'order-lookup'],
|
|
521
|
+
fields: [{ id: 'stackable_action', value: 'order_status' }],
|
|
522
|
+
metadata: { orderId: '12345' },
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
// Standalone: set tags on current/next conversation
|
|
526
|
+
await capabilities.actions.invoke('setConversationTags', ['escalated', 'order-issue'])
|
|
527
|
+
|
|
528
|
+
// Standalone: set custom fields
|
|
529
|
+
await capabilities.actions.invoke('setConversationFields', [
|
|
530
|
+
{ id: 'order_status', value: 'shipped' },
|
|
531
|
+
])
|
|
532
|
+
\`\`\`
|
|
533
|
+
|
|
534
|
+
**Zendesk constraints:** Tags max 20, auto-lowercased/sanitized. Fields require \`web_widget_conversation_ticket_metadata\` feature flag. Both \`conversationTags\` and \`conversationFields\` **replace** on each call (not additive).
|
|
535
|
+
|
|
536
|
+
## events:identity \u2014 Identity Event Subscription
|
|
537
|
+
Subscribe to real-time identity events (login, logout, refresh, expired) pushed from the host.
|
|
538
|
+
- **Permission required:** \`events:identity\`
|
|
539
|
+
- **Manifest events array:** Declare specific events to listen for (e.g. \`["identity:login", "identity:logout"]\`)
|
|
540
|
+
- **Hook:** \`useIdentityEvent(eventType, handler)\`
|
|
541
|
+
- **Event types:** \`${identityEventTypes}\`
|
|
542
|
+
|
|
543
|
+
\`\`\`json
|
|
544
|
+
{
|
|
545
|
+
"permissions": ["events:identity"],
|
|
546
|
+
"events": ["identity:login", "identity:logout"]
|
|
547
|
+
}
|
|
548
|
+
\`\`\`
|
|
549
|
+
|
|
550
|
+
\`\`\`tsx
|
|
551
|
+
${HOOK_SNIPPETS["events:identity"]}
|
|
552
|
+
\`\`\`
|
|
553
|
+
|
|
554
|
+
**Note:** Identity state is also available via \`context.read()\` \u2192 \`identity\` field (requires \`context:read\`, no separate permission needed).
|
|
555
|
+
|
|
556
|
+
## events:messaging \u2014 Messaging Event Subscription
|
|
557
|
+
Subscribe to messaging events (e.g. postback button clicks) pushed from the host widget.
|
|
558
|
+
- **Permission required:** \`events:messaging\`
|
|
559
|
+
- **Manifest events array:** Declare specific events to listen for (e.g. \`["messaging:postback:Buy Now"]\`) or \`"messaging:postback"\` for all postbacks (requires elevated marketplace review)
|
|
560
|
+
- **Hook:** \`useMessagingEvent(eventType, handler)\` \u2014 \`MessagingEventHandler\` type exported for use with \`useCallback\`
|
|
561
|
+
- **Event types:** \`'postback'\` (all postbacks) or \`'postback:<actionName>'\` (specific postback)
|
|
562
|
+
- **Important:** Only \`postback\`-type buttons fire this event. The Zendesk bot builder's "Present options" creates \`reply\`-type buttons (no event). Use the Sunshine Conversations API with \`{ "type": "postback", "text": "Button Label", "payload": "..." }\` actions to create postback buttons.
|
|
563
|
+
- **actionName caveat:** The \`actionName\` in the event is the button's display **text** (e.g. \`"Add to cart"\`), NOT the postback \`payload\` string. The payload is not exposed by the Zendesk Web Widget. Design manifest \`events\` entries to match button text: \`"messaging:postback:Add to cart"\`.
|
|
564
|
+
|
|
565
|
+
\`\`\`json
|
|
566
|
+
{
|
|
567
|
+
"permissions": ["events:messaging"],
|
|
568
|
+
"events": ["messaging:postback:Add to cart", "messaging:postback:Check order"]
|
|
569
|
+
}
|
|
570
|
+
\`\`\`
|
|
571
|
+
|
|
572
|
+
\`\`\`tsx
|
|
573
|
+
${HOOK_SNIPPETS["events:messaging"]}
|
|
574
|
+
\`\`\`
|
|
575
|
+
|
|
576
|
+
## events:activity \u2014 Activity Event Subscription
|
|
577
|
+
Subscribe to host activity events (e.g. page views, clicks, purchases) pushed from the host application.
|
|
578
|
+
- **Permission required:** \`events:activity\`
|
|
579
|
+
- **Manifest events array:** Declare specific events to listen for (e.g. \`["activity:product_view", "activity:add_to_cart"]\`) \u2014 manifest uses fully-qualified strings
|
|
580
|
+
- **Hook:** \`useActivityEvent(eventType, handler)\` \u2014 \`ActivityEventHandler\` type exported for use with \`useCallback\`
|
|
581
|
+
- **Event types (domain-stripped):** \`${activityEventTypes} | '*'\`
|
|
582
|
+
- **Well-known event names:**
|
|
583
|
+
|
|
584
|
+
| Event | Example payload fields |
|
|
585
|
+
|---|---|
|
|
586
|
+
| \`page_view\` | \`{ url, title, referrer }\` |
|
|
587
|
+
| \`click\` | \`{ elementId, elementText, url }\` |
|
|
588
|
+
| \`product_view\` | \`{ productId, productName, price }\` |
|
|
589
|
+
| \`add_to_cart\` | \`{ productId, quantity, price }\` |
|
|
590
|
+
| \`purchase\` | \`{ orderId, total, currency, items }\` |
|
|
591
|
+
| \`search\` | \`{ query, resultCount }\` |
|
|
592
|
+
| \`form_submit\` | \`{ formId, formName, fields }\` |
|
|
593
|
+
|
|
594
|
+
- \`'*'\` receives ALL activity events
|
|
595
|
+
|
|
596
|
+
\`\`\`json
|
|
597
|
+
{
|
|
598
|
+
"permissions": ["events:activity"],
|
|
599
|
+
"events": ["activity:product_view", "activity:add_to_cart"]
|
|
600
|
+
}
|
|
601
|
+
\`\`\`
|
|
602
|
+
|
|
603
|
+
\`\`\`tsx
|
|
604
|
+
${HOOK_SNIPPETS["events:activity"]}
|
|
605
|
+
\`\`\`
|
|
606
|
+
|
|
607
|
+
**Generic alternative:** \`useEvent('activity:product_view', handler)\` \u2014 a cross-domain hook that accepts fully-qualified event types. Domain wildcard (e.g., \`'activity'\`) receives all events in that domain.
|
|
608
|
+
|
|
609
|
+
## extend:identity \u2014 Identity Claim Enrichment
|
|
610
|
+
Enrich identity JWT claims before signing. The host sends base claims to your extension, and you return additional claims to merge into the token.
|
|
611
|
+
- **Permission required:** \`extend:identity\`
|
|
612
|
+
- **Hook:** \`useExtendIdentity(handler)\` \u2014 \`ExtendIdentityHandler\` type exported for use with \`useCallback\`
|
|
613
|
+
- **Handler signature:** \`(claims: IdentityBaseClaims) => Record<string, unknown> | Promise<Record<string, unknown>>\`
|
|
614
|
+
- **IdentityBaseClaims:** \`{ external_id: string, email?: string, name?: string, [key: string]: unknown }\`
|
|
615
|
+
|
|
616
|
+
\`\`\`json
|
|
617
|
+
{
|
|
618
|
+
"permissions": ["extend:identity"]
|
|
619
|
+
}
|
|
620
|
+
\`\`\`
|
|
621
|
+
|
|
622
|
+
\`\`\`tsx
|
|
623
|
+
${HOOK_SNIPPETS["extend.identity"]}
|
|
624
|
+
\`\`\`
|
|
625
|
+
`;
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
// ../../sdk/extension/ai-docs/src/snippets/examples.ts
|
|
629
|
+
var EXAMPLE_SNIPPETS = {
|
|
630
|
+
// ── Structural topics ───────────────────────────────────────────────────────
|
|
631
|
+
bootstrap: `import { createExtension } from '@stackable-labs/sdk-extension-react'
|
|
632
|
+
import { Header } from './surfaces/Header'
|
|
633
|
+
import { Content } from './surfaces/Content'
|
|
634
|
+
import { Footer } from './surfaces/Footer'
|
|
635
|
+
|
|
636
|
+
const Extension = () => (
|
|
637
|
+
<>
|
|
638
|
+
<Header />
|
|
639
|
+
<Content />
|
|
640
|
+
<Footer />
|
|
641
|
+
</>
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
// NOTE: extensionId is optional \u2014 used when connected to a registered extension
|
|
645
|
+
createExtension(() => <Extension />, { extensionId: 'my-extension' })`,
|
|
646
|
+
surfaces: `import { Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
647
|
+
|
|
648
|
+
export function Header(): React.ReactElement {
|
|
649
|
+
return (
|
|
650
|
+
<Surface id="slot.header">
|
|
651
|
+
<ui.Stack direction="column" gap="2" className="p-3">
|
|
652
|
+
<ui.Text className="text-sm font-medium">Hello from slot.header</ui.Text>
|
|
653
|
+
</ui.Stack>
|
|
654
|
+
</Surface>
|
|
655
|
+
)
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
export function Content(): React.ReactElement {
|
|
659
|
+
return (
|
|
660
|
+
<Surface id="slot.content">
|
|
661
|
+
<ui.Stack direction="column" gap="3" className="p-4">
|
|
662
|
+
<ui.Heading level="3">Content Surface</ui.Heading>
|
|
663
|
+
<ui.Text>Rendered inside the host at slot.content.</ui.Text>
|
|
664
|
+
</ui.Stack>
|
|
665
|
+
</Surface>
|
|
666
|
+
)
|
|
667
|
+
}`,
|
|
668
|
+
store: `import { createStore, useStore, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
669
|
+
|
|
670
|
+
// store.ts
|
|
671
|
+
type ViewState = { type: 'list' } | { type: 'detail'; id: string }
|
|
672
|
+
|
|
673
|
+
interface AppState {
|
|
674
|
+
viewState: ViewState
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
export const appStore = createStore<AppState>({
|
|
678
|
+
viewState: { type: 'list' },
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
// Content.tsx
|
|
682
|
+
export function Content(): React.ReactElement {
|
|
683
|
+
const viewState = useStore(appStore, (s) => s.viewState)
|
|
684
|
+
|
|
685
|
+
const goToDetail = (id: string) => appStore.set({ viewState: { type: 'detail', id } })
|
|
686
|
+
const goBack = () => appStore.set({ viewState: { type: 'list' } })
|
|
687
|
+
|
|
688
|
+
return (
|
|
689
|
+
<Surface id="slot.content">
|
|
690
|
+
{viewState.type === 'list' && (
|
|
691
|
+
<ui.Button onClick={() => goToDetail('abc123')}>View Detail</ui.Button>
|
|
692
|
+
)}
|
|
693
|
+
{viewState.type === 'detail' && (
|
|
694
|
+
<ui.Stack direction="column" gap="2">
|
|
695
|
+
<ui.Text>Viewing: {viewState.id}</ui.Text>
|
|
696
|
+
<ui.Button onClick={goBack}>Back</ui.Button>
|
|
697
|
+
</ui.Stack>
|
|
698
|
+
)}
|
|
699
|
+
</Surface>
|
|
700
|
+
)
|
|
701
|
+
}`,
|
|
702
|
+
loading: `import { useContextData, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
703
|
+
|
|
704
|
+
export function Content(): React.ReactElement {
|
|
705
|
+
const { loading, customerId } = useContextData()
|
|
706
|
+
|
|
707
|
+
if (loading) {
|
|
708
|
+
return (
|
|
709
|
+
<Surface id="slot.content">
|
|
710
|
+
<ui.Skeleton />
|
|
711
|
+
</Surface>
|
|
712
|
+
)
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (!customerId) {
|
|
716
|
+
return (
|
|
717
|
+
<Surface id="slot.content">
|
|
718
|
+
<ui.Stack direction="column" gap="1" className="p-4 items-center">
|
|
719
|
+
<ui.Icon name="user" size="sm" />
|
|
720
|
+
<ui.Text className="text-xs text-muted-foreground">No customer selected</ui.Text>
|
|
721
|
+
</ui.Stack>
|
|
722
|
+
</Surface>
|
|
723
|
+
)
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
return (
|
|
727
|
+
<Surface id="slot.content">
|
|
728
|
+
<ui.Text className="text-sm">Customer: {customerId}</ui.Text>
|
|
729
|
+
</Surface>
|
|
730
|
+
)
|
|
731
|
+
}`,
|
|
732
|
+
surfaceContext: `import { useSurfaceContext, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
733
|
+
import type { ContextData } from '@stackable-labs/sdk-extension-contracts'
|
|
734
|
+
|
|
735
|
+
export function Header(): React.ReactElement {
|
|
736
|
+
// Lower-level hook \u2014 reads host-pushed context for this specific surface
|
|
737
|
+
const context = useSurfaceContext() as ContextData
|
|
738
|
+
|
|
739
|
+
return (
|
|
740
|
+
<Surface id="slot.header">
|
|
741
|
+
<ui.Text className="text-xs">{context.customerId ?? 'No customer'}</ui.Text>
|
|
742
|
+
</Surface>
|
|
743
|
+
)
|
|
744
|
+
}`,
|
|
745
|
+
// ── Per-capability example snippets ─────────────────────────────────────────
|
|
746
|
+
"context.read": `import { useContextData, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
747
|
+
|
|
748
|
+
export function Header(): React.ReactElement {
|
|
749
|
+
const { loading, customerId, customerEmail } = useContextData()
|
|
750
|
+
|
|
751
|
+
if (loading) {
|
|
752
|
+
return (
|
|
753
|
+
<Surface id="slot.header">
|
|
754
|
+
<ui.Text className="text-xs text-muted-foreground">Loading...</ui.Text>
|
|
755
|
+
</Surface>
|
|
756
|
+
)
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
return (
|
|
760
|
+
<Surface id="slot.header">
|
|
761
|
+
<ui.Stack direction="column" gap="1">
|
|
762
|
+
<ui.Text className="text-xs font-semibold">{customerEmail}</ui.Text>
|
|
763
|
+
<ui.Text className="text-xs text-muted-foreground">ID: {customerId}</ui.Text>
|
|
764
|
+
</ui.Stack>
|
|
765
|
+
</Surface>
|
|
766
|
+
)
|
|
767
|
+
}`,
|
|
768
|
+
"data.query": `import { useCapabilities, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
769
|
+
import { useState } from 'react'
|
|
770
|
+
import type { ApiRequest } from '@stackable-labs/sdk-extension-contracts'
|
|
771
|
+
|
|
772
|
+
export function Content(): React.ReactElement {
|
|
773
|
+
const capabilities = useCapabilities()
|
|
774
|
+
const [data, setData] = useState<unknown>(null)
|
|
775
|
+
|
|
776
|
+
const handleQuery = async () => {
|
|
777
|
+
const payload: ApiRequest = {
|
|
778
|
+
action: 'getCustomer',
|
|
779
|
+
customerId: '123',
|
|
780
|
+
}
|
|
781
|
+
const result = await capabilities.data.query<{ name: string }>(payload)
|
|
782
|
+
setData(result)
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
return (
|
|
786
|
+
<Surface id="slot.content">
|
|
787
|
+
<ui.Button onClick={handleQuery}>Fetch Data</ui.Button>
|
|
788
|
+
</Surface>
|
|
789
|
+
)
|
|
790
|
+
}`,
|
|
791
|
+
"data.fetch": `import { useCapabilities, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
792
|
+
import { useState } from 'react'
|
|
793
|
+
import type { FetchResponse } from '@stackable-labs/sdk-extension-contracts'
|
|
794
|
+
|
|
795
|
+
export function Content(): React.ReactElement {
|
|
796
|
+
const capabilities = useCapabilities()
|
|
797
|
+
const [data, setData] = useState<unknown>(null)
|
|
798
|
+
|
|
799
|
+
const handleGet = async () => {
|
|
800
|
+
const result: FetchResponse = await capabilities.data.fetch('https://api.myservice.com/orders')
|
|
801
|
+
if (result.ok) setData(result.data)
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const handlePost = async () => {
|
|
805
|
+
const result: FetchResponse = await capabilities.data.fetch(
|
|
806
|
+
'https://api.myservice.com/orders',
|
|
807
|
+
{ method: 'POST', body: { limit: 10 } }
|
|
808
|
+
)
|
|
809
|
+
if (result.ok) setData(result.data)
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
return (
|
|
813
|
+
<Surface id="slot.content">
|
|
814
|
+
<ui.Stack gap="2">
|
|
815
|
+
<ui.Button onClick={handleGet}>GET Orders</ui.Button>
|
|
816
|
+
<ui.Button onClick={handlePost}>POST Orders</ui.Button>
|
|
817
|
+
</ui.Stack>
|
|
818
|
+
</Surface>
|
|
819
|
+
)
|
|
820
|
+
}`,
|
|
821
|
+
"actions.toast": `import { useCapabilities, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
822
|
+
|
|
823
|
+
export function Content(): React.ReactElement {
|
|
824
|
+
const capabilities = useCapabilities()
|
|
825
|
+
|
|
826
|
+
const showToast = async () => {
|
|
827
|
+
await capabilities.actions.toast({ type: 'success', message: 'Done!' })
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
return (
|
|
831
|
+
<Surface id="slot.content">
|
|
832
|
+
<ui.Button onClick={showToast}>Show Toast</ui.Button>
|
|
833
|
+
</Surface>
|
|
834
|
+
)
|
|
835
|
+
}`,
|
|
836
|
+
"actions.invoke": `import { useCapabilities, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
837
|
+
|
|
838
|
+
export function Content(): React.ReactElement {
|
|
839
|
+
const capabilities = useCapabilities()
|
|
840
|
+
|
|
841
|
+
const newConversation = async () => {
|
|
842
|
+
await capabilities.actions.invoke('newConversation', {
|
|
843
|
+
tags: ['stackable', 'order-lookup'],
|
|
844
|
+
fields: [{ id: 'stackable_action', value: 'order_status' }],
|
|
845
|
+
metadata: { orderId: '12345' },
|
|
846
|
+
})
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
const setTags = async () => {
|
|
850
|
+
await capabilities.actions.invoke('setConversationTags', ['escalated', 'order-issue'])
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
return (
|
|
854
|
+
<Surface id="slot.content">
|
|
855
|
+
<ui.Stack gap="2">
|
|
856
|
+
<ui.Button onClick={newConversation}>New Conversation</ui.Button>
|
|
857
|
+
<ui.Button onClick={setTags}>Set Tags</ui.Button>
|
|
858
|
+
</ui.Stack>
|
|
859
|
+
</Surface>
|
|
860
|
+
)
|
|
861
|
+
}`,
|
|
862
|
+
"extend.identity": `import { useExtendIdentity } from '@stackable-labs/sdk-extension-react'
|
|
863
|
+
import type { ExtendIdentityHandler } from '@stackable-labs/sdk-extension-contracts'
|
|
864
|
+
|
|
865
|
+
// Enrich identity JWT claims before signing.
|
|
866
|
+
// The host sends base claims (external_id, email, name),
|
|
867
|
+
// and your handler returns additional claims to merge.
|
|
868
|
+
useExtendIdentity((claims) => ({
|
|
869
|
+
external_id: \`shopify_\${claims.external_id}\`,
|
|
870
|
+
loyalty_tier: 'gold',
|
|
871
|
+
}))`,
|
|
872
|
+
// ── Per-event example snippets ─────────────────────────────────────────────
|
|
873
|
+
"events:identity": `import { useIdentityEvent, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
874
|
+
import type { IdentityEvent } from '@stackable-labs/sdk-extension-contracts'
|
|
875
|
+
import { useState } from 'react'
|
|
876
|
+
|
|
877
|
+
export function Header(): React.ReactElement {
|
|
878
|
+
const [user, setUser] = useState<string | null>(null)
|
|
879
|
+
|
|
880
|
+
useIdentityEvent('login', (event: IdentityEvent) => {
|
|
881
|
+
setUser(event.data.state.user?.email ?? null)
|
|
882
|
+
})
|
|
883
|
+
|
|
884
|
+
useIdentityEvent('logout', () => {
|
|
885
|
+
setUser(null)
|
|
886
|
+
})
|
|
887
|
+
|
|
888
|
+
return (
|
|
889
|
+
<Surface id="slot.header">
|
|
890
|
+
<ui.Text className="text-xs">{user ?? 'Not logged in'}</ui.Text>
|
|
891
|
+
</Surface>
|
|
892
|
+
)
|
|
893
|
+
}`,
|
|
894
|
+
"events:messaging": `import { useMessagingEvent, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
895
|
+
import type { MessagingEventHandler } from '@stackable-labs/sdk-extension-contracts'
|
|
896
|
+
import { useState } from 'react'
|
|
897
|
+
|
|
898
|
+
export function Content(): React.ReactElement {
|
|
899
|
+
const [lastPostback, setLastPostback] = useState<string | null>(null)
|
|
900
|
+
|
|
901
|
+
useMessagingEvent('postback', (event) => {
|
|
902
|
+
setLastPostback(event.data.actionName)
|
|
903
|
+
})
|
|
904
|
+
|
|
905
|
+
return (
|
|
906
|
+
<Surface id="slot.content">
|
|
907
|
+
<ui.Text className="text-xs">{lastPostback ?? 'No postbacks yet'}</ui.Text>
|
|
908
|
+
</Surface>
|
|
909
|
+
)
|
|
910
|
+
}`,
|
|
911
|
+
"events:activity": `import { useActivityEvent, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
912
|
+
import type { ActivityEventHandler } from '@stackable-labs/sdk-extension-contracts'
|
|
913
|
+
import { useState } from 'react'
|
|
914
|
+
|
|
915
|
+
export function Content(): React.ReactElement {
|
|
916
|
+
const [lastEvent, setLastEvent] = useState<string | null>(null)
|
|
917
|
+
|
|
918
|
+
useActivityEvent('page_view', (event) => {
|
|
919
|
+
setLastEvent(event.data.url as string)
|
|
920
|
+
})
|
|
921
|
+
|
|
922
|
+
return (
|
|
923
|
+
<Surface id="slot.content">
|
|
924
|
+
<ui.Text className="text-xs">{lastEvent ?? 'No activity yet'}</ui.Text>
|
|
925
|
+
</Surface>
|
|
926
|
+
)
|
|
927
|
+
}`
|
|
928
|
+
};
|
|
929
|
+
|
|
930
|
+
// ../../sdk/extension/ai-docs/src/generators/hooks-and-api.ts
|
|
931
|
+
var stripImports = (snippet) => snippet.split("\n").filter((line) => !line.startsWith("import ")).join("\n").trim();
|
|
932
|
+
var identityEventTypes2 = Object.values(IDENTITY_EVENTS).map((e) => `'${e}'`).join(" | ");
|
|
933
|
+
var activityEventTypes2 = Object.values(ACTIVITY_EVENTS).map((e) => `'${e}'`).join(" | ");
|
|
934
|
+
var generateHooksAndApi = () => {
|
|
935
|
+
const fm = frontmatter({
|
|
936
|
+
root: false,
|
|
937
|
+
targets: ["*"],
|
|
938
|
+
description: "Extension hooks and API reference: createExtension, Surface, useCapabilities, createStore",
|
|
939
|
+
globs: ["packages/extension/src/**/*.tsx", "packages/extension/src/**/*.ts"]
|
|
940
|
+
});
|
|
941
|
+
return `${fm}
|
|
942
|
+
|
|
943
|
+
# Hooks & API Reference
|
|
944
|
+
|
|
945
|
+
All imports from \`@stackable-labs/sdk-extension-react\`.
|
|
946
|
+
|
|
947
|
+
## createExtension(factory, options?)
|
|
948
|
+
Bootstrap the extension runtime. Call once in \`src/index.tsx\`.
|
|
949
|
+
- \`factory: () => React.ReactElement\` \u2014 render function returning all surfaces
|
|
950
|
+
- \`options?: { extensionId?: string }\` \u2014 extension identifier
|
|
951
|
+
|
|
952
|
+
\`\`\`tsx
|
|
953
|
+
${EXAMPLE_SNIPPETS.bootstrap}
|
|
954
|
+
\`\`\`
|
|
955
|
+
|
|
956
|
+
## Surface component
|
|
957
|
+
Wraps content for a target slot. The \`id\` must match a target in \`manifest.json\`.
|
|
958
|
+
- \`<Surface id="slot.content">...</Surface>\`
|
|
959
|
+
|
|
960
|
+
## useCapabilities()
|
|
961
|
+
Returns the capabilities object for calling host-mediated APIs.
|
|
962
|
+
\`\`\`tsx
|
|
963
|
+
const capabilities = useCapabilities()
|
|
964
|
+
// capabilities.context.read() \u2014 includes identity state in response
|
|
965
|
+
// capabilities.data.query(payload)
|
|
966
|
+
// capabilities.data.fetch(url, init?)
|
|
967
|
+
// capabilities.actions.toast(payload)
|
|
968
|
+
// capabilities.actions.invoke(action, payload?) \u2014 actions: newConversation, setConversationTags, setConversationFields, open, close, show, hide
|
|
969
|
+
// capabilities.extend.identity(payload) \u2014 enrich identity claims (prefer useExtendIdentity hook)
|
|
970
|
+
\`\`\`
|
|
971
|
+
|
|
972
|
+
## useStore(store, selector?)
|
|
973
|
+
Subscribe to a shared store. Re-renders when the selected state changes.
|
|
974
|
+
\`\`\`tsx
|
|
975
|
+
const viewState = useStore(appStore, (s) => s.viewState)
|
|
976
|
+
\`\`\`
|
|
977
|
+
|
|
978
|
+
## useContextData()
|
|
979
|
+
Reads host-provided context. Returns \`{ loading, customerId, customerEmail, ... }\`.
|
|
980
|
+
\`\`\`tsx
|
|
981
|
+
const { loading, customerId, customerEmail } = useContextData()
|
|
982
|
+
\`\`\`
|
|
983
|
+
|
|
984
|
+
## useSurfaceContext()
|
|
985
|
+
Returns host-provided context specific to the current surface slot.
|
|
986
|
+
\`\`\`tsx
|
|
987
|
+
const surfaceContext = useSurfaceContext()
|
|
988
|
+
\`\`\`
|
|
989
|
+
|
|
990
|
+
## useExtension()
|
|
991
|
+
Returns extension-level context.
|
|
992
|
+
\`\`\`tsx
|
|
993
|
+
const { extensionId } = useExtension()
|
|
994
|
+
\`\`\`
|
|
995
|
+
|
|
996
|
+
## createStore(initialState)
|
|
997
|
+
Create a shared store for cross-surface state coordination.
|
|
998
|
+
\`\`\`tsx
|
|
999
|
+
const appStore = createStore<AppState>({ viewState: { type: 'menu' } })
|
|
1000
|
+
\`\`\`
|
|
1001
|
+
|
|
1002
|
+
### Store\\<T\\> interface
|
|
1003
|
+
- \`get(): T\` \u2014 read current state
|
|
1004
|
+
- \`set(partial: Partial<T>): void\` \u2014 merge partial state update
|
|
1005
|
+
- \`subscribe(listener: (state: T) => void): () => void\` \u2014 subscribe, returns unsubscribe fn
|
|
1006
|
+
|
|
1007
|
+
## useIdentityEvent(eventType, handler)
|
|
1008
|
+
Subscribe to identity events pushed from the host. Requires \`events:identity\` permission and matching entries in manifest \`events\` array.
|
|
1009
|
+
- \`eventType: ${identityEventTypes2}\`
|
|
1010
|
+
- \`handler: (event: IdentityEvent) => void\`
|
|
1011
|
+
- \`IdentityEvent: { eventName: IdentityEventType, data: { state: IdentityState, timestamp: string } }\`
|
|
1012
|
+
- \`IdentityState: { authenticated: boolean, user: UserIdentity | null, expiresAt?: string }\`
|
|
1013
|
+
|
|
1014
|
+
\`\`\`tsx
|
|
1015
|
+
${stripImports(HOOK_SNIPPETS["events:identity"])}
|
|
1016
|
+
\`\`\`
|
|
1017
|
+
|
|
1018
|
+
## useMessagingEvent(eventType, handler)
|
|
1019
|
+
Subscribe to messaging events (e.g. postback button clicks) pushed from the host widget. Requires \`events:messaging\` permission and matching entries in manifest \`events\` array.
|
|
1020
|
+
- \`eventType: 'postback' | 'postback:<actionName>'\`
|
|
1021
|
+
- \`handler: MessagingEventHandler\` \u2014 \`(event: MessagingEvent) => void\`
|
|
1022
|
+
- \`MessagingPostbackEvent: { eventName: 'postback', data: { actionName: string, conversationId: string, timestamp: string } }\`
|
|
1023
|
+
- \`'postback'\` receives ALL postback events (requires elevated marketplace review)
|
|
1024
|
+
- \`'postback:<actionName>'\` receives only events matching the specific actionName
|
|
1025
|
+
|
|
1026
|
+
\`\`\`tsx
|
|
1027
|
+
${stripImports(HOOK_SNIPPETS["events:messaging"])}
|
|
1028
|
+
\`\`\`
|
|
1029
|
+
|
|
1030
|
+
With \`useCallback\` (for memoized handlers):
|
|
1031
|
+
\`\`\`tsx
|
|
1032
|
+
${HOOK_SNIPPETS_MEMOIZED["events:messaging"]}
|
|
1033
|
+
\`\`\`
|
|
1034
|
+
|
|
1035
|
+
## useActivityEvent(eventType, handler)
|
|
1036
|
+
Subscribe to host activity events. Requires \`events:activity\` permission and matching entries in manifest \`events\` array.
|
|
1037
|
+
- \`eventType: ${activityEventTypes2} | '*'\` (domain-stripped)
|
|
1038
|
+
- \`handler: ActivityEventHandler\` \u2014 \`(event: ActivityEvent) => void\`
|
|
1039
|
+
- \`ActivityEvent: { eventName: string, data: Record<string, unknown> }\`
|
|
1040
|
+
- \`'*'\` receives ALL activity events
|
|
1041
|
+
|
|
1042
|
+
\`\`\`tsx
|
|
1043
|
+
${stripImports(HOOK_SNIPPETS["events:activity"])}
|
|
1044
|
+
\`\`\`
|
|
1045
|
+
|
|
1046
|
+
## useEvent(eventType, handler)
|
|
1047
|
+
Generic cross-domain event hook (should not be used unless absolutely required). Subscribe to any event using fully-qualified event types.
|
|
1048
|
+
- \`eventType: EventType\` \u2014 fully-qualified (e.g., \`'activity:product_view'\`, \`'identity:login'\`, \`'messaging:postback'\`)
|
|
1049
|
+
- Domain wildcard (e.g., \`'activity'\`) receives all events in that domain
|
|
1050
|
+
- \`handler: (event: BaseEvent) => void\`
|
|
1051
|
+
|
|
1052
|
+
\`\`\`tsx
|
|
1053
|
+
useEvent('activity', (event) => {
|
|
1054
|
+
console.log('Activity:', event.data)
|
|
1055
|
+
})
|
|
1056
|
+
\`\`\`
|
|
1057
|
+
|
|
1058
|
+
## useExtendIdentity(handler)
|
|
1059
|
+
Register a handler to enrich identity JWT claims before signing. Requires \`extend:identity\` permission.
|
|
1060
|
+
- \`handler: ExtendIdentityHandler\` \u2014 \`(claims: IdentityBaseClaims) => Record<string, unknown> | Promise<Record<string, unknown>>\`
|
|
1061
|
+
- \`IdentityBaseClaims: { external_id: string, email?: string, name?: string, [key: string]: unknown }\`
|
|
1062
|
+
|
|
1063
|
+
\`\`\`tsx
|
|
1064
|
+
${stripImports(HOOK_SNIPPETS["extend.identity"])}
|
|
1065
|
+
\`\`\`
|
|
1066
|
+
|
|
1067
|
+
With \`useCallback\` (for memoized handlers):
|
|
1068
|
+
\`\`\`tsx
|
|
1069
|
+
${HOOK_SNIPPETS_MEMOIZED["extend.identity"]}
|
|
1070
|
+
\`\`\`
|
|
1071
|
+
|
|
1072
|
+
## Identity via context.read()
|
|
1073
|
+
Identity state is available in the \`context.read()\` response as an \`identity\` field. Requires \`context:read\` permission (no separate identity permission needed).
|
|
1074
|
+
\`\`\`tsx
|
|
1075
|
+
const context = await capabilities.context.read()
|
|
1076
|
+
// context.identity \u2014 { authenticated, user, expiresAt? }
|
|
1077
|
+
\`\`\`
|
|
1078
|
+
`;
|
|
1079
|
+
};
|
|
1080
|
+
var generatePermissions = () => {
|
|
1081
|
+
const fm = frontmatter({
|
|
1082
|
+
root: false,
|
|
1083
|
+
targets: ["*"],
|
|
1084
|
+
description: "Permission strings, capability mapping, and target-permission conventions",
|
|
1085
|
+
globs: ["packages/extension/public/manifest.json", "packages/extension/src/**/*.ts"]
|
|
1086
|
+
});
|
|
1087
|
+
const permissionList = PERMISSIONS.map((p) => `- \`${p}\``).join("\n");
|
|
1088
|
+
const mappingLines = Object.entries(CAPABILITY_PERMISSION_MAP).map(([cap, perm]) => `| \`${cap}\` | \`${perm}\` |`).join("\n");
|
|
1089
|
+
return `${fm}
|
|
1090
|
+
|
|
1091
|
+
# Permissions
|
|
1092
|
+
|
|
1093
|
+
Declare permissions in \`manifest.json\` before using the corresponding capability.
|
|
1094
|
+
|
|
1095
|
+
## Available Permissions (${PERMISSIONS.length})
|
|
1096
|
+
|
|
1097
|
+
${permissionList}
|
|
1098
|
+
|
|
1099
|
+
## Capability \u2192 Permission Mapping
|
|
1100
|
+
|
|
1101
|
+
| Capability | Required Permission |
|
|
1102
|
+
|-----------|-------------------|
|
|
1103
|
+
${mappingLines}
|
|
1104
|
+
|
|
1105
|
+
## Target \u2192 Permission Conventions
|
|
1106
|
+
|
|
1107
|
+
Common permission sets for each surface target:
|
|
1108
|
+
|
|
1109
|
+
| Target | Typical Permissions |
|
|
1110
|
+
|--------|-------------------|
|
|
1111
|
+
| \`slot.header\` | \`context:read\` |
|
|
1112
|
+
| \`slot.content\` | \`context:read\`, \`data:query\`, \`actions:toast\`, \`actions:invoke\` |
|
|
1113
|
+
| \`slot.footer\` | (none) |
|
|
1114
|
+
| \`slot.footer-links\` | (none) |
|
|
1115
|
+
|
|
1116
|
+
Only declare permissions your extension actually uses.
|
|
1117
|
+
`;
|
|
1118
|
+
};
|
|
1119
|
+
|
|
1120
|
+
// ../../sdk/extension/ai-docs/src/generators/suggestions.ts
|
|
1121
|
+
var generateSuggestions = () => {
|
|
1122
|
+
const fm = frontmatter({
|
|
1123
|
+
root: false,
|
|
1124
|
+
targets: ["*"],
|
|
1125
|
+
description: "Contextual suggestion patterns for recommending next steps based on current extension code, capabilities, and surfaces",
|
|
1126
|
+
globs: ["packages/extension/src/**/*.tsx", "packages/extension/src/**/*.ts"]
|
|
1127
|
+
});
|
|
1128
|
+
return `${fm}
|
|
1129
|
+
|
|
1130
|
+
# Next Steps \u2014 Suggestion Patterns
|
|
1131
|
+
|
|
1132
|
+
Use these patterns to analyze the current extension and suggest 2-3 specific, actionable next steps.
|
|
1133
|
+
Suggestions should reference actual data, components, and capabilities in the extension \u2014 never generic.
|
|
1134
|
+
|
|
1135
|
+
## When to suggest
|
|
1136
|
+
|
|
1137
|
+
- After making code changes (\`updateCode\`, \`updateManifest\`)
|
|
1138
|
+
- After adding a surface, capability, or component
|
|
1139
|
+
- Do NOT suggest after answering a question or explaining code
|
|
1140
|
+
|
|
1141
|
+
## Suggestion format
|
|
1142
|
+
|
|
1143
|
+
- **Label:** 2-5 word actionable verb phrase (e.g., "Show order total", "Add loading state")
|
|
1144
|
+
- **Prompt:** Specific enough that clicking it produces a useful result. Reference actual data or code in the extension (e.g., "Add the shipping address from the data.fetch response below the order total")
|
|
1145
|
+
|
|
1146
|
+
## Pattern categories
|
|
1147
|
+
|
|
1148
|
+
### Code-aware \u2014 analyze what's in the code vs. what's possible
|
|
1149
|
+
|
|
1150
|
+
| Signal | Suggestion direction |
|
|
1151
|
+
|--------|---------------------|
|
|
1152
|
+
| Data fields fetched but not rendered | Suggest rendering unused fields (e.g., "Show order total", "Display customer email") |
|
|
1153
|
+
| Component rendered without styling refinement | Suggest layout improvements (e.g., "Add spacing to header", "Style the card layout") |
|
|
1154
|
+
| Surface exists but is sparse (few child components) | Suggest adding content (e.g., "Add status badge to header", "Include action buttons") |
|
|
1155
|
+
| No error/loading handling for async operations | Suggest robustness (e.g., "Add loading state", "Handle fetch errors") |
|
|
1156
|
+
| Hardcoded values that could come from context | Suggest dynamic data (e.g., "Use customer name from context") |
|
|
1157
|
+
|
|
1158
|
+
### Capability-aware \u2014 suggest capabilities the extension doesn't use yet
|
|
1159
|
+
|
|
1160
|
+
| Current state | Suggestion direction |
|
|
1161
|
+
|---------------|---------------------|
|
|
1162
|
+
| No capabilities wired | Suggest one relevant to the extension's apparent purpose |
|
|
1163
|
+
| Has \`data.fetch\` but no \`context.read\` | "Wire up customer context" |
|
|
1164
|
+
| Has \`data.query\` but no \`actions.toast\` | "Add success notifications" |
|
|
1165
|
+
| Uses \`context.read\` data but doesn't pass it to \`data.fetch\` | "Use context in API call" |
|
|
1166
|
+
| Has \`data.fetch\` but no error handling | "Handle API errors gracefully" |
|
|
1167
|
+
|
|
1168
|
+
### Surface-aware \u2014 suggest additional UI surfaces
|
|
1169
|
+
|
|
1170
|
+
| Current surfaces | Suggestion direction |
|
|
1171
|
+
|-----------------|---------------------|
|
|
1172
|
+
| Only \`content\` | "Add a header surface" or "Add footer actions" |
|
|
1173
|
+
| Only \`header\` | "Add a content surface" |
|
|
1174
|
+
| Has content but no \`footer-links\` | "Add quick action links" |
|
|
1175
|
+
| Has header + content but no footer | "Add footer with action buttons" |
|
|
1176
|
+
|
|
1177
|
+
### Platform-aware \u2014 suggest based on extension target
|
|
1178
|
+
|
|
1179
|
+
| Target platform | Suggestion direction |
|
|
1180
|
+
|----------------|---------------------|
|
|
1181
|
+
| Commerce (Shopify, BigCommerce) | Order data, product info, customer purchase history |
|
|
1182
|
+
| Support (Zendesk, Freshdesk) | Ticket context, conversation data, agent tools |
|
|
1183
|
+
| General | Configuration, settings, external API integrations |
|
|
1184
|
+
|
|
1185
|
+
## Quality rules
|
|
1186
|
+
|
|
1187
|
+
- Never suggest something already present in the code
|
|
1188
|
+
- Prefer suggestions that build on what the user just did (e.g., after adding a surface, suggest populating it)
|
|
1189
|
+
- Mix suggestion types: one "enhance what's there", one "add something new", one "improve quality" (loading/error states, styling)
|
|
1190
|
+
- Labels must be short enough to fit in a chip button (2-5 words)
|
|
1191
|
+
- Prompts should be one sentence that clearly describes the change
|
|
1192
|
+
|
|
1193
|
+
## Examples by context
|
|
1194
|
+
|
|
1195
|
+
| After this action | Label | Prompt |
|
|
1196
|
+
|---|---|---|
|
|
1197
|
+
| Added \`data.fetch\` to an orders API | "Show order total" | "Add the order total from the data.fetch response to the content surface" |
|
|
1198
|
+
| Added a header surface | "Add status badge" | "Add a status badge component to the header showing the current order status" |
|
|
1199
|
+
| Wired up \`context.read\` | "Use in API call" | "Pass the customerId from context.read as a parameter in the data.fetch call" |
|
|
1200
|
+
| Styled a component | "Add loading state" | "Add a loading skeleton that displays while the data.fetch call is in progress" |
|
|
1201
|
+
| Added \`data.query\` | "Show results in list" | "Render the query results as a list of cards in the content surface" |
|
|
1202
|
+
| Added a button | "Wire up action" | "Connect the button click to an actions.invoke call" |
|
|
1203
|
+
| Added error handling | "Add success toast" | "Show a success toast notification when the operation completes" |
|
|
1204
|
+
`;
|
|
1205
|
+
};
|
|
1206
|
+
|
|
1207
|
+
// ../../sdk/extension/ai-docs/src/generators/guardrails.ts
|
|
1208
|
+
var generateGuardrails = () => {
|
|
1209
|
+
const fm = frontmatter({
|
|
1210
|
+
root: false,
|
|
1211
|
+
targets: ["*"],
|
|
1212
|
+
description: "SDK constraints and rules that must always be followed",
|
|
1213
|
+
globs: ["packages/extension/src/**/*.tsx", "packages/extension/src/**/*.ts", "packages/extension/public/manifest.json"],
|
|
1214
|
+
cursor: { alwaysApply: true }
|
|
1215
|
+
});
|
|
1216
|
+
return `${fm}
|
|
1217
|
+
|
|
1218
|
+
# Guardrails
|
|
1219
|
+
|
|
1220
|
+
These rules must always be followed when writing extension code.
|
|
1221
|
+
|
|
1222
|
+
## Sandbox
|
|
1223
|
+
- Never access \`document\` or \`window.location\` directly \u2014 the extension runs in a sandboxed iframe
|
|
1224
|
+
- Never modify files in \`packages/preview/\` \u2014 the preview host is pre-configured
|
|
1225
|
+
|
|
1226
|
+
## Components
|
|
1227
|
+
- Use the \`ui.*\` namespace for components (\`<ui.Card>\`, \`<ui.Button>\`) \u2014 don't import components directly
|
|
1228
|
+
- Only use the attributes listed in the component reference \u2014 the host rejects unknown attributes
|
|
1229
|
+
- Use \`<ui.ScrollArea>\` for content that may overflow
|
|
1230
|
+
|
|
1231
|
+
## Manifest
|
|
1232
|
+
- Always declare permissions in \`manifest.json\` before using capabilities
|
|
1233
|
+
- Don't use \`data.fetch\` without adding the domain to \`allowedDomains\` in manifest
|
|
1234
|
+
- \`allowedDomains\` must be exact hostnames \u2014 no wildcards, no paths
|
|
1235
|
+
|
|
1236
|
+
## Entry Point
|
|
1237
|
+
- Don't modify \`index.html\` \u2014 the extension entry point is always \`src/index.tsx\` via \`createExtension\`
|
|
1238
|
+
|
|
1239
|
+
## State Management
|
|
1240
|
+
- Use discriminated unions for view state types, not string constants
|
|
1241
|
+
- Always handle loading states when using capabilities or context data
|
|
1242
|
+
|
|
1243
|
+
## Performance
|
|
1244
|
+
- Keep bundle size under 500KB
|
|
1245
|
+
- Use the API wrapper pattern \u2014 don't call \`data.fetch\` inline in components
|
|
1246
|
+
- Use narrow selectors with \`useStore(store, selector)\` to minimize re-renders
|
|
1247
|
+
`;
|
|
1248
|
+
};
|
|
1249
|
+
|
|
1250
|
+
// ../../sdk/extension/ai-docs/src/generators/overview.ts
|
|
1251
|
+
var generateOverview = () => {
|
|
1252
|
+
const fm = frontmatter({
|
|
1253
|
+
root: true,
|
|
1254
|
+
targets: ["*"],
|
|
1255
|
+
description: "Stackable Labs Extension SDK overview, packages, and import conventions",
|
|
1256
|
+
globs: ["packages/extension/src/**/*.tsx", "packages/extension/src/**/*.ts"],
|
|
1257
|
+
cursor: { alwaysApply: true }
|
|
1258
|
+
});
|
|
1259
|
+
const baseBody = CORE_CONTENT.replace(/^#[^\n]*\n+(?:>[^\n]*\n+)?/, "");
|
|
1260
|
+
return `${fm}
|
|
1261
|
+
|
|
1262
|
+
# Stackable Labs Extension Development
|
|
1263
|
+
|
|
1264
|
+
## SDK Packages
|
|
1265
|
+
This extension is built on the following npm packages. When generating code,
|
|
1266
|
+
read the installed source in node_modules to ensure type-accurate imports:
|
|
1267
|
+
- \`@stackable-labs/sdk-extension-react\` \u2014 hooks, createExtension, Surface, UI components
|
|
1268
|
+
- \`@stackable-labs/sdk-extension-contracts\` \u2014 TypeScript types, constants, manifest schema
|
|
1269
|
+
|
|
1270
|
+
## Import Convention
|
|
1271
|
+
Components are accessed via the \`ui.*\` namespace:
|
|
1272
|
+
\`\`\`tsx
|
|
1273
|
+
import { ui, Surface, createExtension, useCapabilities } from '@stackable-labs/sdk-extension-react'
|
|
1274
|
+
// Usage: <ui.Card>, <ui.Button>, <ui.Text>, etc.
|
|
1275
|
+
// Do NOT import components directly (e.g., import { Card } will not work)
|
|
1276
|
+
\`\`\`
|
|
1277
|
+
|
|
1278
|
+
## Project Structure
|
|
1279
|
+
\`\`\`
|
|
1280
|
+
packages/extension/
|
|
1281
|
+
public/
|
|
1282
|
+
manifest.json # Extension manifest (targets, permissions, allowedDomains)
|
|
1283
|
+
src/
|
|
1284
|
+
index.tsx # Entry point \u2014 createExtension bootstraps the runtime
|
|
1285
|
+
store.ts # Shared store for cross-surface state (createStore)
|
|
1286
|
+
surfaces/ # One file per surface (Header.tsx, Content.tsx, Footer.tsx)
|
|
1287
|
+
components/ # Feature components rendered within surfaces
|
|
1288
|
+
lib/ # API wrappers and utilities
|
|
1289
|
+
packages/preview/ # Local preview host \u2014 DO NOT MODIFY
|
|
1290
|
+
\`\`\`
|
|
1291
|
+
|
|
1292
|
+
${baseBody}
|
|
1293
|
+
`;
|
|
1294
|
+
};
|
|
1295
|
+
|
|
1296
|
+
// ../../sdk/extension/ai-docs/src/generators/patterns.ts
|
|
1297
|
+
var generatePatterns = () => {
|
|
1298
|
+
const fm = frontmatter({
|
|
1299
|
+
root: false,
|
|
1300
|
+
targets: ["*"],
|
|
1301
|
+
description: "Code patterns: store navigation, API wrapper, surface composition",
|
|
1302
|
+
globs: ["packages/extension/src/**/*.tsx", "packages/extension/src/**/*.ts"]
|
|
1303
|
+
});
|
|
1304
|
+
const sections = PATTERN_SECTIONS.map(({ path, title, code }) => `## ${title}
|
|
1305
|
+
|
|
1306
|
+
\`\`\`tsx
|
|
1307
|
+
// src/${path}
|
|
1308
|
+
${code}
|
|
1309
|
+
\`\`\``).join("\n\n");
|
|
1310
|
+
const body = `# Code Patterns
|
|
1311
|
+
|
|
1312
|
+
${sections}
|
|
1313
|
+
`;
|
|
1314
|
+
return `${fm}
|
|
1315
|
+
|
|
1316
|
+
${body}`;
|
|
1317
|
+
};
|
|
1318
|
+
|
|
1319
|
+
// ../../sdk/extension/ai-docs/src/generators/recipes.ts
|
|
1320
|
+
var generateRecipes = () => {
|
|
1321
|
+
const fm = frontmatter({
|
|
1322
|
+
root: false,
|
|
1323
|
+
targets: ["*"],
|
|
1324
|
+
description: "Reference code and recipes for common extension development tasks",
|
|
1325
|
+
globs: ["packages/extension/src/**/*.tsx", "packages/extension/src/**/*.ts", "packages/extension/public/manifest.json"]
|
|
1326
|
+
});
|
|
1327
|
+
const references = RECIPE_SECTIONS.map(({ path, title, code }) => `## Reference: ${title}
|
|
1328
|
+
|
|
1329
|
+
\`\`\`tsx
|
|
1330
|
+
// src/${path}
|
|
1331
|
+
${code}
|
|
1332
|
+
\`\`\``).join("\n\n");
|
|
1333
|
+
const body = `# Recipes
|
|
1334
|
+
|
|
1335
|
+
${references}
|
|
1336
|
+
`;
|
|
1337
|
+
return `${fm}
|
|
1338
|
+
|
|
1339
|
+
${body}`;
|
|
1340
|
+
};
|
|
1341
|
+
var SURFACE_SNIPPETS = {
|
|
1342
|
+
[SURFACE_TARGETS.HEADER]: `import { Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
1343
|
+
|
|
1344
|
+
export function Header() {
|
|
1345
|
+
return (
|
|
1346
|
+
<Surface id="slot.header">
|
|
1347
|
+
<ui.Text className="text-sm font-medium">Header content</ui.Text>
|
|
1348
|
+
</Surface>
|
|
1349
|
+
)
|
|
1350
|
+
}`,
|
|
1351
|
+
[SURFACE_TARGETS.CONTENT]: `import { Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
1352
|
+
|
|
1353
|
+
export function Content() {
|
|
1354
|
+
return (
|
|
1355
|
+
<Surface id="slot.content">
|
|
1356
|
+
<ui.Card>
|
|
1357
|
+
<ui.CardContent>
|
|
1358
|
+
<ui.Text>Extension content</ui.Text>
|
|
1359
|
+
</ui.CardContent>
|
|
1360
|
+
</ui.Card>
|
|
1361
|
+
</Surface>
|
|
1362
|
+
)
|
|
1363
|
+
}`,
|
|
1364
|
+
[SURFACE_TARGETS.FOOTER]: `import { Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
1365
|
+
|
|
1366
|
+
export function Footer() {
|
|
1367
|
+
return (
|
|
1368
|
+
<Surface id="slot.footer">
|
|
1369
|
+
<ui.Text className="text-xs">Powered by My Extension</ui.Text>
|
|
1370
|
+
</Surface>
|
|
1371
|
+
)
|
|
1372
|
+
}`,
|
|
1373
|
+
[SURFACE_TARGETS.FOOTER_LINKS]: `import { Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
1374
|
+
|
|
1375
|
+
export function FooterLinks() {
|
|
1376
|
+
return (
|
|
1377
|
+
<Surface id="slot.footer-links">
|
|
1378
|
+
<ui.FooterLink href="https://example.com">My Extension</ui.FooterLink>
|
|
1379
|
+
</Surface>
|
|
1380
|
+
)
|
|
1381
|
+
}`
|
|
1382
|
+
};
|
|
1383
|
+
|
|
1384
|
+
// ../../sdk/extension/ai-docs/src/generators/surfaces.ts
|
|
1385
|
+
var generateSurfaces = () => {
|
|
1386
|
+
const fm = frontmatter({
|
|
1387
|
+
root: false,
|
|
1388
|
+
targets: ["*"],
|
|
1389
|
+
description: "Surface types, lifecycle, and layout slots for Stackable extensions",
|
|
1390
|
+
globs: ["packages/extension/src/**/*.tsx", "packages/extension/src/**/*.ts"]
|
|
1391
|
+
});
|
|
1392
|
+
return `${fm}
|
|
1393
|
+
|
|
1394
|
+
# Surfaces
|
|
1395
|
+
|
|
1396
|
+
Surfaces are the UI slots where your extension renders content inside the host application.
|
|
1397
|
+
Each surface maps to a specific layout position and is declared as a React component using
|
|
1398
|
+
the \`<Surface>\` wrapper from \`@stackable-labs/sdk-extension-react\`.
|
|
1399
|
+
|
|
1400
|
+
## Surface Slots
|
|
1401
|
+
|
|
1402
|
+
| Slot | Target String | Typical Use |
|
|
1403
|
+
|------|--------------|-------------|
|
|
1404
|
+
| Header | \`slot.header\` | Compact status bar, quick actions, summary info |
|
|
1405
|
+
| Content | \`slot.content\` | Main extension UI \u2014 forms, data display, workflows |
|
|
1406
|
+
| Footer | \`slot.footer\` | Actions, save buttons, contextual controls |
|
|
1407
|
+
| Footer Links | \`slot.footer-links\` | Navigation links, external references |
|
|
1408
|
+
|
|
1409
|
+
## Declaring a Surface
|
|
1410
|
+
|
|
1411
|
+
Each surface is a React component wrapped in \`<Surface>\`:
|
|
1412
|
+
|
|
1413
|
+
\`\`\`tsx
|
|
1414
|
+
${SURFACE_SNIPPETS[SURFACE_TARGETS.CONTENT]}
|
|
1415
|
+
\`\`\`
|
|
1416
|
+
|
|
1417
|
+
The \`id\` prop must match a target declared in your \`manifest.json\`:
|
|
1418
|
+
\`\`\`json
|
|
1419
|
+
{
|
|
1420
|
+
"targets": ["slot.content"]
|
|
1421
|
+
}
|
|
1422
|
+
\`\`\`
|
|
1423
|
+
|
|
1424
|
+
## Registering Surfaces
|
|
1425
|
+
|
|
1426
|
+
Surfaces are composed in the entry point (\`src/index.tsx\`) via \`createExtension\`:
|
|
1427
|
+
|
|
1428
|
+
\`\`\`tsx
|
|
1429
|
+
${EXAMPLE_SNIPPETS.bootstrap}
|
|
1430
|
+
\`\`\`
|
|
1431
|
+
|
|
1432
|
+
\`createExtension\` bootstraps the extension runtime \u2014 it handles the sandboxed iframe
|
|
1433
|
+
communication, capability injection, and surface registration with the host.
|
|
1434
|
+
|
|
1435
|
+
## Surface Lifecycle
|
|
1436
|
+
|
|
1437
|
+
1. **Mount** \u2014 Host creates an iframe for the extension and loads the entry point
|
|
1438
|
+
2. **Register** \u2014 \`createExtension\` scans the rendered tree for \`<Surface>\` components
|
|
1439
|
+
3. **Render** \u2014 Each \`<Surface>\` renders its children into the matching host slot
|
|
1440
|
+
4. **Update** \u2014 Surfaces re-render when props, state, or context data changes
|
|
1441
|
+
5. **Unmount** \u2014 Host removes the extension iframe when no longer needed
|
|
1442
|
+
|
|
1443
|
+
## Multi-Surface State
|
|
1444
|
+
|
|
1445
|
+
Surfaces share state via \`createStore\` (see Store & Navigation). Each surface can read
|
|
1446
|
+
and write to the same store, enabling coordinated behavior across layout slots:
|
|
1447
|
+
|
|
1448
|
+
\`\`\`tsx
|
|
1449
|
+
import { useStore } from '@stackable-labs/sdk-extension-react'
|
|
1450
|
+
import { appStore } from '../store'
|
|
1451
|
+
|
|
1452
|
+
export const Header = () => {
|
|
1453
|
+
const viewState = useStore(appStore, (s) => s.viewState)
|
|
1454
|
+
return (
|
|
1455
|
+
<Surface id="slot.header">
|
|
1456
|
+
<ui.Text>Current view: {viewState.type}</ui.Text>
|
|
1457
|
+
</Surface>
|
|
1458
|
+
)
|
|
1459
|
+
}
|
|
1460
|
+
\`\`\`
|
|
1461
|
+
|
|
1462
|
+
## Best Practices
|
|
1463
|
+
|
|
1464
|
+
- **One surface per file** \u2014 keep surfaces in \`src/surfaces/\` with clear names (Header.tsx, Content.tsx)
|
|
1465
|
+
- **Minimal surface components** \u2014 surfaces should compose feature components from \`src/components/\`
|
|
1466
|
+
- **Always declare targets** \u2014 every \`<Surface id="...">\` must have a matching target in manifest.json
|
|
1467
|
+
- **Use ScrollArea** \u2014 wrap content surfaces in \`<ui.ScrollArea>\` for overflow handling
|
|
1468
|
+
`;
|
|
1469
|
+
};
|
|
1470
|
+
|
|
1471
|
+
// ../../sdk/extension/ai-docs/src/cli-commands.ts
|
|
1472
|
+
var DLX = "pnpm --config.dlx-cache-max-age=0 dlx";
|
|
1473
|
+
var CLI = {
|
|
1474
|
+
/** Scaffold a new extension project */
|
|
1475
|
+
create: (name = "<project-name>") => `${DLX} @stackable-labs/create-extension ${name}`,
|
|
1476
|
+
/** Start dev servers with hot reload */
|
|
1477
|
+
dev: `${DLX} @stackable-labs/cli-app-extension@latest dev`,
|
|
1478
|
+
/** Deploy the extension */
|
|
1479
|
+
deploy: `${DLX} @stackable-labs/cli-app-extension@latest deploy`,
|
|
1480
|
+
/** Validate the extension for common errors */
|
|
1481
|
+
validate: `${DLX} @stackable-labs/cli-app-extension@latest validate`,
|
|
1482
|
+
/** Scaffold from an existing extension */
|
|
1483
|
+
scaffold: `${DLX} @stackable-labs/cli-app-extension@latest scaffold`,
|
|
1484
|
+
/** Update an existing extension */
|
|
1485
|
+
update: `${DLX} @stackable-labs/cli-app-extension@latest update`
|
|
1486
|
+
};
|
|
1487
|
+
var TEMPLATE_FLAVORS = [
|
|
1488
|
+
{ name: "minimal", label: "Minimal", description: "Bare minimum \u2014 single surface, hello-world component" },
|
|
1489
|
+
{ name: "starter", label: "Starter", description: "Common patterns \u2014 store, api helpers, menu (default)" },
|
|
1490
|
+
{ name: "kitchen-sink", label: "Kitchen Sink", description: "Everything \u2014 every component, capability, surface, and hook" }
|
|
1491
|
+
];
|
|
1492
|
+
|
|
1493
|
+
// ../../sdk/extension/ai-docs/src/generators/quick-start.ts
|
|
1494
|
+
var generateQuickStart = () => {
|
|
1495
|
+
const fm = frontmatter({
|
|
1496
|
+
root: false,
|
|
1497
|
+
targets: ["*"],
|
|
1498
|
+
description: "Step-by-step guide to create, develop, and deploy your first Stackable extension",
|
|
1499
|
+
globs: ["packages/extension/src/**/*.tsx", "packages/extension/src/**/*.ts"]
|
|
1500
|
+
});
|
|
1501
|
+
return `${fm}
|
|
1502
|
+
|
|
1503
|
+
# Quick Start
|
|
1504
|
+
|
|
1505
|
+
Create, develop, and deploy your first Stackable extension in minutes.
|
|
1506
|
+
|
|
1507
|
+
## Prerequisites
|
|
1508
|
+
|
|
1509
|
+
- **Node.js** 22 or later
|
|
1510
|
+
- **pnpm** (recommended) or npm
|
|
1511
|
+
|
|
1512
|
+
## 1. Create a New Extension
|
|
1513
|
+
|
|
1514
|
+
Scaffold a new extension project:
|
|
1515
|
+
|
|
1516
|
+
\`\`\`bash
|
|
1517
|
+
${CLI.create("my-extension")}
|
|
1518
|
+
cd my-extension
|
|
1519
|
+
pnpm install
|
|
1520
|
+
\`\`\`
|
|
1521
|
+
|
|
1522
|
+
This creates a project with:
|
|
1523
|
+
- A working extension with header, content, and footer surfaces
|
|
1524
|
+
- A local preview host for development
|
|
1525
|
+
- TypeScript configuration
|
|
1526
|
+
- A manifest with default permissions and targets
|
|
1527
|
+
|
|
1528
|
+
## 2. Preview Your Extension
|
|
1529
|
+
|
|
1530
|
+
Run the Stackable CLI dev server from your project directory:
|
|
1531
|
+
|
|
1532
|
+
\`\`\`bash
|
|
1533
|
+
${CLI.dev}
|
|
1534
|
+
\`\`\`
|
|
1535
|
+
|
|
1536
|
+
> **Note:** This uses \`pnpm dlx\` to run the Stackable CLI without installing it
|
|
1537
|
+
> globally. The \`--config.dlx-cache-max-age=0\` flag ensures you always get the
|
|
1538
|
+
> latest version.
|
|
1539
|
+
|
|
1540
|
+
The dev command:
|
|
1541
|
+
1. Starts a local Vite dev server for your extension with hot reload
|
|
1542
|
+
2. Creates a public Cloudflare tunnel to your local server
|
|
1543
|
+
3. Displays a **query parameter** you can use to preview your extension
|
|
1544
|
+
|
|
1545
|
+
### Testing against your host app
|
|
1546
|
+
|
|
1547
|
+
The CLI outputs a query param like:
|
|
1548
|
+
|
|
1549
|
+
\`\`\`
|
|
1550
|
+
?_stackable_dev=ext-123%3Ahttps%3A%2F%2Fabc.trycloudflare.com
|
|
1551
|
+
\`\`\`
|
|
1552
|
+
|
|
1553
|
+
Copy this and **append it to your host application URL** to load your local extension
|
|
1554
|
+
instead of the production bundle. For example:
|
|
1555
|
+
|
|
1556
|
+
\`\`\`
|
|
1557
|
+
https://your-app.com/dashboard?_stackable_dev=ext-123%3Ahttps%3A%2F%2Fabc.trycloudflare.com
|
|
1558
|
+
\`\`\`
|
|
1559
|
+
|
|
1560
|
+
This override is **browser-session only** \u2014 no database changes, no shared state.
|
|
1561
|
+
Each developer gets isolated overrides. Changes you make locally appear immediately
|
|
1562
|
+
in the host app via hot reload.
|
|
1563
|
+
|
|
1564
|
+
Use \`--no-tunnel\` if you only want to run the local Vite dev server without a tunnel.
|
|
1565
|
+
|
|
1566
|
+
## 3. Explore the Project
|
|
1567
|
+
|
|
1568
|
+
\`\`\`
|
|
1569
|
+
my-extension/
|
|
1570
|
+
packages/
|
|
1571
|
+
extension/
|
|
1572
|
+
public/
|
|
1573
|
+
manifest.json # Targets, permissions, allowed domains
|
|
1574
|
+
src/
|
|
1575
|
+
index.tsx # Entry point \u2014 createExtension bootstraps the runtime
|
|
1576
|
+
store.ts # Shared state across surfaces
|
|
1577
|
+
surfaces/ # One component per surface slot
|
|
1578
|
+
Header.tsx
|
|
1579
|
+
Content.tsx
|
|
1580
|
+
Footer.tsx
|
|
1581
|
+
components/ # Feature components used by surfaces
|
|
1582
|
+
lib/ # API wrappers and utilities
|
|
1583
|
+
preview/ # Local preview host \u2014 DO NOT MODIFY
|
|
1584
|
+
\`\`\`
|
|
1585
|
+
|
|
1586
|
+
## 4. Make Your First Change
|
|
1587
|
+
|
|
1588
|
+
Open \`packages/extension/src/surfaces/Content.tsx\` and modify the content:
|
|
1589
|
+
|
|
1590
|
+
\`\`\`tsx
|
|
1591
|
+
import { Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
1592
|
+
|
|
1593
|
+
export const Content = () => (
|
|
1594
|
+
<Surface id="slot.content">
|
|
1595
|
+
<ui.Card>
|
|
1596
|
+
<ui.CardContent>
|
|
1597
|
+
<ui.Heading level={3}>Hello, Stackable!</ui.Heading>
|
|
1598
|
+
<ui.Text>My first extension is working.</ui.Text>
|
|
1599
|
+
</ui.CardContent>
|
|
1600
|
+
</ui.Card>
|
|
1601
|
+
</Surface>
|
|
1602
|
+
)
|
|
1603
|
+
\`\`\`
|
|
1604
|
+
|
|
1605
|
+
Save the file \u2014 the preview updates automatically.
|
|
1606
|
+
|
|
1607
|
+
## 5. Add a Capability
|
|
1608
|
+
|
|
1609
|
+
To read host context data (customer info, etc.):
|
|
1610
|
+
|
|
1611
|
+
1. Add the permission to \`manifest.json\`:
|
|
1612
|
+
\`\`\`json
|
|
1613
|
+
{
|
|
1614
|
+
"permissions": ["context:read"]
|
|
1615
|
+
}
|
|
1616
|
+
\`\`\`
|
|
1617
|
+
|
|
1618
|
+
2. Use it in a surface:
|
|
1619
|
+
\`\`\`tsx
|
|
1620
|
+
import { Surface, useContextData, ui } from '@stackable-labs/sdk-extension-react'
|
|
1621
|
+
|
|
1622
|
+
export const Content = () => {
|
|
1623
|
+
const { loading, customerId } = useContextData()
|
|
1624
|
+
|
|
1625
|
+
if (loading) {
|
|
1626
|
+
return (
|
|
1627
|
+
<Surface id="slot.content">
|
|
1628
|
+
<ui.Skeleton />
|
|
1629
|
+
</Surface>
|
|
1630
|
+
)
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
return (
|
|
1634
|
+
<Surface id="slot.content">
|
|
1635
|
+
<ui.Card>
|
|
1636
|
+
<ui.CardContent>
|
|
1637
|
+
<ui.Text>Customer: {customerId}</ui.Text>
|
|
1638
|
+
</ui.CardContent>
|
|
1639
|
+
</ui.Card>
|
|
1640
|
+
</Surface>
|
|
1641
|
+
)
|
|
1642
|
+
}
|
|
1643
|
+
\`\`\`
|
|
1644
|
+
|
|
1645
|
+
## 6. Validate & Deploy
|
|
1646
|
+
|
|
1647
|
+
Before deploying, validate your extension:
|
|
1648
|
+
|
|
1649
|
+
\`\`\`bash
|
|
1650
|
+
${CLI.validate}
|
|
1651
|
+
\`\`\`
|
|
1652
|
+
|
|
1653
|
+
This checks manifest structure, permission usage, surface targets, and import patterns.
|
|
1654
|
+
|
|
1655
|
+
When ready, deploy:
|
|
1656
|
+
|
|
1657
|
+
\`\`\`bash
|
|
1658
|
+
${CLI.deploy}
|
|
1659
|
+
\`\`\`
|
|
1660
|
+
|
|
1661
|
+
## Next Steps
|
|
1662
|
+
|
|
1663
|
+
- **[Components](/docs/reference/components)** \u2014 browse the full UI component catalog
|
|
1664
|
+
- **[Capabilities](/docs/reference/capabilities)** \u2014 learn about data.query, data.fetch, and more
|
|
1665
|
+
- **[Surfaces](/docs/reference/surfaces)** \u2014 understand surface types and layout slots
|
|
1666
|
+
- **[Patterns](/docs/reference/patterns)** \u2014 see common extension code patterns
|
|
1667
|
+
`;
|
|
1668
|
+
};
|
|
1669
|
+
|
|
1670
|
+
// ../../sdk/extension/ai-docs/src/generators/cli-reference.ts
|
|
1671
|
+
var generateCliReference = () => {
|
|
1672
|
+
const fm = frontmatter({
|
|
1673
|
+
root: false,
|
|
1674
|
+
targets: ["*"],
|
|
1675
|
+
description: "CLI commands for creating, developing, and deploying Stackable extensions",
|
|
1676
|
+
globs: ["packages/extension/src/**/*.tsx", "packages/extension/src/**/*.ts"]
|
|
1677
|
+
});
|
|
1678
|
+
const templateRows = TEMPLATE_FLAVORS.map((t) => `| \`${t.name}\` | ${t.description} |`).join("\n");
|
|
1679
|
+
return `${fm}
|
|
1680
|
+
|
|
1681
|
+
# CLI Reference
|
|
1682
|
+
|
|
1683
|
+
The Stackable CLI provides commands to create, develop, validate, and deploy extensions.
|
|
1684
|
+
|
|
1685
|
+
## create
|
|
1686
|
+
|
|
1687
|
+
Scaffold a new extension project:
|
|
1688
|
+
|
|
1689
|
+
\`\`\`bash
|
|
1690
|
+
${CLI.create()}
|
|
1691
|
+
\`\`\`
|
|
1692
|
+
|
|
1693
|
+
**Arguments:**
|
|
1694
|
+
|
|
1695
|
+
| Argument | Description |
|
|
1696
|
+
|----------|-------------|
|
|
1697
|
+
| \`project-name\` | Directory name for the new project |
|
|
1698
|
+
|
|
1699
|
+
**Options:**
|
|
1700
|
+
|
|
1701
|
+
| Flag | Description |
|
|
1702
|
+
|------|-------------|
|
|
1703
|
+
| \`--template <flavor>\` | Template flavor (see below) |
|
|
1704
|
+
| \`--app-id <id>\` | Skip App selection |
|
|
1705
|
+
| \`--extension-port <port>\` | Extension dev server port (default: 6543) |
|
|
1706
|
+
| \`--preview-port <port>\` | Preview dev server port |
|
|
1707
|
+
| \`--skip-install\` | Skip package manager install |
|
|
1708
|
+
| \`--skip-git\` | Skip git initialization |
|
|
1709
|
+
|
|
1710
|
+
**Template flavors:**
|
|
1711
|
+
|
|
1712
|
+
| Flavor | Description |
|
|
1713
|
+
|--------|-------------|
|
|
1714
|
+
${templateRows}
|
|
1715
|
+
|
|
1716
|
+
**Example:**
|
|
1717
|
+
\`\`\`bash
|
|
1718
|
+
${CLI.create("order-lookup")}
|
|
1719
|
+
cd order-lookup
|
|
1720
|
+
pnpm install
|
|
1721
|
+
\`\`\`
|
|
1722
|
+
|
|
1723
|
+
## dev
|
|
1724
|
+
|
|
1725
|
+
Start dev servers with a public tunnel for live preview:
|
|
1726
|
+
|
|
1727
|
+
\`\`\`bash
|
|
1728
|
+
${CLI.dev}
|
|
1729
|
+
\`\`\`
|
|
1730
|
+
|
|
1731
|
+
**Options:**
|
|
1732
|
+
|
|
1733
|
+
| Flag | Description |
|
|
1734
|
+
|------|-------------|
|
|
1735
|
+
| \`--dir <path>\` | Project root (default: cwd) |
|
|
1736
|
+
| \`--extension-port <port>\` | Override extension port |
|
|
1737
|
+
| \`--preview-port <port>\` | Override preview port |
|
|
1738
|
+
| \`--no-tunnel\` | Skip tunnel, just run vite dev |
|
|
1739
|
+
|
|
1740
|
+
**What it does:**
|
|
1741
|
+
1. Reads \`.env.stackable\` for cached App/Extension context (prompts if missing)
|
|
1742
|
+
2. Creates Cloudflare tunnels for the extension dev server
|
|
1743
|
+
3. Starts Vite dev servers with hot reload
|
|
1744
|
+
4. Displays a \`_stackable_dev\` query param to append to your host app URL
|
|
1745
|
+
|
|
1746
|
+
### Host App Override
|
|
1747
|
+
|
|
1748
|
+
The CLI outputs a query param like:
|
|
1749
|
+
|
|
1750
|
+
\`\`\`
|
|
1751
|
+
?_stackable_dev=ext-123%3Ahttps%3A%2F%2Fabc.trycloudflare.com
|
|
1752
|
+
\`\`\`
|
|
1753
|
+
|
|
1754
|
+
Append this to your deployed host app URL to load your local extension instead
|
|
1755
|
+
of the production bundle. The override is browser-session only \u2014 no DB changes,
|
|
1756
|
+
no shared state. Each developer gets isolated overrides.
|
|
1757
|
+
|
|
1758
|
+
## validate
|
|
1759
|
+
|
|
1760
|
+
Check your extension for common errors before deploying:
|
|
1761
|
+
|
|
1762
|
+
\`\`\`bash
|
|
1763
|
+
${CLI.validate}
|
|
1764
|
+
\`\`\`
|
|
1765
|
+
|
|
1766
|
+
**What it checks:**
|
|
1767
|
+
|
|
1768
|
+
| Check | Description |
|
|
1769
|
+
|-------|-------------|
|
|
1770
|
+
| Manifest structure | Valid JSON, required fields present |
|
|
1771
|
+
| Permission usage | Capabilities used in code have matching permissions in manifest |
|
|
1772
|
+
| Surface targets | Each \`<Surface id="...">\` has a matching target in manifest |
|
|
1773
|
+
| Import validation | Uses \`ui.*\` namespace, no direct component imports |
|
|
1774
|
+
| Domain configuration | \`data.fetch\` calls match \`allowedDomains\` entries |
|
|
1775
|
+
| Sandbox compliance | No \`document\` or \`window.location\` access |
|
|
1776
|
+
|
|
1777
|
+
**Exit codes:**
|
|
1778
|
+
- \`0\` \u2014 all checks pass
|
|
1779
|
+
- \`1\` \u2014 errors found (must fix before deploying)
|
|
1780
|
+
|
|
1781
|
+
## deploy
|
|
1782
|
+
|
|
1783
|
+
Package and deploy the extension:
|
|
1784
|
+
|
|
1785
|
+
\`\`\`bash
|
|
1786
|
+
${CLI.deploy}
|
|
1787
|
+
\`\`\`
|
|
1788
|
+
|
|
1789
|
+
**What it does:**
|
|
1790
|
+
1. Runs validation checks (same as validate)
|
|
1791
|
+
2. Builds the extension for production
|
|
1792
|
+
3. Packages the build output
|
|
1793
|
+
4. Uploads to the Stackable extension registry
|
|
1794
|
+
|
|
1795
|
+
**Prerequisites:**
|
|
1796
|
+
- All validation checks must pass
|
|
1797
|
+
- You must be authenticated (follow the prompts if not)
|
|
1798
|
+
|
|
1799
|
+
## scaffold
|
|
1800
|
+
|
|
1801
|
+
Scaffold a local project from an existing extension:
|
|
1802
|
+
|
|
1803
|
+
\`\`\`bash
|
|
1804
|
+
${CLI.scaffold} [extensionId]
|
|
1805
|
+
\`\`\`
|
|
1806
|
+
|
|
1807
|
+
**Options:**
|
|
1808
|
+
|
|
1809
|
+
| Flag | Description |
|
|
1810
|
+
|------|-------------|
|
|
1811
|
+
| \`--app-id <id>\` | App ID (auto-resolved from extensionId if omitted) |
|
|
1812
|
+
| \`--project-id <id>\` | Studio project ID (fetches files/manifest from Studio) |
|
|
1813
|
+
| \`--skip-install\` | Skip package manager install |
|
|
1814
|
+
| \`--skip-git\` | Skip git initialization |
|
|
1815
|
+
|
|
1816
|
+
## update
|
|
1817
|
+
|
|
1818
|
+
Update an existing extension:
|
|
1819
|
+
|
|
1820
|
+
\`\`\`bash
|
|
1821
|
+
${CLI.update} [extensionId]
|
|
1822
|
+
\`\`\`
|
|
1823
|
+
|
|
1824
|
+
**Options:**
|
|
1825
|
+
|
|
1826
|
+
| Flag | Description |
|
|
1827
|
+
|------|-------------|
|
|
1828
|
+
| \`--app-id <id>\` | App ID (auto-resolved from extensionId if omitted) |
|
|
1829
|
+
| \`--name <name>\` | New extension name |
|
|
1830
|
+
| \`--targets <targets>\` | Comma-separated target slots |
|
|
1831
|
+
| \`--bundle-url <url>\` | New bundle URL |
|
|
1832
|
+
| \`--enabled <bool>\` | Enable/disable extension |
|
|
1833
|
+
| \`--dir <path>\` | Project root (default: cwd) |
|
|
1834
|
+
`;
|
|
1835
|
+
};
|
|
1836
|
+
|
|
1837
|
+
// ../../sdk/extension/ai-docs/src/generators/external-apis.ts
|
|
1838
|
+
var generateExternalApis = () => {
|
|
1839
|
+
const fm = frontmatter({
|
|
1840
|
+
root: false,
|
|
1841
|
+
targets: ["*"],
|
|
1842
|
+
description: "Direct HTTP requests via data.fetch, allowedDomains configuration, and API wrapper patterns",
|
|
1843
|
+
globs: ["packages/extension/src/**/*.tsx", "packages/extension/src/**/*.ts", "packages/extension/public/manifest.json"]
|
|
1844
|
+
});
|
|
1845
|
+
return `${fm}
|
|
1846
|
+
|
|
1847
|
+
# External APIs
|
|
1848
|
+
|
|
1849
|
+
Extensions can make direct HTTP requests to external services using the \`data.fetch\`
|
|
1850
|
+
capability. All requests are proxied through the host for security \u2014 the extension
|
|
1851
|
+
never makes raw network calls from the sandbox.
|
|
1852
|
+
|
|
1853
|
+
## Setup
|
|
1854
|
+
|
|
1855
|
+
### 1. Add Permission
|
|
1856
|
+
|
|
1857
|
+
Add \`data:fetch\` to your manifest permissions:
|
|
1858
|
+
|
|
1859
|
+
\`\`\`json
|
|
1860
|
+
{
|
|
1861
|
+
"permissions": ["data:fetch"]
|
|
1862
|
+
}
|
|
1863
|
+
\`\`\`
|
|
1864
|
+
|
|
1865
|
+
### 2. Configure Allowed Domains
|
|
1866
|
+
|
|
1867
|
+
Every domain your extension calls must be listed in \`allowedDomains\`:
|
|
1868
|
+
|
|
1869
|
+
\`\`\`json
|
|
1870
|
+
{
|
|
1871
|
+
"allowedDomains": ["api.example.com", "graphql.example.com"]
|
|
1872
|
+
}
|
|
1873
|
+
\`\`\`
|
|
1874
|
+
|
|
1875
|
+
**Rules:**
|
|
1876
|
+
- Exact hostnames only \u2014 no wildcards, no paths, no protocols
|
|
1877
|
+
- The host rejects requests to unlisted domains
|
|
1878
|
+
- Add each subdomain separately (e.g., \`api.example.com\` and \`cdn.example.com\`)
|
|
1879
|
+
|
|
1880
|
+
### 3. Make Requests
|
|
1881
|
+
|
|
1882
|
+
Access \`data.fetch\` through the \`useCapabilities()\` hook:
|
|
1883
|
+
|
|
1884
|
+
\`\`\`tsx
|
|
1885
|
+
const capabilities = useCapabilities()
|
|
1886
|
+
|
|
1887
|
+
const result = await capabilities.data.fetch('https://api.example.com/data', {
|
|
1888
|
+
method: 'GET',
|
|
1889
|
+
headers: { 'Authorization': 'Bearer token' },
|
|
1890
|
+
})
|
|
1891
|
+
|
|
1892
|
+
if (!result.ok) throw new Error(\`Request failed: \${result.status}\`)
|
|
1893
|
+
const data = result.data as MyType
|
|
1894
|
+
\`\`\`
|
|
1895
|
+
|
|
1896
|
+
## API Signature
|
|
1897
|
+
|
|
1898
|
+
\`\`\`tsx
|
|
1899
|
+
capabilities.data.fetch(url: string, init?: FetchRequestInit): Promise<FetchResponse>
|
|
1900
|
+
\`\`\`
|
|
1901
|
+
|
|
1902
|
+
**FetchRequestInit:**
|
|
1903
|
+
| Field | Type | Default |
|
|
1904
|
+
|-------|------|---------|
|
|
1905
|
+
| \`method\` | \`'GET' \\| 'POST' \\| 'PUT' \\| 'PATCH' \\| 'DELETE'\` | \`'GET'\` |
|
|
1906
|
+
| \`headers\` | \`Record<string, string>\` | \`{}\` |
|
|
1907
|
+
| \`body\` | \`unknown\` | \u2014 |
|
|
1908
|
+
|
|
1909
|
+
**FetchResponse:**
|
|
1910
|
+
| Field | Type | Description |
|
|
1911
|
+
|-------|------|-------------|
|
|
1912
|
+
| \`status\` | \`number\` | HTTP status code |
|
|
1913
|
+
| \`ok\` | \`boolean\` | \`true\` if status is 2xx |
|
|
1914
|
+
| \`data\` | \`unknown\` | Parsed response body |
|
|
1915
|
+
|
|
1916
|
+
## API Wrapper Pattern
|
|
1917
|
+
|
|
1918
|
+
Create a typed wrapper in \`src/lib/api.ts\` to keep components clean:
|
|
1919
|
+
|
|
1920
|
+
\`\`\`tsx
|
|
1921
|
+
import type { Capabilities } from '@stackable-labs/sdk-extension-react'
|
|
1922
|
+
|
|
1923
|
+
const BASE_URL = 'https://api.example.com'
|
|
1924
|
+
|
|
1925
|
+
export async function fetchCustomer(
|
|
1926
|
+
capabilities: Capabilities,
|
|
1927
|
+
customerId: string
|
|
1928
|
+
): Promise<Customer> {
|
|
1929
|
+
const result = await capabilities.data.fetch(
|
|
1930
|
+
\`\${BASE_URL}/customers/\${customerId}\`
|
|
1931
|
+
)
|
|
1932
|
+
if (!result.ok) throw new Error(\`Failed to fetch customer: \${result.status}\`)
|
|
1933
|
+
return result.data as Customer
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
export async function updateCustomer(
|
|
1937
|
+
capabilities: Capabilities,
|
|
1938
|
+
customerId: string,
|
|
1939
|
+
data: Partial<Customer>
|
|
1940
|
+
): Promise<Customer> {
|
|
1941
|
+
const result = await capabilities.data.fetch(
|
|
1942
|
+
\`\${BASE_URL}/customers/\${customerId}\`,
|
|
1943
|
+
{
|
|
1944
|
+
method: 'PATCH',
|
|
1945
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1946
|
+
body: data,
|
|
1947
|
+
}
|
|
1948
|
+
)
|
|
1949
|
+
if (!result.ok) throw new Error(\`Failed to update customer: \${result.status}\`)
|
|
1950
|
+
return result.data as Customer
|
|
1951
|
+
}
|
|
1952
|
+
\`\`\`
|
|
1953
|
+
|
|
1954
|
+
Then use it in components:
|
|
1955
|
+
|
|
1956
|
+
\`\`\`tsx
|
|
1957
|
+
const capabilities = useCapabilities()
|
|
1958
|
+
const customer = await fetchCustomer(capabilities, customerId)
|
|
1959
|
+
\`\`\`
|
|
1960
|
+
|
|
1961
|
+
## data.fetch vs data.query
|
|
1962
|
+
|
|
1963
|
+
| | data.fetch | data.query |
|
|
1964
|
+
|--|-----------|-----------|
|
|
1965
|
+
| **Who handles the request** | Extension (via proxy) | Host application |
|
|
1966
|
+
| **Permission** | \`data:fetch\` | \`data:query\` |
|
|
1967
|
+
| **Domain config** | Required (\`allowedDomains\`) | Not needed |
|
|
1968
|
+
| **Use when** | Calling external APIs directly | Host provides the API integration |
|
|
1969
|
+
|
|
1970
|
+
## Error Handling
|
|
1971
|
+
|
|
1972
|
+
Always check \`result.ok\` before accessing \`result.data\`:
|
|
1973
|
+
|
|
1974
|
+
\`\`\`tsx
|
|
1975
|
+
const result = await capabilities.data.fetch(url)
|
|
1976
|
+
if (!result.ok) {
|
|
1977
|
+
capabilities.actions.toast({
|
|
1978
|
+
message: 'Request failed. Please try again.',
|
|
1979
|
+
type: 'error',
|
|
1980
|
+
})
|
|
1981
|
+
return
|
|
1982
|
+
}
|
|
1983
|
+
\`\`\`
|
|
1984
|
+
`;
|
|
1985
|
+
};
|
|
1986
|
+
|
|
1987
|
+
// ../../sdk/extension/ai-docs/src/generators/extension-studio.ts
|
|
1988
|
+
var generateExtensionStudio = () => {
|
|
1989
|
+
const fm = frontmatter({
|
|
1990
|
+
root: false,
|
|
1991
|
+
targets: ["*"],
|
|
1992
|
+
description: "Extension Studio: in-browser builder with AI assistant, component palette, and live preview",
|
|
1993
|
+
globs: ["packages/extension/src/**/*.tsx", "packages/extension/src/**/*.ts"]
|
|
1994
|
+
});
|
|
1995
|
+
return `${fm}
|
|
1996
|
+
|
|
1997
|
+
# Extension Studio
|
|
1998
|
+
|
|
1999
|
+
Extension Studio is an in-browser development environment for building Stackable
|
|
2000
|
+
extensions without local tooling. It runs inside the admin dashboard and provides
|
|
2001
|
+
a code editor, live preview, component palette, and an AI assistant \u2014 everything
|
|
2002
|
+
needed to create, iterate on, and deploy extensions from the browser.
|
|
2003
|
+
|
|
2004
|
+
## Layout
|
|
2005
|
+
|
|
2006
|
+
Studio is organized as a 3-pane workspace:
|
|
2007
|
+
|
|
2008
|
+
| Pane | Position | Purpose |
|
|
2009
|
+
|------|----------|---------|
|
|
2010
|
+
| **Shelf** | Left (collapsible) | Component palette, surface picker, capability list |
|
|
2011
|
+
| **Stage** | Center | Code editor (CodeMirror) or live preview \u2014 toggle between modes |
|
|
2012
|
+
| **Sidekick** | Right (collapsible, resizable) | AI chat assistant for code generation and SDK guidance |
|
|
2013
|
+
|
|
2014
|
+
## The Shelf
|
|
2015
|
+
|
|
2016
|
+
The Shelf is a collapsible sidebar with three sections:
|
|
2017
|
+
|
|
2018
|
+
### Components
|
|
2019
|
+
|
|
2020
|
+
Browse all available \`ui.*\` components grouped by category (Layout, Text, Input,
|
|
2021
|
+
Feedback, Navigation, Composite). Click a component to insert it into your code \u2014
|
|
2022
|
+
in Code mode it inserts at the cursor, in Preview mode or with Shift+Click it uses
|
|
2023
|
+
AI-powered smart insertion to place the component in the correct location.
|
|
2024
|
+
|
|
2025
|
+
### Surfaces
|
|
2026
|
+
|
|
2027
|
+
Lists the surface targets available for your app (e.g., \`slot.header\`,
|
|
2028
|
+
\`slot.content\`, \`slot.footer\`). Surfaces already present in your code are
|
|
2029
|
+
filtered out. Clicking a surface adds it to your manifest and inserts a
|
|
2030
|
+
\`<Surface>\` block via AI smart insertion.
|
|
2031
|
+
|
|
2032
|
+
### Capabilities
|
|
2033
|
+
|
|
2034
|
+
The SDK capabilities your extension can use: \`data.query\`, \`data.fetch\`,
|
|
2035
|
+
\`context.read\`, \`actions.toast\`, \`actions.invoke\`, \`extend.identity\`,
|
|
2036
|
+
\`events:identity\`, \`events:messaging\`, and \`events:activity\`. Clicking a
|
|
2037
|
+
capability adds the permission to your manifest and AI-inserts the hook usage.
|
|
2038
|
+
|
|
2039
|
+
## The Stage
|
|
2040
|
+
|
|
2041
|
+
The center pane switches between two modes:
|
|
2042
|
+
|
|
2043
|
+
- **Code mode** \u2014 a CodeMirror 6 editor with TypeScript/JSX syntax highlighting,
|
|
2044
|
+
auto-save, and keyboard shortcuts (Cmd+S to save, Cmd+Z/Shift+Z for undo/redo,
|
|
2045
|
+
Tab for indentation)
|
|
2046
|
+
- **Preview mode** \u2014 a live preview that renders your extension exactly as it
|
|
2047
|
+
would appear in production, using the same Remote DOM pipeline as deployed
|
|
2048
|
+
extensions
|
|
2049
|
+
|
|
2050
|
+
Changes in Code mode recompile automatically via in-browser esbuild and update
|
|
2051
|
+
the preview. The toolbar shows the current status: Ready, Saving, Compiling,
|
|
2052
|
+
Thinking (AI), or Error.
|
|
2053
|
+
|
|
2054
|
+
## The Sidekick
|
|
2055
|
+
|
|
2056
|
+
The Sidekick is an AI chat assistant that understands the Stackable Extension SDK.
|
|
2057
|
+
It can:
|
|
2058
|
+
|
|
2059
|
+
- **Write and modify code** \u2014 ask it to add features, fix bugs, or refactor
|
|
2060
|
+
- **Update the manifest** \u2014 it can add permissions, targets, and allowed domains
|
|
2061
|
+
- **Look up SDK reference** \u2014 it queries the full SDK documentation on demand
|
|
2062
|
+
- **Suggest next steps** \u2014 after making changes, it offers contextual suggestions
|
|
2063
|
+
as clickable chips
|
|
2064
|
+
|
|
2065
|
+
The Sidekick adapts multi-file SDK documentation to Studio's single-file context
|
|
2066
|
+
automatically \u2014 you don't need to worry about file structure when working in Studio.
|
|
2067
|
+
|
|
2068
|
+
## Getting Started
|
|
2069
|
+
|
|
2070
|
+
1. Navigate to the **Studio** section in the admin dashboard
|
|
2071
|
+
2. Select (or create) an App
|
|
2072
|
+
3. Create a new project \u2014 starts with a blank template
|
|
2073
|
+
4. Use the Shelf to add components, surfaces, and capabilities
|
|
2074
|
+
5. Chat with the Sidekick to build out your extension
|
|
2075
|
+
6. Toggle to Preview mode to see your extension rendered live
|
|
2076
|
+
|
|
2077
|
+
## Exporting to a Local Project
|
|
2078
|
+
|
|
2079
|
+
When your extension outgrows single-file editing or you want to use your own
|
|
2080
|
+
IDE and version control, export the Studio project to a local CLI project:
|
|
2081
|
+
|
|
2082
|
+
\`\`\`bash
|
|
2083
|
+
${CLI.scaffold} --project-id <projectId>
|
|
2084
|
+
\`\`\`
|
|
2085
|
+
|
|
2086
|
+
This scaffolds a full multi-file extension project from your Studio code,
|
|
2087
|
+
including surfaces split into separate files, a store, and a configured manifest.
|
|
2088
|
+
|
|
2089
|
+
You can also download the project as a zip file from the export menu in the
|
|
2090
|
+
Studio toolbar.
|
|
2091
|
+
|
|
2092
|
+
## Studio vs CLI
|
|
2093
|
+
|
|
2094
|
+
| | Studio | CLI |
|
|
2095
|
+
|---|---|---|
|
|
2096
|
+
| **Best for** | Prototyping, learning, quick iterations | Production extensions, team workflows |
|
|
2097
|
+
| **Code structure** | Single file | Multi-file (surfaces/, components/, lib/) |
|
|
2098
|
+
| **Preview** | Built-in live preview | Local dev server + Cloudflare tunnel |
|
|
2099
|
+
| **AI assistance** | Sidekick chat + smart insertion | Your own AI editor (with SDK skills) |
|
|
2100
|
+
| **Version control** | Auto-saved to cloud | Git-based |
|
|
2101
|
+
| **Deployment** | Link to extension + deploy | CLI deploy command |
|
|
2102
|
+
|
|
2103
|
+
Both workflows produce the same output \u2014 a Stackable extension that runs in the
|
|
2104
|
+
host application via the same Remote DOM pipeline. Start in Studio to prototype,
|
|
2105
|
+
then scaffold to CLI when you need the full development workflow.
|
|
2106
|
+
`;
|
|
2107
|
+
};
|
|
2108
|
+
var generateProjectStructure = () => {
|
|
2109
|
+
const fm = frontmatter({
|
|
2110
|
+
root: false,
|
|
2111
|
+
targets: ["*"],
|
|
2112
|
+
description: "Extension project file structure, entry point, surfaces, store, and manifest",
|
|
2113
|
+
globs: ["packages/extension/src/**/*.tsx", "packages/extension/src/**/*.ts", "packages/extension/public/manifest.json"]
|
|
2114
|
+
});
|
|
2115
|
+
return `${fm}
|
|
2116
|
+
|
|
2117
|
+
# Project Structure
|
|
2118
|
+
|
|
2119
|
+
A Stackable extension project is a monorepo with two packages: the extension itself
|
|
2120
|
+
and a local preview host for development.
|
|
2121
|
+
|
|
2122
|
+
## Directory Layout
|
|
2123
|
+
|
|
2124
|
+
\`\`\`
|
|
2125
|
+
my-extension/
|
|
2126
|
+
packages/
|
|
2127
|
+
extension/ # Your extension code
|
|
2128
|
+
public/
|
|
2129
|
+
manifest.json # Extension manifest
|
|
2130
|
+
src/
|
|
2131
|
+
index.tsx # Entry point
|
|
2132
|
+
store.ts # Shared state (createStore)
|
|
2133
|
+
surfaces/ # Surface components
|
|
2134
|
+
Header.tsx # slot.header
|
|
2135
|
+
Content.tsx # slot.content
|
|
2136
|
+
Footer.tsx # slot.footer
|
|
2137
|
+
components/ # Feature components
|
|
2138
|
+
lib/ # API wrappers, utilities
|
|
2139
|
+
preview/ # Local preview host \u2014 DO NOT MODIFY
|
|
2140
|
+
package.json # Workspace root
|
|
2141
|
+
tsconfig.json
|
|
2142
|
+
\`\`\`
|
|
2143
|
+
|
|
2144
|
+
## Key Files
|
|
2145
|
+
|
|
2146
|
+
### manifest.json
|
|
2147
|
+
|
|
2148
|
+
The extension manifest declares what the extension needs from the host:
|
|
2149
|
+
|
|
2150
|
+
\`\`\`json
|
|
2151
|
+
{
|
|
2152
|
+
"name": "My Extension",
|
|
2153
|
+
"version": "0.1.0",
|
|
2154
|
+
"targets": ["slot.header", "slot.content", "slot.footer"],
|
|
2155
|
+
"permissions": ["context:read", "data:fetch"],
|
|
2156
|
+
"allowedDomains": ["api.example.com"]
|
|
2157
|
+
}
|
|
2158
|
+
\`\`\`
|
|
2159
|
+
|
|
2160
|
+
| Field | Purpose |
|
|
2161
|
+
|-------|---------|
|
|
2162
|
+
| \`name\` | Display name shown to users |
|
|
2163
|
+
| \`version\` | Semver version string |
|
|
2164
|
+
| \`targets\` | Surface slots the extension renders into |
|
|
2165
|
+
| \`permissions\` | Capabilities the extension uses |
|
|
2166
|
+
| \`allowedDomains\` | Hostnames for data.fetch requests (exact match, no wildcards) |
|
|
2167
|
+
|
|
2168
|
+
### index.tsx \u2014 Entry Point
|
|
2169
|
+
|
|
2170
|
+
The entry point bootstraps the extension runtime:
|
|
2171
|
+
|
|
2172
|
+
\`\`\`tsx
|
|
2173
|
+
${EXAMPLE_SNIPPETS.bootstrap}
|
|
2174
|
+
\`\`\`
|
|
2175
|
+
|
|
2176
|
+
\`createExtension\` handles:
|
|
2177
|
+
- Sandboxed iframe communication with the host
|
|
2178
|
+
- Capability injection (makes \`useCapabilities()\` work)
|
|
2179
|
+
- Surface registration (maps \`<Surface id="...">\` to host layout slots)
|
|
2180
|
+
|
|
2181
|
+
**Do not modify \`index.html\`** \u2014 the extension always bootstraps through \`createExtension\`.
|
|
2182
|
+
|
|
2183
|
+
### store.ts \u2014 Shared State
|
|
2184
|
+
|
|
2185
|
+
The store provides cross-surface state management:
|
|
2186
|
+
|
|
2187
|
+
\`\`\`tsx
|
|
2188
|
+
${EXAMPLE_SNIPPETS.store}
|
|
2189
|
+
\`\`\`
|
|
2190
|
+
|
|
2191
|
+
All surfaces import from the same store instance. Use \`useStore(appStore, selector)\`
|
|
2192
|
+
to read state with minimal re-renders.
|
|
2193
|
+
|
|
2194
|
+
### Surface Files
|
|
2195
|
+
|
|
2196
|
+
Each surface is a React component in \`src/surfaces/\`:
|
|
2197
|
+
|
|
2198
|
+
\`\`\`tsx
|
|
2199
|
+
${SURFACE_SNIPPETS[SURFACE_TARGETS.CONTENT]}
|
|
2200
|
+
\`\`\`
|
|
2201
|
+
|
|
2202
|
+
The \`id\` prop must match a target in \`manifest.json\`.
|
|
2203
|
+
|
|
2204
|
+
### Preview Host
|
|
2205
|
+
|
|
2206
|
+
The \`packages/preview/\` directory contains a local preview host that simulates
|
|
2207
|
+
the production environment. **Do not modify this directory** \u2014 it is pre-configured
|
|
2208
|
+
and managed by the SDK tooling.
|
|
2209
|
+
|
|
2210
|
+
## Import Conventions
|
|
2211
|
+
|
|
2212
|
+
All SDK imports come from two packages:
|
|
2213
|
+
|
|
2214
|
+
\`\`\`tsx
|
|
2215
|
+
// Runtime: hooks, components, utilities
|
|
2216
|
+
import { ui, Surface, createExtension, useCapabilities, createStore, useStore, useContextData }
|
|
2217
|
+
from '@stackable-labs/sdk-extension-react'
|
|
2218
|
+
|
|
2219
|
+
// Types and constants (dev-time only)
|
|
2220
|
+
import type { ExtensionManifest } from '@stackable-labs/sdk-extension-contracts'
|
|
2221
|
+
\`\`\`
|
|
2222
|
+
|
|
2223
|
+
Components use the \`ui.*\` namespace \u2014 do not import components directly.
|
|
2224
|
+
`;
|
|
2225
|
+
};
|
|
2226
|
+
|
|
2227
|
+
// ../../sdk/extension/ai-docs/src/generators/styling-and-theming.ts
|
|
2228
|
+
var generateStylingAndTheming = () => {
|
|
2229
|
+
const fm = frontmatter({
|
|
2230
|
+
root: false,
|
|
2231
|
+
targets: ["*"],
|
|
2232
|
+
description: "Host theme passthrough, CSS constraints, and styling patterns for extensions",
|
|
2233
|
+
globs: ["packages/extension/src/**/*.tsx", "packages/extension/src/**/*.ts"]
|
|
2234
|
+
});
|
|
2235
|
+
return `${fm}
|
|
2236
|
+
|
|
2237
|
+
# Styling & Theming
|
|
2238
|
+
|
|
2239
|
+
Extensions inherit the host application's theme automatically. The SDK component library
|
|
2240
|
+
(\`ui.*\` namespace) renders inside the host's styling context, so colors, fonts, and
|
|
2241
|
+
spacing match the host UI without any configuration.
|
|
2242
|
+
|
|
2243
|
+
## Host Theme Inheritance
|
|
2244
|
+
|
|
2245
|
+
The \`ui.*\` components automatically use the host's design tokens:
|
|
2246
|
+
- **Colors** \u2014 text, backgrounds, borders adapt to the host's color scheme
|
|
2247
|
+
- **Typography** \u2014 font family, sizes, and weights match the host
|
|
2248
|
+
- **Spacing** \u2014 padding and margins follow the host's spacing scale
|
|
2249
|
+
- **Dark mode** \u2014 components respond to the host's light/dark mode setting
|
|
2250
|
+
|
|
2251
|
+
No CSS or theme configuration is needed in the extension.
|
|
2252
|
+
|
|
2253
|
+
## The className Prop
|
|
2254
|
+
|
|
2255
|
+
Most \`ui.*\` components accept a \`className\` prop for layout adjustments:
|
|
2256
|
+
|
|
2257
|
+
\`\`\`tsx
|
|
2258
|
+
<ui.Card className="mt-4">
|
|
2259
|
+
<ui.CardContent className="p-6">
|
|
2260
|
+
<ui.Text className="text-center">Centered text</ui.Text>
|
|
2261
|
+
</ui.CardContent>
|
|
2262
|
+
</ui.Card>
|
|
2263
|
+
\`\`\`
|
|
2264
|
+
|
|
2265
|
+
**Use className for:**
|
|
2266
|
+
- Layout (margin, padding, flexbox, grid)
|
|
2267
|
+
- Positioning (relative, absolute)
|
|
2268
|
+
- Sizing (width, height, max-width)
|
|
2269
|
+
- Spacing between elements
|
|
2270
|
+
|
|
2271
|
+
**Avoid overriding with className:**
|
|
2272
|
+
- Colors and backgrounds (breaks theme consistency)
|
|
2273
|
+
- Font families and sizes (breaks typography scale)
|
|
2274
|
+
- Border styles (use component variants instead)
|
|
2275
|
+
|
|
2276
|
+
## Layout Components
|
|
2277
|
+
|
|
2278
|
+
Use the built-in layout components instead of raw CSS:
|
|
2279
|
+
|
|
2280
|
+
| Component | Purpose |
|
|
2281
|
+
|-----------|---------|
|
|
2282
|
+
| \`<ui.Stack>\` | Vertical layout with consistent spacing |
|
|
2283
|
+
| \`<ui.Inline>\` | Horizontal layout with consistent spacing |
|
|
2284
|
+
| \`<ui.Separator>\` | Visual divider between sections |
|
|
2285
|
+
| \`<ui.ScrollArea>\` | Scrollable container for overflow content |
|
|
2286
|
+
|
|
2287
|
+
\`\`\`tsx
|
|
2288
|
+
<ui.Stack className="gap-4">
|
|
2289
|
+
<ui.Heading level={3}>Section Title</ui.Heading>
|
|
2290
|
+
<ui.Text>Section content</ui.Text>
|
|
2291
|
+
<ui.Separator />
|
|
2292
|
+
<ui.Inline className="gap-2">
|
|
2293
|
+
<ui.Button variant="primary">Save</ui.Button>
|
|
2294
|
+
<ui.Button variant="ghost">Cancel</ui.Button>
|
|
2295
|
+
</ui.Inline>
|
|
2296
|
+
</ui.Stack>
|
|
2297
|
+
\`\`\`
|
|
2298
|
+
|
|
2299
|
+
## Component Variants
|
|
2300
|
+
|
|
2301
|
+
Use component props (not CSS) to control visual style:
|
|
2302
|
+
|
|
2303
|
+
\`\`\`tsx
|
|
2304
|
+
// Buttons \u2014 variant controls appearance
|
|
2305
|
+
<ui.Button variant="primary">Primary action</ui.Button>
|
|
2306
|
+
<ui.Button variant="ghost">Secondary action</ui.Button>
|
|
2307
|
+
|
|
2308
|
+
// Badges \u2014 variant + hue + tone for semantic colors
|
|
2309
|
+
<ui.Badge variant="default" hue="blue" tone="strong">Active</ui.Badge>
|
|
2310
|
+
<ui.Badge variant="default" hue="red" tone="subtle">Error</ui.Badge>
|
|
2311
|
+
|
|
2312
|
+
// Text \u2014 tone for semantic meaning
|
|
2313
|
+
<ui.Text tone="subdued">Helper text</ui.Text>
|
|
2314
|
+
<ui.Text tone="critical">Error message</ui.Text>
|
|
2315
|
+
\`\`\`
|
|
2316
|
+
|
|
2317
|
+
## CSS Constraints
|
|
2318
|
+
|
|
2319
|
+
Extensions run in a sandboxed iframe. These constraints apply:
|
|
2320
|
+
|
|
2321
|
+
- **No global CSS** \u2014 styles don't leak between extensions or into the host
|
|
2322
|
+
- **No \`document\` access** \u2014 cannot inject stylesheets or modify the DOM directly
|
|
2323
|
+
- **No \`window.location\`** \u2014 cannot read or modify the URL
|
|
2324
|
+
- **Component-only styling** \u2014 use \`className\` on \`ui.*\` components, not raw HTML elements
|
|
2325
|
+
- **Bundle size limit** \u2014 keep CSS dependencies minimal (under 500KB total bundle)
|
|
2326
|
+
|
|
2327
|
+
## Responsive Design
|
|
2328
|
+
|
|
2329
|
+
Extensions render in a constrained viewport (the host's sidebar or panel). Design for
|
|
2330
|
+
narrow widths:
|
|
2331
|
+
|
|
2332
|
+
\`\`\`tsx
|
|
2333
|
+
// Use ScrollArea for content that may overflow
|
|
2334
|
+
<ui.ScrollArea className="h-[400px]">
|
|
2335
|
+
<ui.Stack className="gap-3">
|
|
2336
|
+
{items.map((item) => (
|
|
2337
|
+
<ItemCard key={item.id} item={item} />
|
|
2338
|
+
))}
|
|
2339
|
+
</ui.Stack>
|
|
2340
|
+
</ui.ScrollArea>
|
|
2341
|
+
\`\`\`
|
|
2342
|
+
|
|
2343
|
+
- Assume a **narrow viewport** (~300-400px wide)
|
|
2344
|
+
- Use \`<ui.ScrollArea>\` for long lists or content
|
|
2345
|
+
- Avoid horizontal scrolling \u2014 stack elements vertically
|
|
2346
|
+
- Test at the minimum panel width the host supports
|
|
2347
|
+
`;
|
|
2348
|
+
};
|
|
2349
|
+
|
|
2350
|
+
// ../../sdk/extension/ai-docs/src/generators/store-and-navigation.ts
|
|
2351
|
+
var generateStoreAndNavigation = () => {
|
|
2352
|
+
const fm = frontmatter({
|
|
2353
|
+
root: false,
|
|
2354
|
+
targets: ["*"],
|
|
2355
|
+
description: "Cross-surface state management with createStore and store-based navigation patterns",
|
|
2356
|
+
globs: ["packages/extension/src/**/*.tsx", "packages/extension/src/**/*.ts"]
|
|
2357
|
+
});
|
|
2358
|
+
return `${fm}
|
|
2359
|
+
|
|
2360
|
+
# Store & Navigation
|
|
2361
|
+
|
|
2362
|
+
Extensions use \`createStore\` for cross-surface state management. The store is shared
|
|
2363
|
+
across all surfaces (header, content, footer), enabling coordinated UI updates from
|
|
2364
|
+
a single state source.
|
|
2365
|
+
|
|
2366
|
+
## Creating a Store
|
|
2367
|
+
|
|
2368
|
+
Define your store in \`src/store.ts\`:
|
|
2369
|
+
|
|
2370
|
+
\`\`\`tsx
|
|
2371
|
+
import { createStore } from '@stackable-labs/sdk-extension-react'
|
|
2372
|
+
|
|
2373
|
+
type ViewState = { type: 'list' } | { type: 'detail'; id: string }
|
|
2374
|
+
|
|
2375
|
+
interface AppState {
|
|
2376
|
+
viewState: ViewState
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
export const appStore = createStore<AppState>({
|
|
2380
|
+
viewState: { type: 'list' },
|
|
2381
|
+
})
|
|
2382
|
+
\`\`\`
|
|
2383
|
+
|
|
2384
|
+
## Reading State in Surfaces
|
|
2385
|
+
|
|
2386
|
+
Use \`useStore\` with a selector to subscribe to specific slices of state:
|
|
2387
|
+
|
|
2388
|
+
\`\`\`tsx
|
|
2389
|
+
import { useStore } from '@stackable-labs/sdk-extension-react'
|
|
2390
|
+
import { appStore } from '../store'
|
|
2391
|
+
|
|
2392
|
+
export const Content = () => {
|
|
2393
|
+
const viewState = useStore(appStore, (s) => s.viewState)
|
|
2394
|
+
|
|
2395
|
+
if (viewState.type === 'list') {
|
|
2396
|
+
return <ListView onSelect={(id) => appStore.set({ viewState: { type: 'detail', id } })} />
|
|
2397
|
+
}
|
|
2398
|
+
return <DetailView id={viewState.id} onBack={() => appStore.set({ viewState: { type: 'list' } })} />
|
|
2399
|
+
}
|
|
2400
|
+
\`\`\`
|
|
2401
|
+
|
|
2402
|
+
## Store-Based Navigation
|
|
2403
|
+
|
|
2404
|
+
Use discriminated unions for view state instead of string constants or URL routing.
|
|
2405
|
+
The store replaces traditional routing \u2014 there are no URLs inside the sandbox.
|
|
2406
|
+
|
|
2407
|
+
### View State Pattern
|
|
2408
|
+
|
|
2409
|
+
\`\`\`tsx
|
|
2410
|
+
// Define all possible views as a discriminated union
|
|
2411
|
+
type ViewState =
|
|
2412
|
+
| { type: 'list' }
|
|
2413
|
+
| { type: 'detail'; id: string }
|
|
2414
|
+
| { type: 'edit'; id: string }
|
|
2415
|
+
| { type: 'create' }
|
|
2416
|
+
|
|
2417
|
+
// TypeScript narrows the type based on the discriminant
|
|
2418
|
+
switch (viewState.type) {
|
|
2419
|
+
case 'list': return <ListView />
|
|
2420
|
+
case 'detail': return <DetailView id={viewState.id} />
|
|
2421
|
+
case 'edit': return <EditView id={viewState.id} />
|
|
2422
|
+
case 'create': return <CreateView />
|
|
2423
|
+
}
|
|
2424
|
+
\`\`\`
|
|
2425
|
+
|
|
2426
|
+
### Cross-Surface Coordination
|
|
2427
|
+
|
|
2428
|
+
The header can show context based on what the content surface displays:
|
|
2429
|
+
|
|
2430
|
+
\`\`\`tsx
|
|
2431
|
+
// Header surface \u2014 shows back button when on detail view
|
|
2432
|
+
export const Header = () => {
|
|
2433
|
+
const viewState = useStore(appStore, (s) => s.viewState)
|
|
2434
|
+
|
|
2435
|
+
return (
|
|
2436
|
+
<Surface id="slot.header">
|
|
2437
|
+
<ui.Inline>
|
|
2438
|
+
{viewState.type !== 'list' && (
|
|
2439
|
+
<ui.Button
|
|
2440
|
+
variant="ghost"
|
|
2441
|
+
onClick={() => appStore.set({ viewState: { type: 'list' } })}
|
|
2442
|
+
>
|
|
2443
|
+
Back
|
|
2444
|
+
</ui.Button>
|
|
2445
|
+
)}
|
|
2446
|
+
<ui.Heading level={3}>
|
|
2447
|
+
{viewState.type === 'list' ? 'All Items' : 'Item Detail'}
|
|
2448
|
+
</ui.Heading>
|
|
2449
|
+
</ui.Inline>
|
|
2450
|
+
</Surface>
|
|
2451
|
+
)
|
|
2452
|
+
}
|
|
2453
|
+
\`\`\`
|
|
2454
|
+
|
|
2455
|
+
## Selector Performance
|
|
2456
|
+
|
|
2457
|
+
Use narrow selectors to minimize re-renders. Each \`useStore\` call only
|
|
2458
|
+
triggers a re-render when its selected value changes:
|
|
2459
|
+
|
|
2460
|
+
\`\`\`tsx
|
|
2461
|
+
// Good \u2014 component only re-renders when viewState changes
|
|
2462
|
+
const viewState = useStore(appStore, (s) => s.viewState)
|
|
2463
|
+
|
|
2464
|
+
// Bad \u2014 component re-renders on ANY state change
|
|
2465
|
+
const state = useStore(appStore, (s) => s)
|
|
2466
|
+
\`\`\`
|
|
2467
|
+
|
|
2468
|
+
## Best Practices
|
|
2469
|
+
|
|
2470
|
+
- **One store per extension** \u2014 define in \`src/store.ts\`, import everywhere
|
|
2471
|
+
- **Discriminated unions for views** \u2014 TypeScript can narrow the type and catch missing cases
|
|
2472
|
+
- **Narrow selectors** \u2014 select only what the component needs
|
|
2473
|
+
- **Use \`appStore.set()\`** \u2014 update state directly via \`appStore.set({ viewState: ... })\` from any component
|
|
2474
|
+
- **No URL routing** \u2014 the extension runs in a sandboxed iframe with no URL bar
|
|
2475
|
+
`;
|
|
2476
|
+
};
|
|
2477
|
+
|
|
2478
|
+
// ../../sdk/extension/ai-docs/src/generators/cookbook-capabilities.ts
|
|
2479
|
+
var generateCookbookCapabilities = () => {
|
|
2480
|
+
const fm = frontmatter({
|
|
2481
|
+
root: false,
|
|
2482
|
+
targets: ["*"],
|
|
2483
|
+
description: "Cookbook: data.query, data.fetch, context.read, actions.toast, actions.invoke capability examples",
|
|
2484
|
+
globs: ["packages/extension/src/**/*.tsx", "packages/extension/src/**/*.ts"]
|
|
2485
|
+
});
|
|
2486
|
+
return `${fm}
|
|
2487
|
+
|
|
2488
|
+
# Capability Examples
|
|
2489
|
+
|
|
2490
|
+
Focused examples showing each SDK capability in a working Surface component.
|
|
2491
|
+
Each example is self-contained \u2014 copy it into a surface file and add the
|
|
2492
|
+
corresponding permission to your \`manifest.json\`.
|
|
2493
|
+
|
|
2494
|
+
## context.read \u2014 Reading Host Context
|
|
2495
|
+
|
|
2496
|
+
Read customer and session data provided by the host. The \`useContextData\`
|
|
2497
|
+
hook handles loading state automatically.
|
|
2498
|
+
|
|
2499
|
+
**Permission:** \`context:read\`
|
|
2500
|
+
|
|
2501
|
+
\`\`\`tsx
|
|
2502
|
+
${EXAMPLE_SNIPPETS["context.read"]}
|
|
2503
|
+
\`\`\`
|
|
2504
|
+
|
|
2505
|
+
## data.query \u2014 Host-Mediated Requests
|
|
2506
|
+
|
|
2507
|
+
Send structured requests to the host application. The host handles the API
|
|
2508
|
+
call and returns the result \u2014 no \`allowedDomains\` needed.
|
|
2509
|
+
|
|
2510
|
+
**Permission:** \`data:query\`
|
|
2511
|
+
|
|
2512
|
+
\`\`\`tsx
|
|
2513
|
+
${EXAMPLE_SNIPPETS["data.query"]}
|
|
2514
|
+
\`\`\`
|
|
2515
|
+
|
|
2516
|
+
## data.fetch \u2014 Direct HTTP Requests
|
|
2517
|
+
|
|
2518
|
+
Make HTTP requests directly from the extension sandbox. The domain must be
|
|
2519
|
+
listed in \`allowedDomains\` in your manifest.
|
|
2520
|
+
|
|
2521
|
+
**Permission:** \`data:fetch\`
|
|
2522
|
+
|
|
2523
|
+
\`\`\`tsx
|
|
2524
|
+
${EXAMPLE_SNIPPETS["data.fetch"]}
|
|
2525
|
+
\`\`\`
|
|
2526
|
+
|
|
2527
|
+
## actions.toast \u2014 Toast Notifications
|
|
2528
|
+
|
|
2529
|
+
Display toast notifications in the host UI to provide feedback to users.
|
|
2530
|
+
|
|
2531
|
+
**Permission:** \`actions:toast\`
|
|
2532
|
+
|
|
2533
|
+
\`\`\`tsx
|
|
2534
|
+
${EXAMPLE_SNIPPETS["actions.toast"]}
|
|
2535
|
+
\`\`\`
|
|
2536
|
+
|
|
2537
|
+
## actions.invoke \u2014 Host Actions
|
|
2538
|
+
|
|
2539
|
+
Trigger host-defined actions like starting conversations, setting tags, or
|
|
2540
|
+
updating custom fields.
|
|
2541
|
+
|
|
2542
|
+
**Permission:** \`actions:invoke\`
|
|
2543
|
+
|
|
2544
|
+
\`\`\`tsx
|
|
2545
|
+
${EXAMPLE_SNIPPETS["actions.invoke"]}
|
|
2546
|
+
\`\`\`
|
|
2547
|
+
`;
|
|
2548
|
+
};
|
|
2549
|
+
var generateCookbookStructural = () => {
|
|
2550
|
+
const fm = frontmatter({
|
|
2551
|
+
root: false,
|
|
2552
|
+
targets: ["*"],
|
|
2553
|
+
description: "Cookbook: extension bootstrap, surface declaration, store navigation, and loading patterns",
|
|
2554
|
+
globs: ["packages/extension/src/**/*.tsx", "packages/extension/src/**/*.ts"]
|
|
2555
|
+
});
|
|
2556
|
+
return `${fm}
|
|
2557
|
+
|
|
2558
|
+
# Structural Patterns
|
|
2559
|
+
|
|
2560
|
+
Focused examples for the foundational patterns every extension uses: bootstrapping,
|
|
2561
|
+
surfaces, store-based navigation, and loading states.
|
|
2562
|
+
|
|
2563
|
+
## Bootstrap \u2014 Entry Point
|
|
2564
|
+
|
|
2565
|
+
Set up your extension entry point in \`src/index.tsx\`. The \`createExtension\` factory
|
|
2566
|
+
bootstraps the sandboxed runtime and registers all surfaces with the host.
|
|
2567
|
+
|
|
2568
|
+
\`\`\`tsx
|
|
2569
|
+
${EXAMPLE_SNIPPETS.bootstrap}
|
|
2570
|
+
\`\`\`
|
|
2571
|
+
|
|
2572
|
+
## Surfaces \u2014 Declaring UI Slots
|
|
2573
|
+
|
|
2574
|
+
Each surface renders into a specific layout slot in the host application.
|
|
2575
|
+
The \`id\` prop must match a target declared in your \`manifest.json\`.
|
|
2576
|
+
|
|
2577
|
+
### Header
|
|
2578
|
+
|
|
2579
|
+
\`\`\`tsx
|
|
2580
|
+
${SURFACE_SNIPPETS[SURFACE_TARGETS.HEADER]}
|
|
2581
|
+
\`\`\`
|
|
2582
|
+
|
|
2583
|
+
### Content
|
|
2584
|
+
|
|
2585
|
+
\`\`\`tsx
|
|
2586
|
+
${SURFACE_SNIPPETS[SURFACE_TARGETS.CONTENT]}
|
|
2587
|
+
\`\`\`
|
|
2588
|
+
|
|
2589
|
+
### Footer
|
|
2590
|
+
|
|
2591
|
+
\`\`\`tsx
|
|
2592
|
+
${SURFACE_SNIPPETS[SURFACE_TARGETS.FOOTER]}
|
|
2593
|
+
\`\`\`
|
|
2594
|
+
|
|
2595
|
+
### Footer Links
|
|
2596
|
+
|
|
2597
|
+
\`\`\`tsx
|
|
2598
|
+
${SURFACE_SNIPPETS[SURFACE_TARGETS.FOOTER_LINKS]}
|
|
2599
|
+
\`\`\`
|
|
2600
|
+
|
|
2601
|
+
## Store \u2014 Cross-Surface State & Navigation
|
|
2602
|
+
|
|
2603
|
+
Extensions use \`createStore\` for shared state across surfaces. The store replaces
|
|
2604
|
+
traditional URL routing \u2014 there are no URLs inside the sandbox. Use discriminated
|
|
2605
|
+
unions for view state to get exhaustive type checking.
|
|
2606
|
+
|
|
2607
|
+
\`\`\`tsx
|
|
2608
|
+
${EXAMPLE_SNIPPETS.store}
|
|
2609
|
+
\`\`\`
|
|
2610
|
+
|
|
2611
|
+
## Loading States & Guards
|
|
2612
|
+
|
|
2613
|
+
Handle loading states and missing context gracefully. Always show a skeleton
|
|
2614
|
+
while context loads, and guard against missing data before rendering.
|
|
2615
|
+
|
|
2616
|
+
\`\`\`tsx
|
|
2617
|
+
${EXAMPLE_SNIPPETS.loading}
|
|
2618
|
+
\`\`\`
|
|
2619
|
+
|
|
2620
|
+
## Surface Context
|
|
2621
|
+
|
|
2622
|
+
Read host-pushed context for a specific surface using the lower-level
|
|
2623
|
+
\`useSurfaceContext\` hook.
|
|
2624
|
+
|
|
2625
|
+
\`\`\`tsx
|
|
2626
|
+
${EXAMPLE_SNIPPETS.surfaceContext}
|
|
2627
|
+
\`\`\`
|
|
2628
|
+
`;
|
|
2629
|
+
};
|
|
2630
|
+
var identityEventTypes3 = Object.values(IDENTITY_EVENTS).map((e) => `\`${e}\``).join(", ");
|
|
2631
|
+
var activityEventTypes3 = Object.values(ACTIVITY_EVENTS).map((e) => `\`${e}\``).join(", ");
|
|
2632
|
+
var generateCookbookEvents = () => {
|
|
2633
|
+
const fm = frontmatter({
|
|
2634
|
+
root: false,
|
|
2635
|
+
targets: ["*"],
|
|
2636
|
+
description: "Cookbook: identity, messaging, and activity event subscriptions + extend identity",
|
|
2637
|
+
globs: ["packages/extension/src/**/*.tsx", "packages/extension/src/**/*.ts"]
|
|
2638
|
+
});
|
|
2639
|
+
return `${fm}
|
|
2640
|
+
|
|
2641
|
+
# Events & Extensions
|
|
2642
|
+
|
|
2643
|
+
Subscribe to real-time events pushed from the host and extend identity claims.
|
|
2644
|
+
Each event type has a dedicated hook \u2014 never use \`capabilities.events.*\` directly.
|
|
2645
|
+
|
|
2646
|
+
## Identity Events
|
|
2647
|
+
|
|
2648
|
+
Subscribe to login, logout, refresh, and expired events. Useful for tracking
|
|
2649
|
+
agent authentication state in your extension.
|
|
2650
|
+
|
|
2651
|
+
**Permission:** \`events:identity\`
|
|
2652
|
+
**Event types:** ${identityEventTypes3}
|
|
2653
|
+
|
|
2654
|
+
### Hook usage
|
|
2655
|
+
|
|
2656
|
+
\`\`\`tsx
|
|
2657
|
+
${HOOK_SNIPPETS["events:identity"]}
|
|
2658
|
+
\`\`\`
|
|
2659
|
+
|
|
2660
|
+
### Full component example
|
|
2661
|
+
|
|
2662
|
+
\`\`\`tsx
|
|
2663
|
+
${EXAMPLE_SNIPPETS["events:identity"]}
|
|
2664
|
+
\`\`\`
|
|
2665
|
+
|
|
2666
|
+
## Messaging Events
|
|
2667
|
+
|
|
2668
|
+
Subscribe to postback button clicks from the Zendesk messaging widget.
|
|
2669
|
+
The \`actionName\` is the button's display text, not a programmatic identifier.
|
|
2670
|
+
|
|
2671
|
+
**Permission:** \`events:messaging\`
|
|
2672
|
+
|
|
2673
|
+
### Hook usage
|
|
2674
|
+
|
|
2675
|
+
\`\`\`tsx
|
|
2676
|
+
${HOOK_SNIPPETS["events:messaging"]}
|
|
2677
|
+
\`\`\`
|
|
2678
|
+
|
|
2679
|
+
### Full component example
|
|
2680
|
+
|
|
2681
|
+
\`\`\`tsx
|
|
2682
|
+
${EXAMPLE_SNIPPETS["events:messaging"]}
|
|
2683
|
+
\`\`\`
|
|
2684
|
+
|
|
2685
|
+
## Activity Events
|
|
2686
|
+
|
|
2687
|
+
Subscribe to host activity events like page views, clicks, and purchases.
|
|
2688
|
+
Activity event names are domain-stripped \u2014 use \`useActivityEvent('product_view', ...)\`
|
|
2689
|
+
not \`'activity:product_view'\`.
|
|
2690
|
+
|
|
2691
|
+
**Permission:** \`events:activity\`
|
|
2692
|
+
**Well-known events:** ${activityEventTypes3}
|
|
2693
|
+
|
|
2694
|
+
### Hook usage
|
|
2695
|
+
|
|
2696
|
+
\`\`\`tsx
|
|
2697
|
+
${HOOK_SNIPPETS["events:activity"]}
|
|
2698
|
+
\`\`\`
|
|
2699
|
+
|
|
2700
|
+
### Full component example
|
|
2701
|
+
|
|
2702
|
+
\`\`\`tsx
|
|
2703
|
+
${EXAMPLE_SNIPPETS["events:activity"]}
|
|
2704
|
+
\`\`\`
|
|
2705
|
+
|
|
2706
|
+
## Extend Identity
|
|
2707
|
+
|
|
2708
|
+
Enrich identity JWT claims before signing. The host sends base claims
|
|
2709
|
+
(\`external_id\`, \`email\`, \`name\`) and your handler returns additional
|
|
2710
|
+
claims to merge into the token.
|
|
2711
|
+
|
|
2712
|
+
**Permission:** \`extend:identity\`
|
|
2713
|
+
|
|
2714
|
+
### Hook usage
|
|
2715
|
+
|
|
2716
|
+
\`\`\`tsx
|
|
2717
|
+
${HOOK_SNIPPETS["extend.identity"]}
|
|
2718
|
+
\`\`\`
|
|
2719
|
+
|
|
2720
|
+
### Full component example
|
|
2721
|
+
|
|
2722
|
+
\`\`\`tsx
|
|
2723
|
+
${EXAMPLE_SNIPPETS["extend.identity"]}
|
|
2724
|
+
\`\`\`
|
|
2725
|
+
`;
|
|
2726
|
+
};
|
|
2727
|
+
|
|
2728
|
+
// ../../sdk/extension/ai-docs/src/commands/add-surface.ts
|
|
2729
|
+
var generateAddSurfaceCommand = () => {
|
|
2730
|
+
const fm = frontmatter({
|
|
2731
|
+
description: "Add a new surface (UI slot) to this Stackable extension",
|
|
2732
|
+
targets: ["*"]
|
|
2733
|
+
});
|
|
2734
|
+
return `${fm}
|
|
2735
|
+
|
|
2736
|
+
# Add a New Surface
|
|
2737
|
+
|
|
2738
|
+
Add a new surface to this extension. Follow these steps exactly:
|
|
2739
|
+
|
|
2740
|
+
## 1. Determine the target slot
|
|
2741
|
+
Ask which target slot this surface is for. Valid targets:
|
|
2742
|
+
- \`slot.header\` \u2014 appears at the top of the extension area
|
|
2743
|
+
- \`slot.content\` \u2014 main content area
|
|
2744
|
+
- \`slot.footer\` \u2014 bottom of the extension area
|
|
2745
|
+
- \`slot.footer-links\` \u2014 footer link row
|
|
2746
|
+
|
|
2747
|
+
## 2. Create the surface file
|
|
2748
|
+
Create a new file in \`packages/extension/src/surfaces/\` named after the slot
|
|
2749
|
+
(e.g., \`Header.tsx\`, \`Content.tsx\`, \`Footer.tsx\`).
|
|
2750
|
+
|
|
2751
|
+
Use this template:
|
|
2752
|
+
\`\`\`tsx
|
|
2753
|
+
import { Surface, useCapabilities, useContextData } from '@stackable-labs/sdk-extension-react'
|
|
2754
|
+
import { ui } from '@stackable-labs/sdk-extension-react'
|
|
2755
|
+
|
|
2756
|
+
export const [SurfaceName] = () => {
|
|
2757
|
+
return (
|
|
2758
|
+
<Surface id="[target]">
|
|
2759
|
+
{/* Surface content here */}
|
|
2760
|
+
</Surface>
|
|
2761
|
+
)
|
|
2762
|
+
}
|
|
2763
|
+
\`\`\`
|
|
2764
|
+
|
|
2765
|
+
If the surface needs state management, import and use the existing store from \`../store\`.
|
|
2766
|
+
|
|
2767
|
+
## 3. Register the surface in index.tsx
|
|
2768
|
+
Import the new surface component and add it to the \`createExtension\` factory function
|
|
2769
|
+
alongside existing surfaces.
|
|
2770
|
+
|
|
2771
|
+
## 4. Update manifest.json
|
|
2772
|
+
Add the target to the \`targets\` array in \`packages/extension/public/manifest.json\`.
|
|
2773
|
+
Also add any required permissions based on the target-permission mapping:
|
|
2774
|
+
- \`slot.header\` \u2192 \`context:read\`
|
|
2775
|
+
- \`slot.content\` \u2192 \`context:read\`, \`data:query\`, \`actions:toast\`, \`actions:invoke\`
|
|
2776
|
+
- \`slot.footer\` \u2192 (none)
|
|
2777
|
+
- \`slot.footer-links\` \u2192 (none)
|
|
2778
|
+
|
|
2779
|
+
Only add permissions that aren't already declared.
|
|
2780
|
+
|
|
2781
|
+
## 5. Verify
|
|
2782
|
+
- Confirm the surface renders by checking the import chain: index.tsx \u2192 Surface component \u2192 Surface id matches manifest target
|
|
2783
|
+
- Confirm manifest.json is valid JSON with no duplicate targets or permissions
|
|
2784
|
+
`;
|
|
2785
|
+
};
|
|
2786
|
+
|
|
2787
|
+
// ../../sdk/extension/ai-docs/src/commands/add-capability.ts
|
|
2788
|
+
var generateAddCapabilityCommand = () => {
|
|
2789
|
+
const fm = frontmatter({
|
|
2790
|
+
description: "Wire up a new capability (data.fetch, data.query, context.read, actions.toast, actions.invoke, extend:identity, events:identity, events:messaging, events:activity) in this extension",
|
|
2791
|
+
targets: ["*"]
|
|
2792
|
+
});
|
|
2793
|
+
return `${fm}
|
|
2794
|
+
|
|
2795
|
+
# Add a Capability
|
|
2796
|
+
|
|
2797
|
+
Wire up a new capability in this extension. Follow these steps exactly:
|
|
2798
|
+
|
|
2799
|
+
## 1. Determine the capability
|
|
2800
|
+
Ask which capability to add. Valid capabilities:
|
|
2801
|
+
- \`data.query\` \u2014 host-mediated data requests (action name + params \u2192 host returns data)
|
|
2802
|
+
- \`data.fetch\` \u2014 direct HTTP requests from the sandbox (requires allowedDomains)
|
|
2803
|
+
- \`context.read\` \u2014 read host-provided context (customerId, customerEmail, etc.)
|
|
2804
|
+
- \`actions.toast\` \u2014 show toast notifications (success, error, info, warning)
|
|
2805
|
+
- \`actions.invoke\` \u2014 invoke host actions (e.g., open new conversation)
|
|
2806
|
+
- \`extend:identity\` \u2014 enrich identity JWT claims before signing
|
|
2807
|
+
- \`events:identity\` \u2014 subscribe to identity events (login, logout, refresh, expired)
|
|
2808
|
+
- \`events:messaging\` \u2014 subscribe to messaging events (postback button clicks)
|
|
2809
|
+
- \`events:activity\` \u2014 subscribe to activity events (page views, clicks, purchases)
|
|
2810
|
+
|
|
2811
|
+
## 2. Add permission to manifest.json
|
|
2812
|
+
Add the corresponding permission to \`packages/extension/public/manifest.json\`:
|
|
2813
|
+
- \`data.query\` \u2192 \`"data:query"\`
|
|
2814
|
+
- \`data.fetch\` \u2192 \`"data:fetch"\`
|
|
2815
|
+
- \`context.read\` \u2192 \`"context:read"\`
|
|
2816
|
+
- \`actions.toast\` \u2192 \`"actions:toast"\`
|
|
2817
|
+
- \`actions.invoke\` \u2192 \`"actions:invoke"\`
|
|
2818
|
+
- \`extend:identity\` \u2192 \`"extend:identity"\`
|
|
2819
|
+
- \`events:identity\` \u2192 \`"events:identity"\` (also add entries to \`events\` array, e.g. \`["identity:login", "identity:logout"]\`)
|
|
2820
|
+
- \`events:messaging\` \u2192 \`"events:messaging"\` (also add entries to \`events\` array, e.g. \`["messaging:postback:Buy Now"]\`)
|
|
2821
|
+
- \`events:activity\` \u2192 \`"events:activity"\` (also add entries to \`events\` array, e.g. \`["activity:product_view"]\`)
|
|
2822
|
+
|
|
2823
|
+
Only add if not already declared.
|
|
2824
|
+
|
|
2825
|
+
**Note:** Identity state is available via \`context.read()\` \u2192 \`identity\` field (requires \`context:read\`, no separate permission).
|
|
2826
|
+
|
|
2827
|
+
## 3. If data.fetch \u2014 add allowedDomains
|
|
2828
|
+
Ask for the API domain(s) and add them to the \`allowedDomains\` array in manifest.json.
|
|
2829
|
+
|
|
2830
|
+
## 4. If data.fetch \u2014 create API wrapper
|
|
2831
|
+
Create \`packages/extension/src/lib/api.ts\` with the API wrapper pattern:
|
|
2832
|
+
\`\`\`tsx
|
|
2833
|
+
type FetchFn = (url: string, init?: FetchRequestInit) => Promise<FetchResponse>
|
|
2834
|
+
|
|
2835
|
+
export function createApi(fetch: FetchFn) {
|
|
2836
|
+
return {
|
|
2837
|
+
async getData(): Promise<DataType> {
|
|
2838
|
+
const result = await fetch('https://api.example.com/data', { method: 'GET' })
|
|
2839
|
+
if (!result.ok) throw new Error(\`Request failed: \${result.status}\`)
|
|
2840
|
+
return result.data as DataType
|
|
2841
|
+
},
|
|
2842
|
+
}
|
|
2843
|
+
}
|
|
2844
|
+
\`\`\`
|
|
2845
|
+
|
|
2846
|
+
## 5. Use the capability in a surface
|
|
2847
|
+
|
|
2848
|
+
### For data.*, context.*, and actions.* capabilities \u2014 use \`useCapabilities()\`:
|
|
2849
|
+
\`\`\`tsx
|
|
2850
|
+
import { useCapabilities } from '@stackable-labs/sdk-extension-react'
|
|
2851
|
+
|
|
2852
|
+
const capabilities = useCapabilities()
|
|
2853
|
+
// data.query: capabilities.data.query({ action: 'getItems', ... })
|
|
2854
|
+
// data.fetch: const api = createApi(capabilities.data.fetch)
|
|
2855
|
+
// context.read: capabilities.context.read() (or use useContextData() hook)
|
|
2856
|
+
// actions.toast: capabilities.actions.toast({ message: 'Done!', type: 'success' })
|
|
2857
|
+
// actions.invoke: capabilities.actions.invoke('newConversation', { tags: ['order'], fields: [{ id: 'field_id', value: 'val' }] })
|
|
2858
|
+
\`\`\`
|
|
2859
|
+
|
|
2860
|
+
### For events and extend \u2014 ALWAYS use dedicated hooks (INSTEAD of useCapabilities direct):
|
|
2861
|
+
**IMPORTANT:** Event and extend capabilities have their own React hooks. Never use \`capabilities.events.*\` or \`capabilities.extend.*\` \u2014 those do not exist.
|
|
2862
|
+
|
|
2863
|
+
\`\`\`tsx
|
|
2864
|
+
// events:identity \u2014 use useIdentityEvent hook
|
|
2865
|
+
${HOOK_SNIPPETS["events:identity"]}
|
|
2866
|
+
\`\`\`
|
|
2867
|
+
|
|
2868
|
+
\`\`\`tsx
|
|
2869
|
+
// events:messaging \u2014 use useMessagingEvent hook
|
|
2870
|
+
${HOOK_SNIPPETS["events:messaging"]}
|
|
2871
|
+
\`\`\`
|
|
2872
|
+
|
|
2873
|
+
\`\`\`tsx
|
|
2874
|
+
// events:activity \u2014 use useActivityEvent hook
|
|
2875
|
+
${HOOK_SNIPPETS["events:activity"]}
|
|
2876
|
+
\`\`\`
|
|
2877
|
+
|
|
2878
|
+
\`\`\`tsx
|
|
2879
|
+
// extend:identity \u2014 use useExtendIdentity hook
|
|
2880
|
+
${HOOK_SNIPPETS["extend.identity"]}
|
|
2881
|
+
\`\`\`
|
|
2882
|
+
|
|
2883
|
+
## 6. Verify
|
|
2884
|
+
- Confirm the permission is in manifest.json
|
|
2885
|
+
- If data.fetch, confirm the domain is in allowedDomains
|
|
2886
|
+
- For data/context/actions: confirm accessed via \`useCapabilities()\` hook
|
|
2887
|
+
- For events: confirm using \`useIdentityEvent\`, \`useMessagingEvent\`, or \`useActivityEvent\` hooks
|
|
2888
|
+
- For extend: confirm using \`useExtendIdentity\` hook
|
|
2889
|
+
`;
|
|
2890
|
+
};
|
|
2891
|
+
|
|
2892
|
+
// ../../sdk/extension/ai-docs/src/commands/add-component.ts
|
|
2893
|
+
var generateAddComponentCommand = () => {
|
|
2894
|
+
const fm = frontmatter({
|
|
2895
|
+
description: "Add a UI component to this Stackable extension",
|
|
2896
|
+
targets: ["*"]
|
|
2897
|
+
});
|
|
2898
|
+
return `${fm}
|
|
2899
|
+
|
|
2900
|
+
# Add a UI Component
|
|
2901
|
+
|
|
2902
|
+
Add a UI component to this extension. Follow these steps:
|
|
2903
|
+
|
|
2904
|
+
## 1. Identify the component
|
|
2905
|
+
All UI components use the \`ui.*\` namespace from \`@stackable-labs/sdk-extension-react\`.
|
|
2906
|
+
|
|
2907
|
+
**Available components by category:**
|
|
2908
|
+
- **Layout:** Card, CardContent, CardHeader, Stack, Inline, Separator, ScrollArea
|
|
2909
|
+
- **Text:** Text, Heading, Badge
|
|
2910
|
+
- **Input:** Button, Input, Textarea, Select, SelectOption, Checkbox, Switch, Label, RadioGroup, RadioGroupItem
|
|
2911
|
+
- **Feedback:** Skeleton, Tooltip, Progress, Alert
|
|
2912
|
+
- **Navigation:** Tabs, TabsList, TabsTrigger, TabsContent, Link, Menu, MenuItem
|
|
2913
|
+
- **Composite:** Collapsible, CollapsibleTrigger, CollapsibleContent, Avatar, Icon
|
|
2914
|
+
|
|
2915
|
+
## 2. Place the component
|
|
2916
|
+
- Insert inside an existing \`<Surface>\` block \u2014 never outside a Surface
|
|
2917
|
+
- Place in a logical position within the existing UI structure
|
|
2918
|
+
- For compound components, include their required children:
|
|
2919
|
+
- **Card** \u2192 CardContent (and optionally CardHeader)
|
|
2920
|
+
- **Tabs** \u2192 TabsList + TabsTrigger(s) + TabsContent(s)
|
|
2921
|
+
- **Select** \u2192 SelectOption(s)
|
|
2922
|
+
- **Menu** \u2192 MenuItem(s)
|
|
2923
|
+
- **Collapsible** \u2192 CollapsibleTrigger + CollapsibleContent
|
|
2924
|
+
- **RadioGroup** \u2192 RadioGroupItem(s) + Label(s)
|
|
2925
|
+
|
|
2926
|
+
## 3. Import
|
|
2927
|
+
Ensure \`ui\` is imported from \`@stackable-labs/sdk-extension-react\`. It usually already is.
|
|
2928
|
+
No additional imports needed \u2014 all components are accessed via \`ui.ComponentName\`.
|
|
2929
|
+
|
|
2930
|
+
## 4. Constraints
|
|
2931
|
+
- Do not remove or change any existing code \u2014 only add the new component
|
|
2932
|
+
- Do not add components outside of a Surface
|
|
2933
|
+
- Do not use raw HTML elements \u2014 only \`ui.*\` components are allowed in the sandbox
|
|
2934
|
+
- Child-only tags (CardContent, CardHeader, SelectOption, TabsList, TabsTrigger, TabsContent,
|
|
2935
|
+
RadioGroupItem, CollapsibleTrigger, CollapsibleContent, MenuItem) must be nested inside
|
|
2936
|
+
their parent \u2014 never standalone
|
|
2937
|
+
`;
|
|
2938
|
+
};
|
|
2939
|
+
var generateValidateExtensionCommand = () => {
|
|
2940
|
+
const fm = frontmatter({
|
|
2941
|
+
description: "Validate this extension for common errors before deploying (manifest, permissions, surfaces, imports)",
|
|
2942
|
+
targets: ["*"]
|
|
2943
|
+
});
|
|
2944
|
+
return `${fm}
|
|
2945
|
+
|
|
2946
|
+
# Validate Extension
|
|
2947
|
+
|
|
2948
|
+
Check this extension for common errors before deploying. Run through each check
|
|
2949
|
+
and report all issues found.
|
|
2950
|
+
|
|
2951
|
+
## 1. Manifest validation
|
|
2952
|
+
Read \`packages/extension/public/manifest.json\` and verify:
|
|
2953
|
+
- [ ] Valid JSON
|
|
2954
|
+
- [ ] \`name\` is a non-empty string
|
|
2955
|
+
- [ ] \`version\` follows semver (e.g., "1.0.0")
|
|
2956
|
+
- [ ] \`targets\` is a non-empty array of valid target strings (slot.header, slot.content, slot.footer, slot.footer-links)
|
|
2957
|
+
- [ ] \`permissions\` contains only valid permission strings (${PERMISSIONS.join(", ")})
|
|
2958
|
+
- [ ] \`allowedDomains\` is an array (can be empty if data:fetch is not used)
|
|
2959
|
+
- [ ] If \`data:fetch\` permission is declared, \`allowedDomains\` is not empty
|
|
2960
|
+
|
|
2961
|
+
## 2. Permission-to-usage matching
|
|
2962
|
+
Scan all \`.tsx\` files in \`packages/extension/src/\` for capability usage:
|
|
2963
|
+
- \`capabilities.data.query\` or \`data.query\` \u2192 needs \`data:query\` permission
|
|
2964
|
+
- \`capabilities.data.fetch\` or \`data.fetch\` \u2192 needs \`data:fetch\` permission
|
|
2965
|
+
- \`capabilities.context.read\` or \`useContextData\` \u2192 needs \`context:read\` permission
|
|
2966
|
+
- \`capabilities.actions.toast\` \u2192 needs \`actions:toast\` permission
|
|
2967
|
+
- \`capabilities.actions.invoke\` \u2192 needs \`actions:invoke\` permission
|
|
2968
|
+
- \`useExtendIdentity\` \u2192 needs \`extend:identity\` permission
|
|
2969
|
+
- \`useIdentityEvent\` \u2192 needs \`events:identity\` permission (also check manifest \`events\` array has matching entries)
|
|
2970
|
+
- \`useMessagingEvent\` \u2192 needs \`events:messaging\` permission (also check manifest \`events\` array has matching entries)
|
|
2971
|
+
- \`useActivityEvent\` \u2192 needs \`events:activity\` permission (also check manifest \`events\` array has matching entries)
|
|
2972
|
+
|
|
2973
|
+
Report:
|
|
2974
|
+
- **Missing permissions:** capabilities used in code but not declared in manifest
|
|
2975
|
+
- **Unused permissions:** permissions declared in manifest but not used in code
|
|
2976
|
+
|
|
2977
|
+
## 3. Surface-to-target matching
|
|
2978
|
+
- Each \`.tsx\` file with a \`<Surface id="...">\` should have a matching target in manifest.json
|
|
2979
|
+
- Each target in manifest.json should have a corresponding Surface component
|
|
2980
|
+
|
|
2981
|
+
## 4. Import validation
|
|
2982
|
+
Verify that:
|
|
2983
|
+
- [ ] UI components are imported via \`ui.*\` namespace from \`@stackable-labs/sdk-extension-react\`
|
|
2984
|
+
- [ ] No direct imports of \`document\`, \`window.location\`, or other browser globals
|
|
2985
|
+
- [ ] No modifications to \`packages/preview/\` directory
|
|
2986
|
+
- [ ] Entry point is \`src/index.tsx\` using \`createExtension\`
|
|
2987
|
+
|
|
2988
|
+
## 5. Summary
|
|
2989
|
+
Print a summary: total issues found, categorized by severity (error vs warning).
|
|
2990
|
+
Errors must be fixed before deploying. Warnings are recommendations.
|
|
2991
|
+
`;
|
|
2992
|
+
};
|
|
2993
|
+
|
|
2994
|
+
// ../../sdk/extension/ai-docs/src/skills.ts
|
|
2995
|
+
var SKILLS = [
|
|
2996
|
+
// ── Knowledge skills ──────────────────────────────────────────
|
|
2997
|
+
{
|
|
2998
|
+
id: "overview",
|
|
2999
|
+
description: "Stackable Labs Extension SDK overview, packages, import conventions, and project structure. Use when starting a new extension, asking about the SDK, or needing architectural context.",
|
|
3000
|
+
type: "knowledge",
|
|
3001
|
+
content: () => generateOverview(),
|
|
3002
|
+
references: {
|
|
3003
|
+
"core.md": () => generateCoreReference()
|
|
3004
|
+
}
|
|
3005
|
+
},
|
|
3006
|
+
{
|
|
3007
|
+
id: "components",
|
|
3008
|
+
description: "UI component catalog with allowed attributes per tag and available icon names. Use when building extension UI, looking up component attributes, or asking about available components.",
|
|
3009
|
+
type: "knowledge",
|
|
3010
|
+
content: () => generateComponents(),
|
|
3011
|
+
references: {
|
|
3012
|
+
"icons.md": () => generateIconReference()
|
|
3013
|
+
}
|
|
3014
|
+
},
|
|
3015
|
+
{
|
|
3016
|
+
id: "capabilities",
|
|
3017
|
+
description: "Extension capabilities: data.query, data.fetch, context.read, actions.toast, actions.invoke. Use when wiring up host-mediated APIs or direct HTTP requests.",
|
|
3018
|
+
type: "knowledge",
|
|
3019
|
+
content: () => generateCapabilities()
|
|
3020
|
+
},
|
|
3021
|
+
{
|
|
3022
|
+
id: "permissions",
|
|
3023
|
+
description: "Permission strings, capability-to-permission mapping, and target conventions. Use when configuring manifest.json or debugging permission errors.",
|
|
3024
|
+
type: "knowledge",
|
|
3025
|
+
content: () => generatePermissions()
|
|
3026
|
+
},
|
|
3027
|
+
{
|
|
3028
|
+
id: "hooks-api",
|
|
3029
|
+
description: "Extension hooks and API reference: createExtension, Surface, useCapabilities, createStore, useContextData. Use when writing extension runtime code.",
|
|
3030
|
+
type: "knowledge",
|
|
3031
|
+
content: () => generateHooksAndApi()
|
|
3032
|
+
},
|
|
3033
|
+
{
|
|
3034
|
+
id: "guardrails",
|
|
3035
|
+
description: "SDK constraints and rules that must always be followed: sandbox restrictions, component usage, manifest requirements, entry point rules. Use as a checklist when writing or reviewing extension code.",
|
|
3036
|
+
type: "knowledge",
|
|
3037
|
+
scopes: ["studio", "registry"],
|
|
3038
|
+
content: () => generateGuardrails()
|
|
3039
|
+
},
|
|
3040
|
+
{
|
|
3041
|
+
id: "patterns",
|
|
3042
|
+
description: "Code patterns extracted from the reference extension: entry point, store navigation, surface composition, API wrapper. Use when implementing common extension patterns.",
|
|
3043
|
+
type: "knowledge",
|
|
3044
|
+
content: () => generatePatterns()
|
|
3045
|
+
},
|
|
3046
|
+
{
|
|
3047
|
+
id: "recipes",
|
|
3048
|
+
description: "Reference code examples from the template extension source. Use when looking for working examples of store management, content surfaces, or API wrappers.",
|
|
3049
|
+
type: "knowledge",
|
|
3050
|
+
content: () => generateRecipes()
|
|
3051
|
+
},
|
|
3052
|
+
{
|
|
3053
|
+
id: "suggestions",
|
|
3054
|
+
description: "Contextual suggestion patterns for recommending next steps based on current extension code, capabilities, and surfaces. Use when deciding what to suggest after making changes.",
|
|
3055
|
+
type: "knowledge",
|
|
3056
|
+
scopes: ["studio"],
|
|
3057
|
+
content: () => generateSuggestions()
|
|
3058
|
+
},
|
|
3059
|
+
// ── Dual-use knowledge skills (studio + docs) ────────────────
|
|
3060
|
+
{
|
|
3061
|
+
id: "surfaces",
|
|
3062
|
+
description: "Surface types, lifecycle, layout slots, and multi-surface composition. Use when adding or configuring surfaces in an extension.",
|
|
3063
|
+
type: "knowledge",
|
|
3064
|
+
content: () => generateSurfaces()
|
|
3065
|
+
},
|
|
3066
|
+
{
|
|
3067
|
+
id: "external-apis",
|
|
3068
|
+
description: "Direct HTTP requests via data.fetch, allowedDomains configuration, and API wrapper patterns. Use when connecting an extension to external APIs.",
|
|
3069
|
+
type: "knowledge",
|
|
3070
|
+
content: () => generateExternalApis()
|
|
3071
|
+
},
|
|
3072
|
+
{
|
|
3073
|
+
id: "store-and-navigation",
|
|
3074
|
+
description: "Cross-surface state management with createStore, useStore selectors, and store-based navigation patterns. Use when managing shared state or view navigation.",
|
|
3075
|
+
type: "knowledge",
|
|
3076
|
+
content: () => generateStoreAndNavigation()
|
|
3077
|
+
},
|
|
3078
|
+
{
|
|
3079
|
+
id: "styling-and-theming",
|
|
3080
|
+
description: "Host theme inheritance, className usage, layout components, and CSS constraints. Use when styling extension UI or working with the host theme.",
|
|
3081
|
+
type: "knowledge",
|
|
3082
|
+
content: () => generateStylingAndTheming()
|
|
3083
|
+
},
|
|
3084
|
+
// ── Docs-only knowledge skills ──────────────────────────────
|
|
3085
|
+
{
|
|
3086
|
+
id: "quick-start",
|
|
3087
|
+
description: "Step-by-step guide to create, develop, and deploy your first Stackable extension.",
|
|
3088
|
+
type: "action",
|
|
3089
|
+
scopes: ["docs"],
|
|
3090
|
+
content: () => generateQuickStart()
|
|
3091
|
+
},
|
|
3092
|
+
{
|
|
3093
|
+
id: "project-structure",
|
|
3094
|
+
description: "Extension project file structure, entry point, surfaces, store, and manifest configuration.",
|
|
3095
|
+
type: "knowledge",
|
|
3096
|
+
scopes: ["docs"],
|
|
3097
|
+
content: () => generateProjectStructure()
|
|
3098
|
+
},
|
|
3099
|
+
{
|
|
3100
|
+
id: "cli-reference",
|
|
3101
|
+
description: "CLI commands for creating, developing, validating, and deploying Stackable extensions.",
|
|
3102
|
+
type: "knowledge",
|
|
3103
|
+
scopes: ["docs"],
|
|
3104
|
+
content: () => generateCliReference()
|
|
3105
|
+
},
|
|
3106
|
+
{
|
|
3107
|
+
id: "extension-studio",
|
|
3108
|
+
description: "Extension Studio: in-browser builder with AI assistant, component palette, and live preview. Use when learning about Studio or comparing Studio vs CLI workflows.",
|
|
3109
|
+
type: "knowledge",
|
|
3110
|
+
scopes: ["docs"],
|
|
3111
|
+
content: () => generateExtensionStudio()
|
|
3112
|
+
},
|
|
3113
|
+
// ── Cookbook skills (docs-only) ────────────────────────────────
|
|
3114
|
+
{
|
|
3115
|
+
id: "cookbook-structural",
|
|
3116
|
+
description: "Cookbook: extension bootstrap, surface declaration, store navigation, and loading patterns.",
|
|
3117
|
+
type: "knowledge",
|
|
3118
|
+
scopes: ["docs"],
|
|
3119
|
+
content: () => generateCookbookStructural()
|
|
3120
|
+
},
|
|
3121
|
+
{
|
|
3122
|
+
id: "cookbook-capabilities",
|
|
3123
|
+
description: "Cookbook: data.query, data.fetch, context.read, actions.toast, actions.invoke capability examples.",
|
|
3124
|
+
type: "knowledge",
|
|
3125
|
+
scopes: ["docs"],
|
|
3126
|
+
content: () => generateCookbookCapabilities()
|
|
3127
|
+
},
|
|
3128
|
+
{
|
|
3129
|
+
id: "cookbook-events",
|
|
3130
|
+
description: "Cookbook: identity, messaging, and activity event subscriptions + extend identity.",
|
|
3131
|
+
type: "knowledge",
|
|
3132
|
+
scopes: ["docs"],
|
|
3133
|
+
content: () => generateCookbookEvents()
|
|
3134
|
+
},
|
|
3135
|
+
// ── Action skills ─────────────────────────────────────────────
|
|
3136
|
+
{
|
|
3137
|
+
id: "add-surface",
|
|
3138
|
+
description: "Add a new surface (UI slot) to this Stackable extension. Use when the user wants to add a header, content, footer, or footer-links surface.",
|
|
3139
|
+
type: "action",
|
|
3140
|
+
content: () => generateAddSurfaceCommand()
|
|
3141
|
+
},
|
|
3142
|
+
{
|
|
3143
|
+
id: "add-capability",
|
|
3144
|
+
description: "Wire up a new capability (data.fetch, data.query, context.read, actions.toast, actions.invoke) in this extension. Use when adding a new host-mediated API.",
|
|
3145
|
+
type: "action",
|
|
3146
|
+
content: () => generateAddCapabilityCommand()
|
|
3147
|
+
},
|
|
3148
|
+
{
|
|
3149
|
+
id: "add-component",
|
|
3150
|
+
description: "Add a UI component (ui.Card, ui.Button, ui.Tabs, etc.) to this extension. Use when the user wants to add a visual element.",
|
|
3151
|
+
type: "action",
|
|
3152
|
+
content: () => generateAddComponentCommand()
|
|
3153
|
+
},
|
|
3154
|
+
{
|
|
3155
|
+
id: "validate",
|
|
3156
|
+
description: "Validate this extension for common errors before deploying: manifest, permissions, surfaces, imports. Use before publishing or when debugging issues.",
|
|
3157
|
+
type: "action",
|
|
3158
|
+
content: () => generateValidateExtensionCommand()
|
|
3159
|
+
}
|
|
3160
|
+
];
|
|
3161
|
+
|
|
3162
|
+
// ../../../node_modules/.pnpm/ultramatter@0.0.4/node_modules/ultramatter/dist/index.js
|
|
3163
|
+
function b(t) {
|
|
3164
|
+
let e = 0, r = "default", i = [];
|
|
3165
|
+
for (let n = 0; n < t.length; n++) {
|
|
3166
|
+
if (t[n] === "-") if (e === 0) if (r === "default" && n == 0 || r === "open" && t[n - 1] === `
|
|
3167
|
+
`) e = 1;
|
|
3168
|
+
else continue;
|
|
3169
|
+
else e++;
|
|
3170
|
+
else e = 0;
|
|
3171
|
+
e === 3 && (r = r === "default" ? "open" : "closed", i.push(n + 1));
|
|
3172
|
+
}
|
|
3173
|
+
switch (r) {
|
|
3174
|
+
case "default":
|
|
3175
|
+
return ["", t];
|
|
3176
|
+
case "open":
|
|
3177
|
+
return ["", t];
|
|
3178
|
+
case "closed":
|
|
3179
|
+
return [t.slice(i[0], i[1] - 3).trim(), t.slice(i[1]).trimStart()];
|
|
3180
|
+
}
|
|
3181
|
+
}
|
|
3182
|
+
var h = (t) => t.slice(0, t.length - t.trimStart().length);
|
|
3183
|
+
var a = (t) => {
|
|
3184
|
+
let e = h(t);
|
|
3185
|
+
return t.split(`
|
|
3186
|
+
`).map((r) => r.slice(e.length)).join(`
|
|
3187
|
+
`);
|
|
3188
|
+
};
|
|
3189
|
+
var E = (t) => {
|
|
3190
|
+
if (t[0] === '"' || t[0] === "'") {
|
|
3191
|
+
let e = t[0];
|
|
3192
|
+
if (t[t.length - 1] === e) return t.slice(1, -1);
|
|
3193
|
+
}
|
|
3194
|
+
return t;
|
|
3195
|
+
};
|
|
3196
|
+
var d = (t) => t === "true" || t === "false" ? t === "true" : Number.isNaN(Number(t)) ? E(t) : Number(t);
|
|
3197
|
+
var o = (t) => {
|
|
3198
|
+
let e = /(^[^\:\s]+):(?!\/)\n?([\s\S]*?(?=^\S)|[\s\S]*$)/gm, r = /[\:\-\[\]\|\#]/gm, i = /#.*$/gm, n = h(t);
|
|
3199
|
+
if (!r.test(t)) return n.length > 1 ? a(t) : d(t.trim());
|
|
3200
|
+
n.length <= 1 && (t = t.trimStart());
|
|
3201
|
+
let f, l = {};
|
|
3202
|
+
for (; f = e.exec(t); ) {
|
|
3203
|
+
let [m, c, g] = f;
|
|
3204
|
+
if (h(g).length > 1) {
|
|
3205
|
+
let u = a(g);
|
|
3206
|
+
l[c] = o(u);
|
|
3207
|
+
} else l[c] = o(g);
|
|
3208
|
+
}
|
|
3209
|
+
if (Object.keys(l).length > 0) return l;
|
|
3210
|
+
let s = t.trim().replace(i, "").trim();
|
|
3211
|
+
return s.startsWith("-") ? s.split(/^\-/gm).filter((c) => c).map((c) => o(c.trimEnd())) : s.startsWith("[") ? (s = s.slice(1, -1), s.split(",").map((m) => d(m.trim()))) : s.startsWith("|") ? a(s.replace("|", "").replace(`
|
|
3212
|
+
`, "")) : d(s.trim());
|
|
3213
|
+
};
|
|
3214
|
+
function R(t) {
|
|
3215
|
+
let [e, r] = b(t);
|
|
3216
|
+
return e ? { frontmatter: o(e), content: r } : { content: r };
|
|
3217
|
+
}
|
|
3218
|
+
|
|
3219
|
+
// ../../sdk/extension/ai-docs/src/skill-loader.ts
|
|
3220
|
+
var listSkills = (scope) => SKILLS.filter((s) => !s.scopes || s.scopes.includes(scope));
|
|
3221
|
+
var getSkillBody = (skill) => {
|
|
3222
|
+
const raw = skill.content();
|
|
3223
|
+
const { content } = R(raw);
|
|
3224
|
+
return content.trim();
|
|
3225
|
+
};
|
|
3226
|
+
var getSkillMetadata = (skill) => ({ id: skill.id, description: skill.description });
|
|
3227
|
+
var lookupSkill = (skills, id) => {
|
|
3228
|
+
const skill = skills.find((s) => s.id === id);
|
|
3229
|
+
if (!skill) {
|
|
3230
|
+
return null;
|
|
3231
|
+
}
|
|
3232
|
+
return getSkillBody(skill);
|
|
3233
|
+
};
|
|
3234
|
+
var findRelevantSkills = (skills, query) => {
|
|
3235
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
3236
|
+
if (terms.length === 0) {
|
|
3237
|
+
return [];
|
|
3238
|
+
}
|
|
3239
|
+
return skills.filter((skill) => {
|
|
3240
|
+
const text = `${skill.id} ${skill.description}`.toLowerCase();
|
|
3241
|
+
return terms.some((term) => text.includes(term));
|
|
3242
|
+
});
|
|
3243
|
+
};
|
|
3244
|
+
|
|
3245
|
+
// ../../sdk/extension/ai-docs/src/snippets/capabilities.ts
|
|
3246
|
+
var CAPABILITY_SNIPPETS = {
|
|
3247
|
+
"data.query": `
|
|
3248
|
+
const capabilities = useCapabilities()
|
|
3249
|
+
const result = await capabilities.data.query({ path: '/your-endpoint', method: 'GET' })
|
|
3250
|
+
`,
|
|
3251
|
+
"data.fetch": `
|
|
3252
|
+
const capabilities = useCapabilities()
|
|
3253
|
+
const response = await capabilities.data.fetch('https://api.example.com/endpoint')
|
|
3254
|
+
`,
|
|
3255
|
+
"context.read": `
|
|
3256
|
+
const capabilities = useCapabilities()
|
|
3257
|
+
const ctx = await capabilities.context.read()
|
|
3258
|
+
`,
|
|
3259
|
+
"actions.toast": `
|
|
3260
|
+
const capabilities = useCapabilities()
|
|
3261
|
+
capabilities.actions.toast({ type: 'success', message: 'Done!' })
|
|
3262
|
+
`,
|
|
3263
|
+
"actions.invoke": `
|
|
3264
|
+
const capabilities = useCapabilities()
|
|
3265
|
+
|
|
3266
|
+
// New conversation with tags and fields
|
|
3267
|
+
await capabilities.actions.invoke('newConversation', {
|
|
3268
|
+
tags: ['stackable', 'order-lookup'],
|
|
3269
|
+
fields: [{ id: 'stackable_action', value: 'order_status' }],
|
|
3270
|
+
metadata: { orderId: '12345' },
|
|
3271
|
+
})
|
|
3272
|
+
|
|
3273
|
+
// Standalone: set tags on current/next conversation
|
|
3274
|
+
await capabilities.actions.invoke('setConversationTags', ['escalated', 'order-issue'])
|
|
3275
|
+
`,
|
|
3276
|
+
"events:identity": HOOK_SNIPPETS["events:identity"],
|
|
3277
|
+
"events:messaging": HOOK_SNIPPETS["events:messaging"],
|
|
3278
|
+
"events:activity": HOOK_SNIPPETS["events:activity"],
|
|
3279
|
+
"extend.identity": HOOK_SNIPPETS["extend.identity"]
|
|
3280
|
+
};
|
|
3281
|
+
var EVENT_SNIPPETS = {
|
|
3282
|
+
"events:identity": `import { useIdentityEvent, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
3283
|
+
import type { IdentityEvent } from '@stackable-labs/sdk-extension-contracts'
|
|
3284
|
+
import { useState } from 'react'
|
|
3285
|
+
|
|
3286
|
+
export function Header(): React.ReactElement {
|
|
3287
|
+
const [user, setUser] = useState<string | null>(null)
|
|
3288
|
+
|
|
3289
|
+
useIdentityEvent('login', (event: IdentityEvent) => {
|
|
3290
|
+
setUser(event.data.state.user?.email ?? null)
|
|
3291
|
+
})
|
|
3292
|
+
|
|
3293
|
+
useIdentityEvent('logout', () => {
|
|
3294
|
+
setUser(null)
|
|
3295
|
+
})
|
|
3296
|
+
|
|
3297
|
+
return (
|
|
3298
|
+
<Surface id="slot.header">
|
|
3299
|
+
<ui.Text className="text-xs">{user ?? 'Not logged in'}</ui.Text>
|
|
3300
|
+
</Surface>
|
|
3301
|
+
)
|
|
3302
|
+
}`,
|
|
3303
|
+
"events:messaging": `import { useMessagingEvent, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
3304
|
+
import type { MessagingEventHandler } from '@stackable-labs/sdk-extension-contracts'
|
|
3305
|
+
import { useState } from 'react'
|
|
3306
|
+
|
|
3307
|
+
export function Content(): React.ReactElement {
|
|
3308
|
+
const [lastPostback, setLastPostback] = useState<string | null>(null)
|
|
3309
|
+
|
|
3310
|
+
useMessagingEvent('postback', (event) => {
|
|
3311
|
+
setLastPostback(event.data.actionName)
|
|
3312
|
+
})
|
|
3313
|
+
|
|
3314
|
+
return (
|
|
3315
|
+
<Surface id="slot.content">
|
|
3316
|
+
<ui.Text className="text-xs">{lastPostback ?? 'No postbacks yet'}</ui.Text>
|
|
3317
|
+
</Surface>
|
|
3318
|
+
)
|
|
3319
|
+
}`,
|
|
3320
|
+
"events:activity": `import { useActivityEvent, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
3321
|
+
import type { ActivityEventHandler } from '@stackable-labs/sdk-extension-contracts'
|
|
3322
|
+
import { useState } from 'react'
|
|
3323
|
+
|
|
3324
|
+
export function Content(): React.ReactElement {
|
|
3325
|
+
const [lastEvent, setLastEvent] = useState<string | null>(null)
|
|
3326
|
+
|
|
3327
|
+
useActivityEvent('page_view', (event) => {
|
|
3328
|
+
setLastEvent(event.data.url as string)
|
|
3329
|
+
})
|
|
3330
|
+
|
|
3331
|
+
return (
|
|
3332
|
+
<Surface id="slot.content">
|
|
3333
|
+
<ui.Text className="text-xs">{lastEvent ?? 'No activity yet'}</ui.Text>
|
|
3334
|
+
</Surface>
|
|
3335
|
+
)
|
|
3336
|
+
}`,
|
|
3337
|
+
"extend.identity": `import { useExtendIdentity } from '@stackable-labs/sdk-extension-react'
|
|
3338
|
+
import type { ExtendIdentityHandler } from '@stackable-labs/sdk-extension-contracts'
|
|
3339
|
+
|
|
3340
|
+
// Enrich identity JWT claims before signing.
|
|
3341
|
+
// The host sends base claims (external_id, email, name),
|
|
3342
|
+
// and your handler returns additional claims to merge.
|
|
3343
|
+
// Use ExtendIdentityHandler type with useCallback for memoized handlers.
|
|
3344
|
+
useExtendIdentity((claims) => ({
|
|
3345
|
+
external_id: \`shopify_\${claims.external_id}\`,
|
|
3346
|
+
loyalty_tier: 'gold',
|
|
3347
|
+
}))`
|
|
3348
|
+
};
|
|
3349
|
+
|
|
3350
|
+
// ../../sdk/extension/ai-docs/src/generated/example-snippets-jsx.ts
|
|
3351
|
+
var EXAMPLE_SNIPPETS2 = {
|
|
3352
|
+
"bootstrap": `import { createExtension } from '@stackable-labs/sdk-extension-react'
|
|
3353
|
+
import { Header } from './surfaces/Header'
|
|
3354
|
+
import { Content } from './surfaces/Content'
|
|
3355
|
+
import { Footer } from './surfaces/Footer'
|
|
3356
|
+
|
|
3357
|
+
const Extension = () => (
|
|
3358
|
+
<>
|
|
3359
|
+
<Header />
|
|
3360
|
+
<Content />
|
|
3361
|
+
<Footer />
|
|
3362
|
+
</>
|
|
3363
|
+
)
|
|
3364
|
+
|
|
3365
|
+
// NOTE: extensionId is optional \u2014 used when connected to a registered extension
|
|
3366
|
+
createExtension(() => <Extension />, { extensionId: 'my-extension' })`,
|
|
3367
|
+
"surfaces": `import { Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
3368
|
+
|
|
3369
|
+
export function Header() {
|
|
3370
|
+
return (
|
|
3371
|
+
<Surface id="slot.header">
|
|
3372
|
+
<ui.Stack direction="column" gap="2" className="p-3">
|
|
3373
|
+
<ui.Text className="text-sm font-medium">Hello from slot.header</ui.Text>
|
|
3374
|
+
</ui.Stack>
|
|
3375
|
+
</Surface>
|
|
3376
|
+
)
|
|
3377
|
+
}
|
|
3378
|
+
|
|
3379
|
+
export function Content() {
|
|
3380
|
+
return (
|
|
3381
|
+
<Surface id="slot.content">
|
|
3382
|
+
<ui.Stack direction="column" gap="3" className="p-4">
|
|
3383
|
+
<ui.Heading level="3">Content Surface</ui.Heading>
|
|
3384
|
+
<ui.Text>Rendered inside the host at slot.content.</ui.Text>
|
|
3385
|
+
</ui.Stack>
|
|
3386
|
+
</Surface>
|
|
3387
|
+
)
|
|
3388
|
+
}`,
|
|
3389
|
+
"store": `import { createStore, useStore, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
3390
|
+
|
|
3391
|
+
// store.ts
|
|
3392
|
+
|
|
3393
|
+
export const appStore = createStore({
|
|
3394
|
+
viewState: { type: 'list' },
|
|
3395
|
+
})
|
|
3396
|
+
|
|
3397
|
+
// Content.tsx
|
|
3398
|
+
export function Content() {
|
|
3399
|
+
const viewState = useStore(appStore, (s) => s.viewState)
|
|
3400
|
+
|
|
3401
|
+
const goToDetail = (id) => appStore.set({ viewState: { type: 'detail', id } })
|
|
3402
|
+
const goBack = () => appStore.set({ viewState: { type: 'list' } })
|
|
3403
|
+
|
|
3404
|
+
return (
|
|
3405
|
+
<Surface id="slot.content">
|
|
3406
|
+
{viewState.type === 'list' && (
|
|
3407
|
+
<ui.Button onClick={() => goToDetail('abc123')}>View Detail</ui.Button>
|
|
3408
|
+
)}
|
|
3409
|
+
{viewState.type === 'detail' && (
|
|
3410
|
+
<ui.Stack direction="column" gap="2">
|
|
3411
|
+
<ui.Text>Viewing: {viewState.id}</ui.Text>
|
|
3412
|
+
<ui.Button onClick={goBack}>Back</ui.Button>
|
|
3413
|
+
</ui.Stack>
|
|
3414
|
+
)}
|
|
3415
|
+
</Surface>
|
|
3416
|
+
)
|
|
3417
|
+
}`,
|
|
3418
|
+
"loading": `import { useContextData, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
3419
|
+
|
|
3420
|
+
export function Content() {
|
|
3421
|
+
const { loading, customerId } = useContextData()
|
|
3422
|
+
|
|
3423
|
+
if (loading) {
|
|
3424
|
+
return (
|
|
3425
|
+
<Surface id="slot.content">
|
|
3426
|
+
<ui.Skeleton />
|
|
3427
|
+
</Surface>
|
|
3428
|
+
)
|
|
3429
|
+
}
|
|
3430
|
+
|
|
3431
|
+
if (!customerId) {
|
|
3432
|
+
return (
|
|
3433
|
+
<Surface id="slot.content">
|
|
3434
|
+
<ui.Stack direction="column" gap="1" className="p-4 items-center">
|
|
3435
|
+
<ui.Icon name="user" size="sm" />
|
|
3436
|
+
<ui.Text className="text-xs text-muted-foreground">No customer selected</ui.Text>
|
|
3437
|
+
</ui.Stack>
|
|
3438
|
+
</Surface>
|
|
3439
|
+
)
|
|
3440
|
+
}
|
|
3441
|
+
|
|
3442
|
+
return (
|
|
3443
|
+
<Surface id="slot.content">
|
|
3444
|
+
<ui.Text className="text-sm">Customer: {customerId}</ui.Text>
|
|
3445
|
+
</Surface>
|
|
3446
|
+
)
|
|
3447
|
+
}`,
|
|
3448
|
+
"surfaceContext": `import { useSurfaceContext, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
3449
|
+
|
|
3450
|
+
export function Header() {
|
|
3451
|
+
// Lower-level hook \u2014 reads host-pushed context for this specific surface
|
|
3452
|
+
const context = useSurfaceContext()
|
|
3453
|
+
|
|
3454
|
+
return (
|
|
3455
|
+
<Surface id="slot.header">
|
|
3456
|
+
<ui.Text className="text-xs">{context.customerId ?? 'No customer'}</ui.Text>
|
|
3457
|
+
</Surface>
|
|
3458
|
+
)
|
|
3459
|
+
}`,
|
|
3460
|
+
"context.read": `import { useContextData, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
3461
|
+
|
|
3462
|
+
export function Header() {
|
|
3463
|
+
const { loading, customerId, customerEmail } = useContextData()
|
|
3464
|
+
|
|
3465
|
+
if (loading) {
|
|
3466
|
+
return (
|
|
3467
|
+
<Surface id="slot.header">
|
|
3468
|
+
<ui.Text className="text-xs text-muted-foreground">Loading...</ui.Text>
|
|
3469
|
+
</Surface>
|
|
3470
|
+
)
|
|
3471
|
+
}
|
|
3472
|
+
|
|
3473
|
+
return (
|
|
3474
|
+
<Surface id="slot.header">
|
|
3475
|
+
<ui.Stack direction="column" gap="1">
|
|
3476
|
+
<ui.Text className="text-xs font-semibold">{customerEmail}</ui.Text>
|
|
3477
|
+
<ui.Text className="text-xs text-muted-foreground">ID: {customerId}</ui.Text>
|
|
3478
|
+
</ui.Stack>
|
|
3479
|
+
</Surface>
|
|
3480
|
+
)
|
|
3481
|
+
}`,
|
|
3482
|
+
"data.query": `import { useCapabilities, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
3483
|
+
import { useState } from 'react'
|
|
3484
|
+
|
|
3485
|
+
export function Content() {
|
|
3486
|
+
const capabilities = useCapabilities()
|
|
3487
|
+
const [data, setData] = useState(null)
|
|
3488
|
+
|
|
3489
|
+
const handleQuery = async () => {
|
|
3490
|
+
const payload = {
|
|
3491
|
+
action: 'getCustomer',
|
|
3492
|
+
customerId: '123',
|
|
3493
|
+
}
|
|
3494
|
+
const result = await capabilities.data.query(payload)
|
|
3495
|
+
setData(result)
|
|
3496
|
+
}
|
|
3497
|
+
|
|
3498
|
+
return (
|
|
3499
|
+
<Surface id="slot.content">
|
|
3500
|
+
<ui.Button onClick={handleQuery}>Fetch Data</ui.Button>
|
|
3501
|
+
</Surface>
|
|
3502
|
+
)
|
|
3503
|
+
}`,
|
|
3504
|
+
"data.fetch": `import { useCapabilities, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
3505
|
+
import { useState } from 'react'
|
|
3506
|
+
|
|
3507
|
+
export function Content() {
|
|
3508
|
+
const capabilities = useCapabilities()
|
|
3509
|
+
const [data, setData] = useState(null)
|
|
3510
|
+
|
|
3511
|
+
const handleGet = async () => {
|
|
3512
|
+
const result = await capabilities.data.fetch('https://api.myservice.com/orders')
|
|
3513
|
+
if (result.ok) setData(result.data)
|
|
3514
|
+
}
|
|
3515
|
+
|
|
3516
|
+
const handlePost = async () => {
|
|
3517
|
+
const result = await capabilities.data.fetch(
|
|
3518
|
+
'https://api.myservice.com/orders',
|
|
3519
|
+
{ method: 'POST', body: { limit: 10 } }
|
|
3520
|
+
)
|
|
3521
|
+
if (result.ok) setData(result.data)
|
|
3522
|
+
}
|
|
3523
|
+
|
|
3524
|
+
return (
|
|
3525
|
+
<Surface id="slot.content">
|
|
3526
|
+
<ui.Stack gap="2">
|
|
3527
|
+
<ui.Button onClick={handleGet}>GET Orders</ui.Button>
|
|
3528
|
+
<ui.Button onClick={handlePost}>POST Orders</ui.Button>
|
|
3529
|
+
</ui.Stack>
|
|
3530
|
+
</Surface>
|
|
3531
|
+
)
|
|
3532
|
+
}`,
|
|
3533
|
+
"actions.toast": `import { useCapabilities, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
3534
|
+
|
|
3535
|
+
export function Content() {
|
|
3536
|
+
const capabilities = useCapabilities()
|
|
3537
|
+
|
|
3538
|
+
const showToast = async () => {
|
|
3539
|
+
await capabilities.actions.toast({ type: 'success', message: 'Done!' })
|
|
3540
|
+
}
|
|
3541
|
+
|
|
3542
|
+
return (
|
|
3543
|
+
<Surface id="slot.content">
|
|
3544
|
+
<ui.Button onClick={showToast}>Show Toast</ui.Button>
|
|
3545
|
+
</Surface>
|
|
3546
|
+
)
|
|
3547
|
+
}`,
|
|
3548
|
+
"actions.invoke": `import { useCapabilities, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
3549
|
+
|
|
3550
|
+
export function Content() {
|
|
3551
|
+
const capabilities = useCapabilities()
|
|
3552
|
+
|
|
3553
|
+
const newConversation = async () => {
|
|
3554
|
+
await capabilities.actions.invoke('newConversation', {
|
|
3555
|
+
tags: ['stackable', 'order-lookup'],
|
|
3556
|
+
fields: [{ id: 'stackable_action', value: 'order_status' }],
|
|
3557
|
+
metadata: { orderId: '12345' },
|
|
3558
|
+
})
|
|
3559
|
+
}
|
|
3560
|
+
|
|
3561
|
+
const setTags = async () => {
|
|
3562
|
+
await capabilities.actions.invoke('setConversationTags', ['escalated', 'order-issue'])
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3565
|
+
return (
|
|
3566
|
+
<Surface id="slot.content">
|
|
3567
|
+
<ui.Stack gap="2">
|
|
3568
|
+
<ui.Button onClick={newConversation}>New Conversation</ui.Button>
|
|
3569
|
+
<ui.Button onClick={setTags}>Set Tags</ui.Button>
|
|
3570
|
+
</ui.Stack>
|
|
3571
|
+
</Surface>
|
|
3572
|
+
)
|
|
3573
|
+
}`,
|
|
3574
|
+
"extend.identity": `import { useExtendIdentity } from '@stackable-labs/sdk-extension-react'
|
|
3575
|
+
|
|
3576
|
+
// Enrich identity JWT claims before signing.
|
|
3577
|
+
// The host sends base claims (external_id, email, name),
|
|
3578
|
+
// and your handler returns additional claims to merge.
|
|
3579
|
+
useExtendIdentity((claims) => ({
|
|
3580
|
+
external_id: \`shopify_\${claims.external_id}\`,
|
|
3581
|
+
loyalty_tier: 'gold',
|
|
3582
|
+
}))`,
|
|
3583
|
+
"events:identity": `import { useIdentityEvent, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
3584
|
+
import { useState } from 'react'
|
|
3585
|
+
|
|
3586
|
+
export function Header() {
|
|
3587
|
+
const [user, setUser] = useState(null)
|
|
3588
|
+
|
|
3589
|
+
useIdentityEvent('login', (event) => {
|
|
3590
|
+
setUser(event.data.state.user?.email ?? null)
|
|
3591
|
+
})
|
|
3592
|
+
|
|
3593
|
+
useIdentityEvent('logout', () => {
|
|
3594
|
+
setUser(null)
|
|
3595
|
+
})
|
|
3596
|
+
|
|
3597
|
+
return (
|
|
3598
|
+
<Surface id="slot.header">
|
|
3599
|
+
<ui.Text className="text-xs">{user ?? 'Not logged in'}</ui.Text>
|
|
3600
|
+
</Surface>
|
|
3601
|
+
)
|
|
3602
|
+
}`,
|
|
3603
|
+
"events:messaging": `import { useMessagingEvent, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
3604
|
+
import { useState } from 'react'
|
|
3605
|
+
|
|
3606
|
+
export function Content() {
|
|
3607
|
+
const [lastPostback, setLastPostback] = useState(null)
|
|
3608
|
+
|
|
3609
|
+
useMessagingEvent('postback', (event) => {
|
|
3610
|
+
setLastPostback(event.data.actionName)
|
|
3611
|
+
})
|
|
3612
|
+
|
|
3613
|
+
return (
|
|
3614
|
+
<Surface id="slot.content">
|
|
3615
|
+
<ui.Text className="text-xs">{lastPostback ?? 'No postbacks yet'}</ui.Text>
|
|
3616
|
+
</Surface>
|
|
3617
|
+
)
|
|
3618
|
+
}`,
|
|
3619
|
+
"events:activity": `import { useActivityEvent, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
3620
|
+
import { useState } from 'react'
|
|
3621
|
+
|
|
3622
|
+
export function Content() {
|
|
3623
|
+
const [lastEvent, setLastEvent] = useState(null)
|
|
3624
|
+
|
|
3625
|
+
useActivityEvent('page_view', (event) => {
|
|
3626
|
+
setLastEvent(event.data.url)
|
|
3627
|
+
})
|
|
3628
|
+
|
|
3629
|
+
return (
|
|
3630
|
+
<Surface id="slot.content">
|
|
3631
|
+
<ui.Text className="text-xs">{lastEvent ?? 'No activity yet'}</ui.Text>
|
|
3632
|
+
</Surface>
|
|
3633
|
+
)
|
|
3634
|
+
}`
|
|
3635
|
+
};
|
|
3636
|
+
|
|
3637
|
+
// ../../sdk/extension/ai-docs/src/generated/capability-snippets-jsx.ts
|
|
3638
|
+
var CAPABILITY_SNIPPETS2 = {
|
|
3639
|
+
"data.query": `const capabilities = useCapabilities()
|
|
3640
|
+
const result = await capabilities.data.query({ path: '/your-endpoint', method: 'GET' })`,
|
|
3641
|
+
"data.fetch": `const capabilities = useCapabilities()
|
|
3642
|
+
const response = await capabilities.data.fetch('https://api.example.com/endpoint')`,
|
|
3643
|
+
"context.read": `const capabilities = useCapabilities()
|
|
3644
|
+
const ctx = await capabilities.context.read()`,
|
|
3645
|
+
"actions.toast": `const capabilities = useCapabilities()
|
|
3646
|
+
capabilities.actions.toast({ type: 'success', message: 'Done!' })`,
|
|
3647
|
+
"actions.invoke": `const capabilities = useCapabilities()
|
|
3648
|
+
|
|
3649
|
+
// New conversation with tags and fields
|
|
3650
|
+
await capabilities.actions.invoke('newConversation', {
|
|
3651
|
+
tags: ['stackable', 'order-lookup'],
|
|
3652
|
+
fields: [{ id: 'stackable_action', value: 'order_status' }],
|
|
3653
|
+
metadata: { orderId: '12345' },
|
|
3654
|
+
})
|
|
3655
|
+
|
|
3656
|
+
// Standalone: set tags on current/next conversation
|
|
3657
|
+
await capabilities.actions.invoke('setConversationTags', ['escalated', 'order-issue'])`,
|
|
3658
|
+
"events:identity": `import { useIdentityEvent } from '@stackable-labs/sdk-extension-react'
|
|
3659
|
+
|
|
3660
|
+
useIdentityEvent('login', (event) => {
|
|
3661
|
+
console.log('User logged in:', event.data.state.user?.email)
|
|
3662
|
+
})
|
|
3663
|
+
useIdentityEvent('logout', () => {
|
|
3664
|
+
console.log('User logged out')
|
|
3665
|
+
})`,
|
|
3666
|
+
"events:messaging": `import { useMessagingEvent } from '@stackable-labs/sdk-extension-react'
|
|
3667
|
+
|
|
3668
|
+
useMessagingEvent('postback:Buy Now', (event) => {
|
|
3669
|
+
console.log('Postback:', event.data.actionName, event.data.conversationId)
|
|
3670
|
+
})`,
|
|
3671
|
+
"events:activity": `import { useActivityEvent } from '@stackable-labs/sdk-extension-react'
|
|
3672
|
+
|
|
3673
|
+
useActivityEvent('product_view', (event) => {
|
|
3674
|
+
console.log('Activity:', event.eventName, event.data)
|
|
3675
|
+
})`,
|
|
3676
|
+
"extend.identity": `import { useExtendIdentity } from '@stackable-labs/sdk-extension-react'
|
|
3677
|
+
|
|
3678
|
+
useExtendIdentity((claims) => ({
|
|
3679
|
+
external_id: \`custom_\${claims.external_id}\`,
|
|
3680
|
+
loyalty_tier: 'gold',
|
|
3681
|
+
}))`
|
|
3682
|
+
};
|
|
3683
|
+
var EVENT_SNIPPETS2 = {
|
|
3684
|
+
"events:identity": `import { useIdentityEvent, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
3685
|
+
import { useState } from 'react'
|
|
3686
|
+
|
|
3687
|
+
export function Header() {
|
|
3688
|
+
const [user, setUser] = useState(null)
|
|
3689
|
+
|
|
3690
|
+
useIdentityEvent('login', (event) => {
|
|
3691
|
+
setUser(event.data.state.user?.email ?? null)
|
|
3692
|
+
})
|
|
3693
|
+
|
|
3694
|
+
useIdentityEvent('logout', () => {
|
|
3695
|
+
setUser(null)
|
|
3696
|
+
})
|
|
3697
|
+
|
|
3698
|
+
return (
|
|
3699
|
+
<Surface id="slot.header">
|
|
3700
|
+
<ui.Text className="text-xs">{user ?? 'Not logged in'}</ui.Text>
|
|
3701
|
+
</Surface>
|
|
3702
|
+
)
|
|
3703
|
+
}`,
|
|
3704
|
+
"events:messaging": `import { useMessagingEvent, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
3705
|
+
import { useState } from 'react'
|
|
3706
|
+
|
|
3707
|
+
export function Content() {
|
|
3708
|
+
const [lastPostback, setLastPostback] = useState(null)
|
|
3709
|
+
|
|
3710
|
+
useMessagingEvent('postback', (event) => {
|
|
3711
|
+
setLastPostback(event.data.actionName)
|
|
3712
|
+
})
|
|
3713
|
+
|
|
3714
|
+
return (
|
|
3715
|
+
<Surface id="slot.content">
|
|
3716
|
+
<ui.Text className="text-xs">{lastPostback ?? 'No postbacks yet'}</ui.Text>
|
|
3717
|
+
</Surface>
|
|
3718
|
+
)
|
|
3719
|
+
}`,
|
|
3720
|
+
"events:activity": `import { useActivityEvent, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
3721
|
+
import { useState } from 'react'
|
|
3722
|
+
|
|
3723
|
+
export function Content() {
|
|
3724
|
+
const [lastEvent, setLastEvent] = useState(null)
|
|
3725
|
+
|
|
3726
|
+
useActivityEvent('page_view', (event) => {
|
|
3727
|
+
setLastEvent(event.data.url)
|
|
3728
|
+
})
|
|
3729
|
+
|
|
3730
|
+
return (
|
|
3731
|
+
<Surface id="slot.content">
|
|
3732
|
+
<ui.Text className="text-xs">{lastEvent ?? 'No activity yet'}</ui.Text>
|
|
3733
|
+
</Surface>
|
|
3734
|
+
)
|
|
3735
|
+
}`,
|
|
3736
|
+
"extend.identity": `import { useExtendIdentity } from '@stackable-labs/sdk-extension-react'
|
|
3737
|
+
|
|
3738
|
+
// Enrich identity JWT claims before signing.
|
|
3739
|
+
// The host sends base claims (external_id, email, name),
|
|
3740
|
+
// and your handler returns additional claims to merge.
|
|
3741
|
+
// Use ExtendIdentityHandler type with useCallback for memoized handlers.
|
|
3742
|
+
useExtendIdentity((claims) => ({
|
|
3743
|
+
external_id: \`shopify_\${claims.external_id}\`,
|
|
3744
|
+
loyalty_tier: 'gold',
|
|
3745
|
+
}))`
|
|
3746
|
+
};
|
|
3747
|
+
|
|
3748
|
+
// ../../sdk/extension/ai-docs/src/index.ts
|
|
3749
|
+
var pair = (tsx, jsx) => Object.fromEntries(Object.entries(tsx).map(([k, v]) => [k, { tsx: v, jsx: jsx[k] ?? v }]));
|
|
3750
|
+
pair(EXAMPLE_SNIPPETS, EXAMPLE_SNIPPETS2);
|
|
3751
|
+
pair(CAPABILITY_SNIPPETS, CAPABILITY_SNIPPETS2);
|
|
3752
|
+
pair(EVENT_SNIPPETS, EVENT_SNIPPETS2);
|
|
3753
|
+
|
|
3754
|
+
// ../../lib/contracts/src/permissions.ts
|
|
3755
|
+
var SUPER_ROLE = {
|
|
3756
|
+
ADMIN: "org:super_admin"
|
|
3757
|
+
};
|
|
3758
|
+
var ORG_ROLE = {
|
|
3759
|
+
ADMIN: "org:admin",
|
|
3760
|
+
OWNER: "org:owner"};
|
|
3761
|
+
var EDITOR_ROLES = [
|
|
3762
|
+
SUPER_ROLE.ADMIN,
|
|
3763
|
+
ORG_ROLE.ADMIN,
|
|
3764
|
+
ORG_ROLE.OWNER
|
|
3765
|
+
];
|
|
3766
|
+
[
|
|
3767
|
+
...Object.values(SUPER_ROLE),
|
|
3768
|
+
...Object.values(EDITOR_ROLES)
|
|
3769
|
+
];
|
|
3770
|
+
|
|
3771
|
+
// ../../lib/utils-services/src/auth/index.ts
|
|
3772
|
+
var STANDALONE_CLIENT = {
|
|
3773
|
+
MCP: "@stackable-labs/mcp-app-extension"
|
|
3774
|
+
};
|
|
3775
|
+
|
|
3776
|
+
// package.json
|
|
3777
|
+
var package_default = {
|
|
3778
|
+
version: "0.0.0"};
|
|
3779
|
+
|
|
3780
|
+
// src/server.ts
|
|
3781
|
+
var MCP_CLIENT_NAME = STANDALONE_CLIENT.MCP;
|
|
3782
|
+
var DEFAULT_ADMIN_API_URL = "https://api-use1.stackablelabs.io/admin";
|
|
3783
|
+
var textContent = (text) => ({ content: [{ type: "text", text }] });
|
|
3784
|
+
var errorContent = (text) => ({ content: [{ type: "text", text }], isError: true });
|
|
3785
|
+
var createMcpServer = (options = {}) => {
|
|
3786
|
+
const server = new McpServer({
|
|
3787
|
+
name: "stackable-extension-dev",
|
|
3788
|
+
version: package_default.version
|
|
3789
|
+
});
|
|
3790
|
+
const getAuthToken = async () => {
|
|
3791
|
+
if (options.authContext?.token) {
|
|
3792
|
+
return options.authContext.token;
|
|
3793
|
+
}
|
|
3794
|
+
const { readFile } = await import('fs/promises');
|
|
3795
|
+
const { join } = await import('path');
|
|
3796
|
+
const { homedir } = await import('os');
|
|
3797
|
+
const authFile = join(homedir(), ".stackable", "auth.json");
|
|
3798
|
+
let state;
|
|
3799
|
+
try {
|
|
3800
|
+
const content = await readFile(authFile, "utf8");
|
|
3801
|
+
state = JSON.parse(content);
|
|
3802
|
+
} catch {
|
|
3803
|
+
throw new Error("Not authenticated. Run 'stackable-app-extension auth login' first.");
|
|
3804
|
+
}
|
|
3805
|
+
const [, payload] = state.token.split(".");
|
|
3806
|
+
if (payload) {
|
|
3807
|
+
try {
|
|
3808
|
+
const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
3809
|
+
if (decoded.exp && Date.now() >= decoded.exp * 1e3) {
|
|
3810
|
+
throw new Error("Session expired. Run 'stackable-app-extension auth login' to re-authenticate.");
|
|
3811
|
+
}
|
|
3812
|
+
} catch (err) {
|
|
3813
|
+
if (err instanceof Error && err.message.includes("expired")) {
|
|
3814
|
+
throw err;
|
|
3815
|
+
}
|
|
3816
|
+
}
|
|
3817
|
+
}
|
|
3818
|
+
return state.token;
|
|
3819
|
+
};
|
|
3820
|
+
const authHeaders = (token) => ({
|
|
3821
|
+
authorization: `Bearer ${token}`,
|
|
3822
|
+
"content-type": "application/json",
|
|
3823
|
+
"x-client-name": MCP_CLIENT_NAME
|
|
3824
|
+
});
|
|
3825
|
+
const callApi = async (path) => {
|
|
3826
|
+
const token = await getAuthToken();
|
|
3827
|
+
const baseUrl = process.env.ADMIN_API_BASE_URL ?? DEFAULT_ADMIN_API_URL;
|
|
3828
|
+
const res = await fetch(`${baseUrl}${path}`, { headers: authHeaders(token) });
|
|
3829
|
+
if (!res.ok) {
|
|
3830
|
+
return errorContent(`API error: ${res.status} ${res.statusText}`);
|
|
3831
|
+
}
|
|
3832
|
+
const data = await res.json();
|
|
3833
|
+
return textContent(JSON.stringify(data, null, 2));
|
|
3834
|
+
};
|
|
3835
|
+
const withAuth = async (fn) => {
|
|
3836
|
+
try {
|
|
3837
|
+
return await fn();
|
|
3838
|
+
} catch (err) {
|
|
3839
|
+
return errorContent(err.message);
|
|
3840
|
+
}
|
|
3841
|
+
};
|
|
3842
|
+
const registrySkills = listSkills("registry");
|
|
3843
|
+
for (const skill of registrySkills) {
|
|
3844
|
+
server.registerResource(
|
|
3845
|
+
skill.id,
|
|
3846
|
+
`sdk://${skill.id}`,
|
|
3847
|
+
{ description: skill.description, mimeType: "text/markdown" },
|
|
3848
|
+
async (uri) => ({
|
|
3849
|
+
contents: [{ uri: uri.href, text: getSkillBody(skill), mimeType: "text/markdown" }]
|
|
3850
|
+
})
|
|
3851
|
+
);
|
|
3852
|
+
}
|
|
3853
|
+
server.registerTool("list_skills", {
|
|
3854
|
+
description: "List all available SDK skills with descriptions"
|
|
3855
|
+
}, async () => textContent(JSON.stringify(registrySkills.map(getSkillMetadata), null, 2)));
|
|
3856
|
+
server.registerTool("lookup_skill", {
|
|
3857
|
+
description: "Look up a specific SDK skill by ID or search by keyword. Returns full skill content for ID lookup, or matching skill metadata for keyword search.",
|
|
3858
|
+
inputSchema: { id: z.string().optional().describe('Exact skill ID (e.g. "capabilities", "patterns")'), query: z.string().optional().describe("Search keywords to find relevant skills") }
|
|
3859
|
+
}, async ({ id, query }) => {
|
|
3860
|
+
if (id) {
|
|
3861
|
+
const body = lookupSkill(registrySkills, id);
|
|
3862
|
+
if (!body) {
|
|
3863
|
+
return errorContent(`No skill found with ID "${id}". Use list_skills to see available IDs.`);
|
|
3864
|
+
}
|
|
3865
|
+
return textContent(body);
|
|
3866
|
+
}
|
|
3867
|
+
if (query) {
|
|
3868
|
+
const matches = findRelevantSkills(registrySkills, query);
|
|
3869
|
+
if (matches.length === 0) {
|
|
3870
|
+
return errorContent(`No skills matched "${query}". Use list_skills to see available skills.`);
|
|
3871
|
+
}
|
|
3872
|
+
return textContent(JSON.stringify(matches.map(getSkillMetadata), null, 2));
|
|
3873
|
+
}
|
|
3874
|
+
return errorContent('Provide either "id" or "query" parameter.');
|
|
3875
|
+
});
|
|
3876
|
+
server.registerTool("validate_manifest", {
|
|
3877
|
+
description: "Validate an extension manifest.json. Checks required fields, valid permissions, targets, and allowedDomains consistency with permissions.",
|
|
3878
|
+
inputSchema: { manifestContent: z.string().describe("The full manifest.json content as a string") }
|
|
3879
|
+
}, async ({ manifestContent }) => {
|
|
3880
|
+
const errors = [];
|
|
3881
|
+
let manifest;
|
|
3882
|
+
try {
|
|
3883
|
+
manifest = JSON.parse(manifestContent);
|
|
3884
|
+
} catch {
|
|
3885
|
+
return errorContent("Invalid JSON: could not parse manifest content.");
|
|
3886
|
+
}
|
|
3887
|
+
if (!manifest.name || typeof manifest.name !== "string") {
|
|
3888
|
+
errors.push('Missing or invalid "name" field (must be a non-empty string).');
|
|
3889
|
+
}
|
|
3890
|
+
if (!manifest.version || typeof manifest.version !== "string") {
|
|
3891
|
+
errors.push('Missing or invalid "version" field (must be a non-empty string).');
|
|
3892
|
+
}
|
|
3893
|
+
if (!Array.isArray(manifest.targets) || manifest.targets.length === 0) {
|
|
3894
|
+
errors.push('Missing or empty "targets" array. At least one surface target is required.');
|
|
3895
|
+
}
|
|
3896
|
+
if (!Array.isArray(manifest.permissions)) {
|
|
3897
|
+
errors.push('Missing "permissions" array.');
|
|
3898
|
+
} else {
|
|
3899
|
+
const validPermissions = new Set(PERMISSIONS);
|
|
3900
|
+
for (const perm of manifest.permissions) {
|
|
3901
|
+
if (!validPermissions.has(perm)) {
|
|
3902
|
+
errors.push(`Invalid permission "${perm}". Valid permissions: ${PERMISSIONS.join(", ")}`);
|
|
3903
|
+
}
|
|
3904
|
+
}
|
|
3905
|
+
}
|
|
3906
|
+
if (!Array.isArray(manifest.allowedDomains)) {
|
|
3907
|
+
errors.push('Missing "allowedDomains" array. Use an empty array if no external API calls are needed.');
|
|
3908
|
+
}
|
|
3909
|
+
const permissions = manifest.permissions ?? [];
|
|
3910
|
+
if (permissions.includes("data:fetch") && Array.isArray(manifest.allowedDomains) && manifest.allowedDomains.length === 0) {
|
|
3911
|
+
errors.push('"data:fetch" permission declared but "allowedDomains" is empty. Add the domains your extension needs to fetch from.');
|
|
3912
|
+
}
|
|
3913
|
+
if (errors.length === 0) {
|
|
3914
|
+
return textContent("Manifest is valid.");
|
|
3915
|
+
}
|
|
3916
|
+
return errorContent(`Manifest validation failed:
|
|
3917
|
+
${errors.map((e) => `- ${e}`).join("\n")}`);
|
|
3918
|
+
});
|
|
3919
|
+
server.registerTool("validate_permissions", {
|
|
3920
|
+
description: "Static analysis: match declared manifest permissions against actual capability and event hook usage in source files. Detects unused permissions and missing permissions.",
|
|
3921
|
+
inputSchema: {
|
|
3922
|
+
manifestContent: z.string().describe("The full manifest.json content as a string"),
|
|
3923
|
+
sourceFiles: z.record(z.string(), z.string()).describe("Map of filename \u2192 source content for all extension source files")
|
|
3924
|
+
}
|
|
3925
|
+
}, async ({ manifestContent, sourceFiles }) => {
|
|
3926
|
+
let manifest;
|
|
3927
|
+
try {
|
|
3928
|
+
manifest = JSON.parse(manifestContent);
|
|
3929
|
+
} catch {
|
|
3930
|
+
return errorContent("Invalid JSON: could not parse manifest content.");
|
|
3931
|
+
}
|
|
3932
|
+
const declaredPermissions = new Set(manifest.permissions ?? []);
|
|
3933
|
+
const usedPermissions = /* @__PURE__ */ new Set();
|
|
3934
|
+
const allSource = Object.values(sourceFiles).join("\n");
|
|
3935
|
+
for (const [capability, permission] of Object.entries(CAPABILITY_PERMISSION_MAP)) {
|
|
3936
|
+
if (allSource.includes(capability)) {
|
|
3937
|
+
usedPermissions.add(permission);
|
|
3938
|
+
}
|
|
3939
|
+
}
|
|
3940
|
+
const eventHookMap = {
|
|
3941
|
+
useIdentityEvent: "events:identity",
|
|
3942
|
+
useMessagingEvent: "events:messaging",
|
|
3943
|
+
useActivityEvent: "events:activity"
|
|
3944
|
+
};
|
|
3945
|
+
for (const [hook, permission] of Object.entries(eventHookMap)) {
|
|
3946
|
+
if (allSource.includes(hook)) {
|
|
3947
|
+
usedPermissions.add(permission);
|
|
3948
|
+
}
|
|
3949
|
+
}
|
|
3950
|
+
const issues = [];
|
|
3951
|
+
for (const perm of declaredPermissions) {
|
|
3952
|
+
if (!usedPermissions.has(perm)) {
|
|
3953
|
+
issues.push(`Unused permission "${perm}" \u2014 declared in manifest but no matching capability/hook usage found in source.`);
|
|
3954
|
+
}
|
|
3955
|
+
}
|
|
3956
|
+
for (const perm of usedPermissions) {
|
|
3957
|
+
if (!declaredPermissions.has(perm)) {
|
|
3958
|
+
issues.push(`Missing permission "${perm}" \u2014 capability/hook used in source but not declared in manifest.`);
|
|
3959
|
+
}
|
|
3960
|
+
}
|
|
3961
|
+
if (issues.length === 0) {
|
|
3962
|
+
return textContent("Permissions are consistent with source code usage.");
|
|
3963
|
+
}
|
|
3964
|
+
return textContent(`Permission analysis:
|
|
3965
|
+
${issues.map((i) => `- ${i}`).join("\n")}`);
|
|
3966
|
+
});
|
|
3967
|
+
server.registerTool("list_apps", {
|
|
3968
|
+
description: "List available apps for development with IDs, names, and targets. Requires CLI auth."
|
|
3969
|
+
}, async () => withAuth(() => callApi("/app-extension")));
|
|
3970
|
+
server.registerTool("list_extensions", {
|
|
3971
|
+
description: "List extensions under an app with status, version, and bundle URL. Requires CLI auth.",
|
|
3972
|
+
inputSchema: { appId: z.string().describe("App ID") }
|
|
3973
|
+
}, async ({ appId }) => withAuth(() => callApi(`/app-extension/${appId}/extensions`)));
|
|
3974
|
+
server.registerTool("get_extension", {
|
|
3975
|
+
description: "Get extension detail including manifest, permissions, and targets. Requires CLI auth.",
|
|
3976
|
+
inputSchema: { appId: z.string().describe("App ID"), extensionId: z.string().describe("Extension ID") }
|
|
3977
|
+
}, async ({ appId, extensionId }) => withAuth(() => callApi(`/app-extension/${appId}/extensions/${extensionId}`)));
|
|
3978
|
+
server.registerTool("list_instances", {
|
|
3979
|
+
description: "List instances under an app. Requires CLI auth.",
|
|
3980
|
+
inputSchema: { appId: z.string().describe("App ID") }
|
|
3981
|
+
}, async ({ appId }) => withAuth(() => callApi(`/app-extension/${appId}/instances`)));
|
|
3982
|
+
return server;
|
|
3983
|
+
};
|
|
3984
|
+
|
|
3985
|
+
export { createMcpServer };
|