appilot-mcp 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/LICENSE +15 -0
  4. package/README.md +133 -27
  5. package/dist/appilot-configurator.mcpb +0 -0
  6. package/dist/cli.d.ts +34 -0
  7. package/dist/cli.js +173 -0
  8. package/dist/client.d.ts +45 -1
  9. package/dist/client.js +74 -1
  10. package/dist/config.d.ts +21 -0
  11. package/dist/config.js +6 -0
  12. package/dist/contract/healthContract.js +31 -4
  13. package/dist/index.bundle.js +2111 -826
  14. package/dist/index.d.ts +7 -1
  15. package/dist/index.js +22 -4
  16. package/dist/manifest.d.ts +14 -2
  17. package/dist/manifest.js +31 -9
  18. package/dist/public-marketplace/.claude-plugin/marketplace.json +20 -0
  19. package/dist/public-marketplace/README.md +23 -0
  20. package/dist/public-marketplace/plugins/app-configurator/.claude-plugin/plugin.json +43 -0
  21. package/dist/public-marketplace/plugins/app-configurator/README.md +328 -0
  22. package/dist/public-marketplace/plugins/app-configurator/dist/index.bundle.js +57370 -0
  23. package/dist/public-marketplace/plugins/app-configurator/skills/app-configurator/SKILL.md +267 -0
  24. package/dist/public-marketplace/plugins/app-configurator/skills/app-configurator/agents/openai.yaml +13 -0
  25. package/dist/redaction.d.ts +51 -0
  26. package/dist/redaction.js +59 -0
  27. package/dist/remote/consent.d.ts +10 -2
  28. package/dist/remote/consent.js +16 -6
  29. package/dist/remote/consentMessages.d.ts +6 -1
  30. package/dist/remote/consentMessages.js +15 -6
  31. package/dist/remote/httpServer.d.ts +10 -0
  32. package/dist/remote/httpServer.js +126 -40
  33. package/dist/remote/oauth.d.ts +10 -1
  34. package/dist/remote/oauth.js +29 -11
  35. package/dist/scaffold.d.ts +68 -6
  36. package/dist/scaffold.js +424 -97
  37. package/dist/server.js +175 -18
  38. package/dist/userClient.d.ts +213 -0
  39. package/dist/userClient.js +400 -0
  40. package/dist/userServer.d.ts +47 -0
  41. package/dist/userServer.js +248 -0
  42. package/dist/version.d.ts +1 -1
  43. package/dist/version.js +1 -1
  44. package/examples/app.appilot.json +212 -0
  45. package/mcpb/manifest.json +117 -21
  46. package/package.json +5 -3
  47. package/skills/app-configurator/SKILL.md +61 -19
package/dist/scaffold.js CHANGED
@@ -10,7 +10,62 @@
10
10
  * integration a developer must write in their own backend, it is
11
11
  * security-critical, and getting it wrong is invisible until an auth edge case
12
12
  * shows up in production.
13
+ *
14
+ * Everything returned here has to COMPILE. Three of the six framework outputs
15
+ * did not typecheck under `strict` when the 2026-09-07 audit ran them through
16
+ * `tsc`, and the relay, the piece a developer is least able to review, was the
17
+ * file that failed. `test/scaffoldTypecheck.test.ts` now compiles every output
18
+ * against the workspace sources of `appilot` and `appilot-server`.
19
+ */
20
+ import { IDENTIFIER_KINDS } from 'appilot-shared/validation';
21
+ /**
22
+ * The frameworks with a first-class relay, plus `other`.
23
+ *
24
+ * `appilot-server` is a Node package, so a Django, Rails or PHP host cannot use
25
+ * it. That is not a reason to answer a raw Zod enum dump: the exchange is one
26
+ * authenticated HTTPS call, and `other` returns it as curl plus a Python and a
27
+ * Ruby handler.
13
28
  */
29
+ export const SCAFFOLD_FRAMEWORKS = [
30
+ 'next',
31
+ 'express',
32
+ 'fastify',
33
+ 'hono',
34
+ 'remix',
35
+ 'sveltekit',
36
+ 'other',
37
+ ];
38
+ function publicKeyIdiom(framework) {
39
+ switch (framework) {
40
+ case 'next':
41
+ return {
42
+ envName: 'NEXT_PUBLIC_APPILOT_WIDGET_KEY',
43
+ expr: 'process.env.NEXT_PUBLIC_APPILOT_WIDGET_KEY',
44
+ };
45
+ case 'sveltekit':
46
+ return {
47
+ envName: 'PUBLIC_APPILOT_WIDGET_KEY',
48
+ expr: 'publicEnv.PUBLIC_APPILOT_WIDGET_KEY',
49
+ importLine: "import { env as publicEnv } from '$env/dynamic/public';",
50
+ };
51
+ default:
52
+ return {
53
+ envName: 'VITE_APPILOT_WIDGET_KEY',
54
+ expr: 'import.meta.env.VITE_APPILOT_WIDGET_KEY',
55
+ };
56
+ }
57
+ }
58
+ /**
59
+ * The two ways a developer runs this against a machine they are sitting at.
60
+ * Both are true at once and the difference between them is what the turn can
61
+ * see, so the notes state both rather than picking one.
62
+ */
63
+ function localDevelopmentNotes(idiom) {
64
+ return [
65
+ 'Local development, first choice: run the app on a hostname that resolves to 127.0.0.1 (myapp.lvh.me:3000, or an /etc/hosts entry such as myapp.local) and register THAT hostname as a domain of the app. The page then resolves its tenant by its own hostname, no widget key is needed at all, DNS verification is not required for this path, and the turn gets the full app context: views, controls, forms, action plans and knowledge.',
66
+ `Local development, fallback: on bare localhost no domain resolves, so the page needs a wk_test_ key in ${idiom.envName}. The turn gets the organization and the signed-in user and NO app context, because there is no domain to resolve it from. Use this only where the hostname above is impossible.`,
67
+ ];
68
+ }
14
69
  function relayFetchHandler(namespace) {
15
70
  return `import { createWidgetTokenHandler } from 'appilot-server';
16
71
 
@@ -31,40 +86,68 @@ export const POST = createWidgetTokenHandler({
31
86
  });
32
87
  `;
33
88
  }
89
+ /**
90
+ * Express and Fastify share the Node adapter and differ only in how the handler
91
+ * is mounted.
92
+ *
93
+ * Two things here are not decoration. The session lives on a property the host's
94
+ * own middleware added, which `IncomingMessage` does not declare, so the cast
95
+ * names the shape instead of leaving the read as an error. And Fastify owns the
96
+ * response until it is told otherwise: writing to `reply.raw` without
97
+ * `reply.hijack()` first is how a correct-looking relay produces "Reply was
98
+ * already sent" in production.
99
+ */
34
100
  function relayNodeHandler(namespace, framework) {
35
- const mount = framework === 'express'
36
- ? `app.post('/api/widget/token', relay);`
37
- : `fastify.post('/api/widget/token', (request, reply) => relay(request.raw, reply.raw));`;
38
- return `import { createNodeWidgetTokenHandler } from 'appilot-server/node';
101
+ const head = `import type { IncomingMessage } from 'node:http';
102
+ import { createNodeWidgetTokenHandler } from 'appilot-server/node';
103
+
104
+ /** What your session middleware puts on the request. Yours will differ. */
105
+ type RequestWithSession = IncomingMessage & {
106
+ session?: { user?: { id: string | number; name?: string } };
107
+ };
39
108
 
40
109
  // resolveUser is the security boundary. Derive the user from a credential you
41
110
  // trust (req.session, req.user). Never from the request body.
42
111
  const relay = createNodeWidgetTokenHandler({
43
- apiUrl: process.env.APPILOT_API_URL,
44
- widgetKey: process.env.APPILOT_WIDGET_KEY,
45
- widgetSecret: process.env.APPILOT_WIDGET_SECRET, // server-side only
112
+ apiUrl: process.env.APPILOT_API_URL!,
113
+ widgetKey: process.env.APPILOT_WIDGET_KEY!,
114
+ widgetSecret: process.env.APPILOT_WIDGET_SECRET!, // server-side only
46
115
  resolveUser: req => {
47
- const user = req.session?.user; // your auth, unchanged
48
- if (!user) return null; // 401 IDENTITY_REQUIRED
116
+ const user = (req as RequestWithSession).session?.user; // your auth, unchanged
117
+ if (!user) return null; // 401 IDENTITY_REQUIRED
49
118
  return { externalId: \`${namespace}:\${user.id}\`, displayName: user.name };
50
119
  },
51
120
  });
121
+ `;
122
+ if (framework === 'express') {
123
+ return `${head}
124
+ import type { Express } from 'express';
125
+
126
+ export function mountWidgetToken(app: Express): void {
127
+ app.post('/api/widget/token', relay);
128
+ }
129
+ `;
130
+ }
131
+ return `${head}
132
+ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
52
133
 
53
- ${mount}
134
+ export function mountWidgetToken(fastify: FastifyInstance): void {
135
+ fastify.post('/api/widget/token', async (request: FastifyRequest, reply: FastifyReply) => {
136
+ // The relay answers the raw response itself, so Fastify has to be told it
137
+ // no longer owns the reply. Without hijack() it reports "Reply was already
138
+ // sent" on every call.
139
+ reply.hijack();
140
+ await relay(request.raw, reply.raw);
141
+ });
142
+ }
54
143
  `;
55
144
  }
56
145
  function bootFile(options) {
57
- const lines = [
58
- "import { bootAppilotWidget } from 'appilot';",
59
- '',
60
- '// Call once, after your app knows the user is signed in. The widget',
61
- '// requires an identified user; the relay below mints that identity.',
62
- 'export function startAppilot() {',
63
- ' bootAppilotWidget({',
64
- ` widgetScriptUrl: '${options.widgetScriptUrl}',`,
65
- ];
66
- if (options.widgetKey)
67
- lines.push(` widgetKey: '${options.widgetKey}',`);
146
+ const idiom = publicKeyIdiom(options.framework);
147
+ const lines = ["import { bootAppilotWidget } from 'appilot';"];
148
+ if (idiom.importLine)
149
+ lines.push(idiom.importLine);
150
+ lines.push('', '// The widget key is publishable: it names the app to Appilot and is meant to', '// sit in the page. The widget SECRET is a server credential and never appears', `// in this file. Leave ${idiom.envName} unset when the page runs on a hostname`, '// you registered as a domain: the tenant then resolves from the hostname and', '// the turn gets the full app context.', `const widgetKey = ${idiom.expr};`, '', '// Call once, after your app knows the user is signed in. The widget', '// requires an identified user; the relay mints that identity.', 'export function startAppilot() {', ' bootAppilotWidget({', ` widgetScriptUrl: '${options.widgetScriptUrl}',`, ' widgetKey,');
68
151
  if (options.apiUrl)
69
152
  lines.push(` appilotApiUrl: '${options.apiUrl}',`);
70
153
  lines.push(" tokenEndpoint: '/api/widget/token',", ' });', '}');
@@ -96,8 +179,141 @@ export function registerBookingActions(navigate: (path: string) => void) {
96
179
  return () => handle.unregister(); // unregister on route change
97
180
  }
98
181
  `;
182
+ /** The raw exchange, for a host `appilot-server` cannot serve. */
183
+ function rawExchangeFiles(namespace, apiUrl) {
184
+ return [
185
+ {
186
+ path: 'appilot/widget-token.sh',
187
+ language: 'bash',
188
+ contents: `# The whole relay, as one call. Run it from your BACKEND, never a browser:
189
+ # the widget secret authenticates the exchange and must never leave the server.
190
+ curl -sS -X POST '${apiUrl}/widget/token' \\
191
+ -H 'Content-Type: application/json' \\
192
+ -H "X-Widget-Key: $APPILOT_WIDGET_KEY" \\
193
+ -H "X-Widget-Secret: $APPILOT_WIDGET_SECRET" \\
194
+ -d '{"externalId":"${namespace}:4711","displayName":"Ada Lovelace"}'
195
+
196
+ # 200 -> {"token":"...","expiresIn":900}. Return that body to the page as-is.
197
+ `,
198
+ },
199
+ {
200
+ path: 'appilot/widget_token.py',
201
+ language: 'python',
202
+ contents: `"""Appilot identity relay, Django view. Flask and FastAPI differ only in the
203
+ decorator and the response helper.
204
+
205
+ The user is resolved from the session the host already trusts. Never from the
206
+ request body: the body is client-supplied, so reading an id from it would let
207
+ any caller ask for any user's token.
208
+ """
209
+
210
+ import os
211
+ import json
212
+ import urllib.request
213
+
214
+ APPILOT_API_URL = os.environ["APPILOT_API_URL"]
215
+ WIDGET_KEY = os.environ["APPILOT_WIDGET_KEY"]
216
+ WIDGET_SECRET = os.environ["APPILOT_WIDGET_SECRET"] # server-side only
217
+ NAMESPACE = "${namespace}"
218
+
219
+
220
+ def widget_token(request):
221
+ from django.http import JsonResponse
222
+
223
+ user = getattr(request, "user", None)
224
+ if user is None or not user.is_authenticated:
225
+ return JsonResponse({"code": "IDENTITY_REQUIRED"}, status=401)
226
+
227
+ payload = json.dumps(
228
+ {
229
+ "externalId": f"{NAMESPACE}:{user.pk}",
230
+ "displayName": user.get_full_name() or user.get_username(),
231
+ }
232
+ ).encode()
233
+
234
+ req = urllib.request.Request(
235
+ f"{APPILOT_API_URL}/widget/token",
236
+ data=payload,
237
+ headers={
238
+ "Content-Type": "application/json",
239
+ "X-Widget-Key": WIDGET_KEY,
240
+ "X-Widget-Secret": WIDGET_SECRET,
241
+ },
242
+ method="POST",
243
+ )
244
+ try:
245
+ with urllib.request.urlopen(req, timeout=10) as response:
246
+ body = json.load(response)
247
+ except Exception:
248
+ # Never forward the upstream message: it is not yours to surface.
249
+ return JsonResponse({"code": "WIDGET_TOKEN_EXCHANGE_FAILED"}, status=502)
250
+
251
+ return JsonResponse({"token": body["token"], "expiresIn": body["expiresIn"]})
252
+ `,
253
+ },
254
+ {
255
+ path: 'appilot/widget_token_controller.rb',
256
+ language: 'ruby',
257
+ contents: `# Appilot identity relay, Rails controller.
258
+ #
259
+ # current_user comes from the session Rails already established. Never from
260
+ # params: those are client-supplied, and reading an id from them would let any
261
+ # caller ask for any user's token.
262
+ require "net/http"
263
+ require "json"
264
+
265
+ class WidgetTokenController < ApplicationController
266
+ NAMESPACE = "${namespace}".freeze
267
+
268
+ def create
269
+ return render(json: { code: "IDENTITY_REQUIRED" }, status: :unauthorized) unless current_user
270
+
271
+ uri = URI("#{ENV.fetch('APPILOT_API_URL')}/widget/token")
272
+ request = Net::HTTP::Post.new(uri)
273
+ request["Content-Type"] = "application/json"
274
+ request["X-Widget-Key"] = ENV.fetch("APPILOT_WIDGET_KEY")
275
+ request["X-Widget-Secret"] = ENV.fetch("APPILOT_WIDGET_SECRET") # server-side only
276
+ request.body = {
277
+ externalId: "#{NAMESPACE}:#{current_user.id}",
278
+ displayName: current_user.name
279
+ }.to_json
280
+
281
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 10) do |http|
282
+ http.request(request)
283
+ end
284
+
285
+ unless response.is_a?(Net::HTTPSuccess)
286
+ # Never forward the upstream message.
287
+ return render(json: { code: "WIDGET_TOKEN_EXCHANGE_FAILED" }, status: :bad_gateway)
288
+ end
289
+
290
+ body = JSON.parse(response.body)
291
+ render json: { token: body["token"], expiresIn: body["expiresIn"] }
292
+ end
293
+ end
294
+ `,
295
+ },
296
+ ];
297
+ }
298
+ function rawBootFile(options) {
299
+ const keyAttr = options.widgetKey ? `\n data-api-key="${options.widgetKey}"` : '';
300
+ const apiAttr = options.apiUrl ? `\n data-api-url="${options.apiUrl}"` : '';
301
+ return {
302
+ path: 'appilot/widget-snippet.html',
303
+ language: 'html',
304
+ contents: `<!-- Paste into the layout template your signed-in pages render.
305
+ The relay above must answer at /api/widget/token on the same origin. -->
306
+ <script
307
+ src="${options.widgetScriptUrl}"${keyAttr}${apiAttr}
308
+ async
309
+ ></script>
310
+ `,
311
+ };
312
+ }
99
313
  export function scaffoldIntegration(options) {
100
314
  const namespace = options.idNamespace ?? 'app';
315
+ const apiUrl = options.apiUrl ?? 'https://api.appilot.space';
316
+ const idiom = publicKeyIdiom(options.framework);
101
317
  const files = [];
102
318
  switch (options.framework) {
103
319
  case 'next':
@@ -134,18 +350,21 @@ export const action = ({ request }: { request: Request }) => handler(request);
134
350
  language: 'typescript',
135
351
  contents: `import { createWidgetTokenHandler } from 'appilot-server';
136
352
  import { env } from '$env/dynamic/private';
353
+ import type { RequestHandler } from './$types';
137
354
 
138
355
  const handler = createWidgetTokenHandler({
139
- apiUrl: env.APPILOT_API_URL,
140
- widgetKey: env.APPILOT_WIDGET_KEY,
141
- widgetSecret: env.APPILOT_WIDGET_SECRET,
356
+ // $env/dynamic/private is Record<string, string | undefined>, so each value is
357
+ // asserted here rather than handed to the relay as possibly-undefined.
358
+ apiUrl: env.APPILOT_API_URL!,
359
+ widgetKey: env.APPILOT_WIDGET_KEY!,
360
+ widgetSecret: env.APPILOT_WIDGET_SECRET!,
142
361
  resolveUser: async request => {
143
362
  const user = await getUserFromCookies(request.headers.get('cookie'));
144
363
  return user ? { externalId: \`${namespace}:\${user.id}\`, displayName: user.name } : null;
145
364
  },
146
365
  });
147
366
 
148
- export const POST = ({ request }) => handler(request);
367
+ export const POST: RequestHandler = ({ request }) => handler(request);
149
368
  `,
150
369
  });
151
370
  break;
@@ -178,68 +397,135 @@ export const widgetToken = new Hono().post('/api/widget/token', c => handler(c.r
178
397
  contents: relayNodeHandler(namespace, options.framework),
179
398
  });
180
399
  break;
400
+ case 'other':
401
+ files.push(...rawExchangeFiles(namespace, apiUrl));
402
+ break;
403
+ }
404
+ if (options.framework === 'other') {
405
+ files.push(rawBootFile(options));
406
+ }
407
+ else {
408
+ files.push({ path: 'src/appilot/boot.ts', language: 'typescript', contents: bootFile(options) });
409
+ files.push({
410
+ path: 'src/appilot/actions.ts',
411
+ language: 'typescript',
412
+ contents: CLIENT_ACTION_EXAMPLE,
413
+ });
414
+ }
415
+ const serverEnv = [
416
+ `APPILOT_API_URL=${apiUrl}`,
417
+ `APPILOT_WIDGET_KEY=${options.widgetKey ?? 'wk_test_...'}`,
418
+ 'APPILOT_WIDGET_SECRET=wsk_secret_... # server-side only, never in a client bundle',
419
+ ];
420
+ if (options.framework !== 'other') {
421
+ serverEnv.push(`${idiom.envName}=${options.widgetKey ?? 'wk_test_...'} # the same publishable key, this time for the page`);
422
+ }
423
+ const notes = [
424
+ 'The widget secret must never appear in a client bundle or a public env var. In Next.js that means no NEXT_PUBLIC_ prefix; in Vite, keep it out of VITE_.',
425
+ 'resolveUser is the security boundary: derive the user from a credential you trust, never from the request body.',
426
+ ...localDevelopmentNotes(idiom),
427
+ 'Call verify_integration once this is wired to confirm the relay answers and the widget actually boots.',
428
+ ];
429
+ if (options.framework === 'other') {
430
+ notes.unshift('appilot-server is a Node package, so this host implements the exchange itself. It is one authenticated POST to {apiUrl}/widget/token carrying X-Widget-Key and X-Widget-Secret, and the response body goes back to the page unchanged.');
181
431
  }
182
- files.push({ path: 'src/appilot/boot.ts', language: 'typescript', contents: bootFile(options) });
183
- files.push({
184
- path: 'src/appilot/actions.ts',
185
- language: 'typescript',
186
- contents: CLIENT_ACTION_EXAMPLE,
187
- });
188
432
  return {
189
433
  framework: options.framework,
190
434
  files,
191
- install: 'npm install appilot appilot-server',
192
- env: [
193
- `APPILOT_API_URL=${options.apiUrl ?? 'https://api.appilot.space'}`,
194
- `APPILOT_WIDGET_KEY=${options.widgetKey ?? 'wk_live_...'}`,
195
- 'APPILOT_WIDGET_SECRET=wsk_secret_... # server-side only, never in a client bundle',
196
- ].join('\n'),
197
- notes: [
198
- 'The widget secret must never appear in a client bundle or a public env var. In Next.js that means no NEXT_PUBLIC_ prefix; in Vite, keep it out of VITE_.',
199
- 'resolveUser is the security boundary: derive the user from a credential you trust, never from the request body.',
200
- 'Call verify_integration once this is wired to confirm the relay answers and the widget actually boots.',
201
- ],
435
+ install: options.framework === 'other'
436
+ ? 'No package to install. The relay is one HTTPS call and the widget loads from the script tag above.'
437
+ : 'npm install appilot appilot-server',
438
+ env: serverEnv.join('\n'),
439
+ notes,
202
440
  };
203
441
  }
204
- export function scaffoldAgentFirst(options) {
205
- const { capability, slug, appId } = options;
206
- const method = options.endpoint?.method?.toUpperCase() ?? 'POST';
207
- const pathTemplate = options.endpoint?.path ?? `/api/${slug}`;
208
- const tool = {
209
- app_id: appId,
210
- tool_name: slug,
211
- title: capability,
212
- description: `Use when the user wants to ${capability.toLowerCase()}.`,
213
- parameters: {
214
- type: 'object',
215
- properties: {},
216
- required: [],
217
- },
218
- runtime_spec: {
219
- kind: 'http_proxy',
220
- method,
221
- path_template: pathTemplate,
222
- body_template: {},
442
+ /** `open-booking` -> `open_booking`, and `Open booking!` -> `open_booking`. */
443
+ function toolNameFrom(slug) {
444
+ return IDENTIFIER_KINDS.tool_name.normalize(slug) || 'do_the_thing';
445
+ }
446
+ /** `open_booking` -> `open-booking`, the shape control and plan ids take. */
447
+ function semanticSlugFrom(slug) {
448
+ return IDENTIFIER_KINDS.semantic_id.normalize(slug) || 'the-thing';
449
+ }
450
+ function planSections(shape, capability, ids, viewPath) {
451
+ if (shape === 'read')
452
+ return null;
453
+ if (shape === 'navigate') {
454
+ return [
455
+ {
456
+ view_path: viewPath,
457
+ steps: [`Open [${capability}]({{click:${ids.open}}}).`],
458
+ },
459
+ ];
460
+ }
461
+ return [
462
+ {
463
+ view_path: viewPath,
464
+ steps: [
465
+ `Open the form with [${capability}]({{click:${ids.open}}}).`,
466
+ `Fill in [the ${capability.toLowerCase()} form]({{form:${ids.form}}}).`,
467
+ `Submit it with [Save]({{click:${ids.submit}}}).`,
468
+ ],
223
469
  },
224
- title_i18n: {},
225
- description_i18n: {},
470
+ ];
471
+ }
472
+ export function scaffoldAgentFirst(options) {
473
+ const { capability, appId } = options;
474
+ const shape = options.shape ?? 'create';
475
+ const viewPath = options.viewPath ?? '/';
476
+ // Both identifier kinds, from the one slug. `tool_name` is snake_case because
477
+ // it reaches the model as a callable name; control, form and plan ids are
478
+ // semantic_ids. Normalising here is what stopped the scaffold from failing
479
+ // the server's own identifier contract on its first call.
480
+ const toolName = toolNameFrom(options.slug);
481
+ const slug = semanticSlugFrom(options.slug);
482
+ // `submit-x` rather than `btn-x-submit`: the contract's own identifier lint
483
+ // treats four segments over twenty-four characters as a slugified UI label,
484
+ // and a two-word capability made the scaffold trip that heuristic on an
485
+ // identifier it had just generated itself.
486
+ const ids = {
487
+ open: `btn-${slug}`,
488
+ form: `form-${slug}`,
489
+ submit: `submit-${slug}`,
226
490
  };
227
- const actionPlan = {
491
+ const method = options.endpoint?.method?.toUpperCase() ?? (shape === 'read' ? 'GET' : 'POST');
492
+ const pathTemplate = options.endpoint?.path ?? `/api/${slug}`;
493
+ const trigger = `Use when the user wants to ${capability.toLowerCase()}.`;
494
+ // A client action and an HTTP-proxy tool are two answers to the same
495
+ // question, and emitting both taught the reader that a capability needs one
496
+ // of each. It needs whichever one matches where the work happens.
497
+ const tool = options.clientSide
498
+ ? null
499
+ : {
500
+ app_id: appId,
501
+ tool_name: toolName,
502
+ title: capability,
503
+ description: trigger,
504
+ parameters: {
505
+ type: 'object',
506
+ properties: {},
507
+ required: [],
508
+ },
509
+ runtime_spec: {
510
+ kind: 'http_proxy',
511
+ method,
512
+ path_template: pathTemplate,
513
+ body_template: {},
514
+ },
515
+ title_i18n: {},
516
+ description_i18n: {},
517
+ };
518
+ const sections = planSections(shape, capability, ids, viewPath);
519
+ const actionPlan = sections && {
228
520
  app_id: appId,
229
- semantic_id: `plan-${slug}`,
521
+ semantic_id: IDENTIFIER_KINDS.plan_id.normalize(`plan-${slug}`),
230
522
  name: capability,
231
- description: `Use when the user wants to ${capability.toLowerCase()}.`,
232
- sections: [
233
- {
234
- title: capability,
235
- steps: [
236
- { text: `Open the form: [${capability}]({{click:btn-${slug}}})` },
237
- { text: `Fill it in: [the ${slug} form]({{form:form-${slug}}})` },
238
- { text: `Submit: [Save]({{click:btn-${slug}-submit}})` },
239
- ],
240
- },
241
- ],
242
- form_values: { [`form-${slug}`]: {} },
523
+ description: trigger,
524
+ sections,
525
+ // Keyed by form id, and each entry carries a `fields` array of
526
+ // { control_id, value }. The empty array is the authored default: the
527
+ // user supplies the values at runtime.
528
+ form_values: shape === 'create' ? { [ids.form]: { fields: [] } } : {},
243
529
  step_narratives_i18n: {},
244
530
  is_active: false,
245
531
  };
@@ -261,6 +547,7 @@ export function scaffoldAgentFirst(options) {
261
547
  };
262
548
  const files = [];
263
549
  if (options.clientSide) {
550
+ const registerName = toolName.replace(/(^|_)([a-z])/g, (_m, _s, c) => c.toUpperCase());
264
551
  files.push({
265
552
  path: `src/appilot/${slug}.ts`,
266
553
  language: 'typescript',
@@ -270,11 +557,11 @@ export function scaffoldAgentFirst(options) {
270
557
  //
271
558
  // This runs in the page, in the user's own session, so it inherits their
272
559
  // permissions and needs no credential of its own. Anything the backend must
273
- // authorize belongs in the HTTP-proxy tool instead.
274
- export function register${slug.replace(/(^|[-_])([a-z])/g, (_m, _s, c) => c.toUpperCase())}() {
560
+ // authorize belongs in an HTTP-proxy tool instead.
561
+ export function register${registerName}() {
275
562
  const handle = registerTool({
276
- name: '${slug}',
277
- description: 'Use when the user wants to ${capability.toLowerCase()}.',
563
+ name: '${toolName}',
564
+ description: '${trigger}',
278
565
  inputSchema: { type: 'object', properties: {}, required: [] },
279
566
  // Omit readOnlyHint, or set it false, when this changes something: the
280
567
  // agent confirms with the user before calling a mutating action.
@@ -288,24 +575,64 @@ export function register${slug.replace(/(^|[-_])([a-z])/g, (_m, _s, c) => c.toUp
288
575
  `,
289
576
  });
290
577
  }
578
+ const order = [];
579
+ if (sections) {
580
+ order.push(`1. Create the controls the plan names (${Object.values(ids).slice(0, shape === 'create' ? 3 : 1).join(', ')}), with stable locators. inspect_page gives you candidates.`);
581
+ if (shape === 'create') {
582
+ order.push(`2. Create the form ${ids.form}, naming those controls as entry, submit and required fields.`);
583
+ }
584
+ }
585
+ order.push(options.clientSide
586
+ ? 'Register the client action in the page. It runs in the user\'s session, so it needs no credential.'
587
+ : 'Create the tool. path_template is a path on the host origin, not an absolute URL, and the executor rejects an absolute one. If it calls an authenticated backend, set auth_secret in the same call, because a tool with no stored credential runs in the page and cannot be used as session preflight.');
588
+ if (sections) {
589
+ order.push('Create the action plan. Run validate_action_plan on the sections first.');
590
+ }
591
+ order.push('Create the knowledge article, and keep the procedure out of it.');
592
+ order.push('Activate the plan and the article once validate_config is clean.');
593
+ const notes = [
594
+ 'The test for agent-first is whether a user could complete this capability end to end through the assistant alone. If not, it has a screen and nothing else.',
595
+ 'Both the plan description and the tool description are how the agent finds them. Write the trigger, in the user\'s words, and localize it.',
596
+ 'is_active is false on the plan and the article on purpose. Turn them on after validate_config passes, not before.',
597
+ ];
598
+ if (shape === 'read') {
599
+ notes.push('No action plan is emitted for a read capability. A plan whose only step opens a screen does nothing, and the health contract reports it. The tool answers the question and the knowledge article explains what the answer means.');
600
+ }
601
+ if (options.clientSide) {
602
+ notes.push('No HTTP-proxy tool is emitted, because the operation runs in the page. Add one only if a second, server-authorized path to the same capability is genuinely needed.');
603
+ }
604
+ notes.push(`The plan's steps run on view_path "${viewPath}". Pass viewPath when the screen is somewhere else; the value must match a registered View.`);
605
+ return { capability, shape, tool, actionPlan, knowledge, files, order, notes };
606
+ }
607
+ /**
608
+ * The script tag and the boot call for an app that is already provisioned.
609
+ *
610
+ * `create_app` returns these, and returning them was the only way to get them,
611
+ * so a developer who had lost the snippet re-ran a provisioning write to read
612
+ * one line of HTML. They are composed from the connection and the key, so this
613
+ * is pure: it calls nothing and needs no scope. The shape matches what the
614
+ * backend's own `buildIntegration` returns, deliberately, so a curl caller and
615
+ * an agent read the same thing.
616
+ */
617
+ export function integrationSnippet(options) {
618
+ const idiom = publicKeyIdiom(options.framework ?? 'other');
619
+ const keyAttr = options.widgetKey ? `\n data-api-key="${options.widgetKey}"` : '';
620
+ const apiAttr = options.apiUrl ? `\n data-api-url="${options.apiUrl}"` : '';
621
+ const bootLines = ["import { bootAppilotWidget } from 'appilot';", '', 'bootAppilotWidget({'];
622
+ bootLines.push(` widgetScriptUrl: '${options.widgetScriptUrl}',`);
623
+ bootLines.push(` widgetKey: ${idiom.expr},`);
624
+ if (options.apiUrl)
625
+ bootLines.push(` appilotApiUrl: '${options.apiUrl}',`);
626
+ bootLines.push(" tokenEndpoint: '/api/widget/token',", '});');
291
627
  return {
292
- capability,
293
- tool,
294
- actionPlan,
295
- knowledge,
296
- files,
297
- order: [
298
- '1. Create the controls the plan names, with stable locators. inspect_page gives you candidates.',
299
- '2. Create the form, naming those controls as entry, submit and required fields.',
300
- '3. Create the tool. path_template is a path on the host origin, not an absolute URL, and the executor rejects an absolute one. If it calls an authenticated backend, set auth_secret in the same call, because a tool with no stored credential runs in the page and cannot be used as session preflight.',
301
- '4. Create the action plan. Run validate_action_plan first; a plan that only opens an element does nothing.',
302
- '5. Create the knowledge article, and keep the procedure out of it.',
303
- '6. Activate the plan and the article once validate_config is clean.',
304
- ],
628
+ apiUrl: options.apiUrl ?? null,
629
+ scriptTag: `<script\n src="${options.widgetScriptUrl}"${keyAttr}${apiAttr}\n async\n></script>`,
630
+ bootSnippet: bootLines.join('\n'),
631
+ tokenEndpointHint: 'Serve /api/widget/token from your backend with createWidgetTokenHandler from appilot-server. The widget requires an identified user, and the widget secret must never reach a browser.',
632
+ publicEnvName: idiom.envName,
305
633
  notes: [
306
- 'The test for agent-first is whether a user could complete this capability end to end through the assistant alone. If not, it has a screen and nothing else.',
307
- 'Both the plan description and the tool description are how the agent finds them. Write the trigger, in the user\'s words, and localize it.',
308
- 'is_active is false on the plan and the article on purpose. Turn them on after validate_config passes, not before.',
634
+ 'The script tag is the no-build form and carries the key as an attribute. The boot call is the bundled form and reads it from the public variable, which is what survives a key rotation.',
635
+ ...localDevelopmentNotes(idiom),
309
636
  ],
310
637
  };
311
638
  }